又又要开发Electron程序了(着重代码签名)

2018年底开发了一款内部IM软件,当时使用了Electron开发,可惜内部使用了1年多后,停止维护了。五年之后的今天,又要开发一款套壳浏览器,内嵌现有的网页端,兜兜转转又捡起来了。

1.前言

开发之前也考虑过选型问题,之前开发Electron的时候确实是踩了很多坑的,重新使用Electron开发是有些犹豫的。6年前内部很多业务人员的设备都是win7, 内存没有超过8G的,Electon性能饱受诟病。

开发之前也去对比了Tauri2做了选型比较(对于js开发者)

维度EletronTauri2
跨平台优秀,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,DiscordPostgreSQL

指标上Tauri2有很大优势,但是我们的项目目标是开发速度快,内核兼容性好。 另外可以预期新的框架一定会存在一些很难解决的巨坑。所以最终还是选择了 Electron,如果是个人项目,我可能会倾向于 Tauri2

另外 Elctron 基于 chromium 内核,所以兼容性取决于 chromium 的兼容性,具体参考下方,酌情选择 Elctron版本

操作系统版本Chrome 弃用版本和日期系统弃用日期说明
Windows 7Chrome 109 是支持此操作系统的最后一个版本。Chrome 109 是在 2023 年 1 月 10 日发布的。2020 年 1 月--
Windows 8/8.1Chrome 109 是支持此操作系统的最后一个版本。Chrome 109 是在 2023 年 1 月 10 日发布的。2023 年 1 月--
Windows 8/8.1Chrome 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实现

预览
自定义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-builderElectron-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证书分为两大类

  • 云签名(如Certum EV)

优点:审核通过即可使用,无需硬件。如果希望CI/CD 中完成签名,这个方便一点。

缺点:资料多 审核严格。审核时间1-3个工作日,需要提供营业执照图片+企业法人手持身份证正面反面 + 法人身份证正反面+企业最近的水电、电话、物业费 等公共事业账单图片 选择提供其一提供即可!

  • USB Token(如GlobalSign)

优点: 只需要提供公司名字,审核快,审核没那么严格

缺点: 需要拿到硬件,用于签名的机器,必须插上 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

  • 依赖 Java, 需要前置安装

安装 jsign

brew install 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 准备工作

  • 成为苹果开发者, 如果是企业应用,则需要使用公司名义注册。
  • xcode 内置notarytool

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 账户的邮箱地址。这是你登录苹果开发者网站时使用的邮箱。
appleIdPasswordelectron-buildernotarytool 生成的应用专用密码,而不是你的 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