又又要开发Electron程序了(着重代码签名)
2025-06-20 Electron 跨平台 4.5k 字 14 分钟
2018年底开发了一款内部IM软件,当时使用了Electron开发,可惜内部使用了1年多后,停止维护了。五年之后的今天,又要开发一款套壳浏览器,内嵌现有的网页端,兜兜转转又捡起来了。
1.前言
开发之前也考虑过选型问题,之前开发Electron的时候确实是踩了很多坑的,重新使用Electron开发是有些犹豫的。6年前内部很多业务人员的设备都是win7, 内存没有超过8G的,Electon性能饱受诟病。
开发之前也去对比了Tauri2做了选型比较(对于js开发者)
维度 | Eletron | Tauri2 |
---|
跨平台 | 优秀,win,mac,linux | 优秀,win,mac,linux,and,ios |
体积 | 较大(80M+) | 极小(本体几M,可能要下载webview2,系统所有的应用可复用内核) |
内存 | 高(参考 chrome) | 低 |
启动速度 | 较快 | 快 |
安全性 | 一般(主进程字节码加密) | 较好(二进制) |
生态 | 极丰富 | 较少 |
工具链 | 完整 | 较少 |
上手难度 | 易(web+node) | 难(web+rust) |
兼容性 | 21+ 只支持win10+ | win7 但是 webview2 110 之后只支持 win10,底层内核应该一样的 |
内核兼容性 | 最新Chromium内核,很好 | 依赖于系统内核,不太好 |
谁在用 | QQ,VSCode,Slack,Discord | PostgreSQL |
指标上Tauri2有很大优势,但是我们的项目目标是开发速度快,内核兼容性好。 另外可以预期新的框架一定会存在一些很难解决的巨坑。所以最终还是选择了 Electron,如果是个人项目,我可能会倾向于 Tauri2
另外 Elctron 基于 chromium 内核,所以兼容性取决于 chromium 的兼容性,具体参考下方,酌情选择 Elctron版本
操作系统版本 | Chrome 弃用版本和日期 | 系统弃用日期 | 说明 |
---|
Windows 7 | Chrome 109 是支持此操作系统的最后一个版本。Chrome 109 是在 2023 年 1 月 10 日发布的。 | 2020 年 1 月 | -- |
Windows 8/8.1 | Chrome 109 是支持此操作系统的最后一个版本。Chrome 109 是在 2023 年 1 月 10 日发布的。 | 2023 年 1 月 | -- |
Windows 8/8.1 | Chrome 109 是支持此操作系统的最后一个版本。Chrome 109 是在 2023 年 1 月 10 日发布的。 | 2023 年 1 月 | -- |
macOS Big Sur 11 + | -- | -- | 以下版本不支持 |
Ubuntu 18.04+ | -- | -- | 只支持64位以及18.04以下版本不支持 |
Debian 10+ | -- | -- | 以下版本不支持 |
openSUSE 15.5+ | -- | -- | 以下版本不支持 |
Fedora Linux 39+ | -- | -- | 以下版本不支持 |
其中关键的 108版本对应 Electron22,因此如果有兼容 win7计划,能使用的最新版本是 22
2.项目初始化
需求为内嵌公司网站,需要支持类 Chorme多 Tab 操作,支持各类文件下载。
# 初始化项目
npm create @quick-start/electron@latest
2.1 自定义Tab实现
新版本30.0.0+基于 WebContentView
实现, 旧版本可以通过BrowserView
实现。
const { BaseWindow, WebContentsView } = require('electron')
const win = new BaseWindow({ width: 800, height: 600 })
const tabbarView = new WebContentsView()
View.webContents.loadURL('https://electronjs.org')
win.contentView.addChildView(tabbarView)
const webView = new WebContentsView()
webView.webContents.loadURL('https://github.com/electron/electron')
win.contentView.addChildView(webView)
tabbarView.setBounds({ x: 0, y: 0, width: 800, height: 100 })
webView.setBounds({ x: 100, y: 0, width: 800, height: 500 })
- 实际运行中 webView 的代码要复杂的多,每一个 Tab 对应了一个 webView视图。由全局的 tabs 数据存储
- 每次新增 Tab 即创建一个新的 webView
- 每次切换 Tab 即改变 webView 的显示状态
- 关闭 Tab,移除webView视图,并解除引用
实际代码是通过一个 TabManger 类来维护的, 内部还增加 LRU 排序,设置最大存在视图数量。动态释放超出的视图(最久未使用)。
待办事项
1.模拟 chorme 冻结机制。定时检测 Tab 最后使用状态,超过一定时间主动销毁视图。下次激活视图,重新创建视图。
2.省内存模式,针对内存过小的硬件,全部复用一个视图。
整个容器的内存占用,其实取决于,每个 webView 的 url实际的内存占用,页面越复杂,dom 越多,优化不合理,占用的内存会越高。
根据下面公式,所以内存优化的重点就是如何减少 WebViewContent的数量,作为套壳应用,每个 WebViewContent 的内存使用是不可控的
# deepseek 数据可能存在谬误,但是每个webViewContent对应一个进程是肯定的。
总进程数 =
1 (主进程) +
N (渲染进程,通常 = 窗口/BrowserView/WebViewContent/WebView 的数量) +
1 (GPU 进程) +
K (Node.js 子进程) +
M (Service Workers/扩展等隐藏进程)
2.2 自定义文件下载
如果是标准下载可以不用处理,electron 默认会处理下载行为。比如 window.open 或者 a 标签打开可下载文件 URL。
但是会有一些 web 应用会使用特殊的方案来进行文件下载,比如说 XHR & Blob & a 标签方案
这种方案,ELectron 对于 blob 协议无法下载的,对于主进程来说这只是一个普通的链接。
而在 chrome 中
浏览器内存中的 Blob 数据
↓
URL.createObjectURL()
↓
生成临时的 blob: URL
↓
浏览器解析 blob: 协议
↓
从内存中检索 Blob 数据
↓
触发下载管理器
↓
保存到磁盘
也就是 blob://XXXXXXX
是 chrome 的一个自定义协议。默认 ELecton 是不支持这个协议的。并且也无法从主进程中无法读渲染进程的内存中的Blob数据。
所以第一次尝试的方案是,在sessionwill-download
hook中,执行 js 代码获取 blob 数据,传输给 Node程序,执行下载。在某些场景下,这是可以正常工作的。
但是有个场景,URL.createObjectURL 生成之后,立马进行了 remove 操作。导致在will-download
的场景下,无法获取到blob 数据可能存在谬误,但是每个webViewContent对应一个进程是肯定的。
因此只能对createObjectURL
进行劫持。重写原生的createObjectUR
, 每次创建的时候使用临时变量将 blob 数据存储起来,触发will-download
通过 blob id, 通过 ipc 通讯,获取 blob 数据后,从临时变量中移除,解除内存占用。
预计后面肯定要做下载记录,所以实际的代码实现中,会有一个 Download类来实现这一系列逻辑, 同时引入better-sqlite3
来做本地化存储。
3.构建
Electron 构建常用的两个工具是Electron-builder、Electron-Forge
两者的对比参考文档
因为之前的项目使用的就是 Electron-builder,加上它经过更多的用户验证,因此使用 Electron-buidler 作为打包工具,实际上 Electron-vite 提供有 Eletron-Forge配置教程,可以很方便的切换到 Electron-forge 打包,如有需要,自行切换。
3.1 Electron-Builder
配置说明
# electron-builder.yml
appId: appid # 唯一标识符
productName: xxxxxxx # app名称
extraMetadata:
name: xxxxxxx # 覆盖 package.json name 一般用于区分 beta 和 prod版本可以用此配置(beta prod 两个配置文件)
directories:
buildResources: build
files:
- "out/**/*" # 哪那些文件会被打包进去
asarUnpack:
- resources/** # 哪些资源 会放到asarUnpack目录,比如原生.node 模块
win:
icon: build/icon.ico # windows 应用 icon 推荐使用 ico 格式(包含多种尺寸)
target:
- target: nsis
arch:
- ia32 # 打包32位系统
- x64 # 打包64位系统
artifactName: ${productName}-Setup-${version}-${arch}.${ext} # 打包后exe的文件名
executableName: xxxxxxx # 安装后可执行文件名
signtoolOptions:
sign: ./scripts/sign.mjs # 自定义签名脚本
nsis:
shortcutName: ${productName} # 桌面显示APP名称
deleteAppDataOnUninstall: true # 卸载软件是否删除AppData
oneClick: true # 一键安装
buildUniversalInstaller: false
perMachine: false # 是否以所有用户(需要管理员权限)还是当前用户身份安装 当 oneClick: true 时,perMachine 默认为 false,即默认按用户安装(不需要管理员权限)。如果设为 true,则默认按计算机安装(需要管理员权限)。
allowToChangeInstallationDirectory: false # 是否允许用户选择目录 oneClick: true 时此项无效,始终不允许更改安装目录。
mac:
target:
- target: dmg
arch:
- arm64 # apple m 系列芯片
- x64 # intel 系列芯片
entitlementsInherit: build/entitlements.mac.plist
artifactName: ${productName}-${version}-${arch}.${ext} # 打包后文件名
extendInfo: # 权限说明
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false # 是否公证
identity: null # 签名时使用的证书名称 null 跳过签名
dmg:
icon: build/icon.icns # macos icon
npmRebuild: false # 是否在开始打包应用程序之前重新构建原生依赖项
publish:
provider: generic
url: xxxxxx # 配置文件目录,安装包目录
channel: "latest-${arch}" # 更新通道,需要跟 auto-updater 代码匹配
updaterCacheDirName: xxxxxx # 更新目录
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/ # electron 二进制文件镜像
afterPack: ./scripts/afterPack.js # 打包后脚本,用于移除无用语言包等
为了做数据隔离,配置文件做了两套,方便系统同时安装 beta 和 prod 包。通过 AppID 以及 name 区分。
如果你需要更合理的区分不同channel,建议研究 publish channel 字段。参考文档
afterPack
主要做一些,构建完文件的处理。目前做了以下几个操作。
- 删除无用的语言包,只保留 zh_CN, 以减小体积。
- 复制不同系统架构的原生模块,本地编译失败概率高,直接下载编译过的原生模块,构建完复制到对应目录即可。
待办事项
Electron-vite 推荐主进程 preload node相关的依赖不打包,但是目前依赖解析或者说某些依赖对于 dev dep 设置不规范导致 asar 文件里面包含不需要的依赖,需要在打包环节剔除。
3.2 源码加密
V8 字节码 通过 Node 标准库里的 vm 模块,可以从 script 对象中生成其缓存数据(参考)。该缓存数据可以理解为 v8 的字节码,该方案通过分发字节码的形式来达到源代码保护的目的。
需要注意的是以下几点
- 目前已知的库字节码只支持 cjs, 如果需要字节码,那么需要提前将 esm 转换为 cjs
- 字节码是按照 Electron Node.js 版本生成的, 但不同的架构不一定相互兼容。(比如我 mac intel 64 打的包,win 64 运行jsc 报错)
- 不支持 Function.prototype.toString
- 不能启用最小化混淆
使用方法, 直接引入插件即可
// electron.vite.config.ts
import { defineConfig, bytecodePlugin } from 'electron-vite'
export default defineConfig({
main: {
plugins: [bytecodePlugin()]
},
})
3.3 代码签名
目前只做了 windows 应用签名,macos 只签名没用,还需要上架才行。这一块单独抽出一章介绍。
代码签名依赖于 electron-builder, windows 底层依赖 jsign,mac 依赖 xcode
4.签名(Code Signing)
4.1 window签名
4.1.1 EV证书购买
直接淘宝搜win 代码签名
如果是企业用户,推荐EV证书
(可以直接通过 Windows SmartScreen信任), 个人用户可以依据自己需求购买OV证书
(无法直接信任,用户主动信任的次数多了(信誉累计 ),Windows SmartScreen才会信任)
EV证书分为两大类
优点
:审核通过即可使用,无需硬件。如果希望CI/CD 中完成签名,这个方便一点。
缺点
:资料多 审核严格。审核时间1-3个工作日,需要提供营业执照图片+企业法人手持身份证正面反面 + 法人身份证正反面+企业最近的水电、电话、物业费 等公共事业账单图片 选择提供其一提供即可!
优点
: 只需要提供公司名字,审核快,审核没那么严格
缺点
: 需要拿到硬件,用于签名的机器,必须插上 USB TOKEN,才能进行签名。
以下文档以GlobalSign EV
为例
4.1.2 签名提取
下载驱动
- 访问官网地址globalsign 下载适合的驱动程序。
- 安装完成后,插入
USB TOKEN
, 需要注意受安全策略影响,通过windowsRemote Desktop
远程访问无法识别到 USB Toeken
- 依赖于 IE 浏览器,推荐使用 windows 电脑,或者windows 电脑 Microsoft Edge的 IE模式(设置-默认浏览器-Internet Explorer 兼容性-允许在 Internet Explorer 模式下重新加载网站IE 模式-允许-将签名提取地址加入到名单里面), 提取地址有代理商(淘宝商家或者其他渠道商家)提供。
- 重启 Edge 浏览器,将提取链接粘贴到网址栏,然后进行访问。
- 输入提取密码(提取密码请咨询您对应的销售人员),点击
下一步
,访问确认,点击是
- 确认有 Token 选项,然后点击下一步
- 输入 token 密码(token 初始密码为:88888888 如果您已更改密码则输入您更改后的密码),点击 OK,然后点击下一步(此过程需要耐心等待,尽量不要操作电脑,避免提取页面卡死未响应导致提取证书失败)
- 点击安装我的证书。web 访问确认,点击
是
。提示安装成功。
- 打开驱动软件检查
设置
-设备
-我的令牌
-用户证书
-证书名称(一般为企业名)
如果存在即为安装成功。
SafeNet Authentication Client Tools 一般情况下是不提供私钥,我们只能导出公钥证书无法进行签名。以下签名完全基于硬件,所以必须保持能够正常的读取 USB Token 设备
4.1.3 win签win
- 客户端设置单点登录
设置
-客户端设置
-高级
-勾选单点登录(第三个)
然后保存
- 导出证书
设置
-设备
-我的令牌
-用户证书
-右键导出
,导出 .cer 文件
- 安装 Windows SDK(仅适用于桌面应用程序的 Windows SDK 签名工具),在安装程序的后半部分,有一个屏幕可以在多个工具中选择要放置的内容,但只选择“Windows SDK Signing Tools for Desktop Apps”,然后单击“Install”完成。
- 在环境变量中添加了 “C:\Program Files (x86)\Windows Kits\10\App Certification Kit”。
- 双击下载.cer 证书,安装证书到当前电脑。
- 最后配置签名名称即可,一般为企业名称
# electron-builder.yml
win:
signtoolOptions:
# 证书名称
certificateSubjectName: xxxxxxxxxxx
构建过程中会至少提示输入一次 token 密码(签名提取时的 token 密码, 你可能改过以最新的密码为准)
4.1.4 mac签win
jsign
安装 jsign
Electron builder 配置指定使用 js 脚本自定义签名
# electron-builder.yml
# win 字段下增加选项 signtoolOptions
win:
signtoolOptions:
sign: ./scripts/sign.mjs
jsign硬件token的配置文件
# hardwareToken.cfg
name = HardwareToken
library = /Library/Frameworks/eToken.framework/Versions/A/libeToken.dylib
slotListIndex = 0
如果有多个证书,通过 slotListIndex
指定使用第几个。library 大概率是一样的不用变更。如果不存在就全局搜下libeToken.dylib
自定义签名脚本
// sign.mjs
import { execSync } from 'child_process'
import { signConfig } from './config.mjs' // 自己签名配置,主要是 token 密码,去提取证书-下载驱动那一章查找
import path from 'path'
// 签名脚本,用于macos linux 签名windows app
async function sign(configuration) {
// do not include passwords or other sensitive data in the file
// rather create environment variables with sensitive data
const cfgPath = path.resolve('./scripts/hardwareToken.cfg');
execSync(
// your commande here ! For exemple and with JSign :
`jsign --keystore ${cfgPath} --storepass "${signConfig.TOKEN_PASSWORD}" --storetype PKCS11 --tsaurl http://timestamp.digicert.com --alias "${signConfig.CERTIFICATE_NAME}" "${configuration.path}"`,
{
stdio: "inherit"
}
);
};
export default sign;
实际项目中TOKEN_PASSWORD
推荐使用环境变量。目前项目只有一人开发,且 config.mjs不在 git 版本追踪之中, 因此临时放到了本地配置文件中。
4.2 macos签名
4.2.1 准备工作
4.2.2 准备证书
证书种类说明
证书类型 | 说明 | 签名场景 |
---|
Mac Developer | 旧版证书类型,主要用于开发和测试 Mac 应用,现已被 Apple Development 替代。 | 本地开发环境签名 |
Apple Development | 用于开发和调试阶段的证书,可以在真机或模拟器上安装和运行未签名的开发版本应用。适用于开发者个人或团队成员调试 | 本地开发环境签名 |
Developer ID Application | 用于将 Mac 应用分发到 App Store 以外的渠道(如官网、第三方平台),让 Gatekeeper 验证应用的合法性。适合独立分发的正式版应用 | 不上架 Apple Store签名 |
Apple Distribution | 用于将应用发布到 Mac App Store 或企业内部分发。是生产环境下的正式签名证书,适合 App Store 上架或企业大规模分发 | 上架 Apple Store 签名 |
我们只需要签名以及公证,所以需要使用的是Developer ID Application
, 具体安装方法参考XCode开发文档
使用证书有几种方案详细说明
- 指定钥匙串内的证书名称 适用于开发Mac 打包 设置系统变量
CSC_NAME
- 导出证书,转换成 base64编码, 适用于CI/CD打包,设置系统变量
CSC_LINK
& CSC_KEY_PASSWORD
说明
CSC_NAME 是指类型后面的那一段字符。 【Developer ID Application: xxxxxxxxxxxxxxx Co.,Ltd. (xxxxxx)】, 即 :
后面的字符。
另外相关配置也可以在 electron builder 中配置,不过基于安全考虑,更推荐环境变量的形式
由于 gitlab runner 暂时没有 macos的运行环境,本文使用开发机打包,最终获取到CSC_NAME
, 将其设置到系统环境变量里,可以修改.bashrc
文件, 末尾增加以下代码。
export CSC_NAME=your-csc-name
# 修改完成之后,退出文件编辑。执行以下命令生效,命令行需要重启,vscode 内终端需要重启 vscode
source .bashrc
公证相关参数
认证方式 (Method) | 参数 (Parameter) | 说明 (Explanation) | 获取方式 (How to get) |
---|
密码 (不推荐) | appleId | 你的 Apple Developer 账户的邮箱地址。 | 这是你登录苹果开发者网站时使用的邮箱。 |
| appleIdPassword | 为 electron-builder 或 notarytool 生成的应用专用密码,而不是你的 Apple ID 登录密码。 | 登录 appleid.apple.com,在“登录与安全” > “App 专用密码”中生成。 |
| teamId | 你的 Apple Developer Team ID。如果你的账户属于多个团队,需要指定使用哪个团队的证书和身份进行公证。 | 登录 Apple Developer,在“成员资格详细信息” (Membership details) 页面可以找到。 |
密钥 推荐 | appleApiKey | 从 App Store Connect 生成的 API 密钥 ID。这是用于 API 验证的密钥标识符。 | 登录 App Store Connect,进入“用户和访问” > “密钥”标签页。创建一个新的具有“App 管理”权限的密钥,即可看到密钥 ID。需要拿到 Id 以及.p8后缀的文件 |
| appleApiIssuer | 从 App Store Connect 生成的 Issuer ID。这是一个与你的账户关联的唯一 UUID。 | 和 appleApiKey 在同一个页面可以找到,位于页面顶部。 |
凭证 推荐 | keychainProfile | 存储在 macOS 钥匙串中的凭证配置文件名称。你可以将 API 密钥或账户密码安全地存储在钥匙串中,并通过一个自定义的名称来引用它,避免在脚本或环境变量中暴露敏感信息。 | 使用 notarytool 命令行工具创建。例如:xcrun notarytool store-credentials "your-profile-name" --key-id "your-apple-apikey-id" --issuer "your-appleApiIssuer" --key "your-appleApiKey local path" 。 |
推荐使用 密钥
或 凭证
方式进行公证。即参考凭证的获取方式,通过notarytool
将密钥存储到凭证
里面, 此种方式可以避免401
错误。参考官方的troubleshooting
最终获取到your-profile-name
, 将其设置到系统环境变量里,可以修改.bashrc
文件, 末尾增加以下代码。
export APPLE_KEYCHAIN_PROFILE=your-profile-name
# 修改完成之后,退出文件编辑。执行以下命令生效,命令行需要重启,vscode 内终端需要重启 vscode
source .bashrc
终端输入env
命令检查是否包含上面所需的两个系统变量。
4.2.3
打包配置修改
# electron-builder.yml
# before
mac
notarize: false # 是否公证
identity: null # 签名时使用的证书名称 null 跳过签名
# after, 其他程序会默认读取系统变量配置
mac
notarize: true # 是否公证
# 如果需要自动更新,那么还需要再加zip格式,构架 dmg + zip 文件
target:
- target: zip
arch:
- arm64 # apple m 系列芯片
- x64 # intel 系列芯片
Mac 签名配置已经完成。builder 会自动读取系统变量,获取签名&公证相关配置,访问问钥匙串的时候会提示输入密码(电脑开机密码),然后选择 always,以后可以跳过。
Mac 公证是要将你的文件上传到苹果服务器,依赖网络,此步骤可能要耗费较长时间,请耐心等待。
5.原生模块
5.1 better-sqlite3
众所周知的原因,需要用到 node-gyp
编译,那么大概率要出问题的,你可能没遇到过better-sqlite3
的, 那你也肯定遇到过node-sass
的。
对比node-sass
更复杂的点在于,node-sass 只要关心当前运行环境。better-sqlite3 要兼顾编译目标环境,就当前项目来说,学要编译4种.node 模块。
对于想要在 macos 一套构建所有目标来说,每次都编译太耗时,且容易出错。国内的淘宝镜像之类的,没有 better-sqlite3 的镜像。
所以才用了一种本地化的方案(仅 build 使用, 开发环境使用npm i 时候 node-gyp 构建就可以了)。
- electron-builder 配置文件 npmRebuild 设置为 false
- 首先去better-sqlite3的 github release 页面下载,跟你版本匹配的.node 文件
- 存放到本地项目目录。我存放到了
/native/prebuiltBinaries/
下面根据平台区分。
- 增加 afterPack 脚本,根据构建目标将.node 文件复制到目录里面,默认原生代码会放到 asaruppack 目录下, 也删了了一些无用源码,减小体积。
import fs from 'fs-extra';
import path from 'path';
// 脚本参考
export default context => {
// 处理 better-sqlite3 文件
const platformKeys = {
'win32': {
'0': 'win32-ia32',
'1': 'win32-x64'
},
'darwin': {
'1': 'darwin-x64',
'3': 'darwin-arm64'
},
};
const platformKey = platformKeys[context.electronPlatformName][context.arch]
const isMac = context.electronPlatformName === 'darwin';
// // 源文件路径(native 目录中的 better-sqlite3.node)
const sourcePath = path.join(process.cwd(), 'native', 'prebuiltBinaries', platformKey, 'better_sqlite3.node');
const toPath = path.join(
isMac ? macLocalesDir : context.appOutDir + /resources/,
'app.asar.unpacked/node_modules/better-sqlite3/'
);
// // 目标路径(app.asar.unpacked 中)
const targetDir = path.join(toPath, '/build/Release');
// 删除多余的减少体积
const removeDir = ['deps', 'src', './build/Release/better_sqlite3.node', 'LICENSE'];
for (let i = 0, len = removeDir.length; i < len; i++) {
const itemPath = path.join(toPath, removeDir[i]);
// 如果是文件
if (fs.statSync(itemPath).isFile()) {
fs.unlinkSync(itemPath);
} else if (fs.existsSync(itemPath)) {
// 如果是目录
fs.rmSync(itemPath, { recursive: true, force: true });
}
}
// 确保目标目录存在
fs.ensureDirSync(targetDir);
// // 复制 better-sqlite3.node 文件
if (fs.existsSync(sourcePath)) {
const targetPath = path.join(targetDir, 'better_sqlite3.node');
fs.copyFileSync(sourcePath, targetPath);
console.log(`Copied better-sqlite3.node for ${platformKey}`);
}
}
这样构建即可正常使用本地数据库。
参考文档
超完整的Electron打包签名更新指南,这真是太酷啦!
Integration of USB Hardware Token for EV Code Signing with electron-builder