動機#
- 由於 App 的版本鋪量存在碎片化,用戶升級不及時或者不更新,會導致版本長期停留在舊版本無法得到最快的安全性或者其他功能修復。
- 由於 Electron App 的全量更新需要更多的網絡流量才能進行,對用戶不友好。
擬使用熱更的方式去動態更新 Electron 的 Renderer 層代碼。
編寫此文時,下面的實現正在 Follow 中實驗。
基礎知識#
Electron App 在生產環境下,默認會加載封裝在 app.asar 中的代碼,包括了 electron main 進程的代碼,和 renderer 進程的代碼。app.asar 一般在 app 解包或者安裝路徑的 resources 目錄下,一種方式是我們可以直接替換這個 asar 文件然後重啟 app 就行了,這種方式不僅能更新 renderer 還能更新 main 的代碼,但是又兩個問題是,第一這種方式是需要重啟 app,而且在替換 app.asar 時需要解決文件佔用的問題(Windows 專屬),第二替換了 app.asar 會破壞 app 的完整性,導致簽名失效(macOS 專屬)。
在兩者之間折中,只更新 renderer 的代碼,並且不替換 app.asar 和破壞 app 完整性。
思路如下:當熱更新資源存在時,並且驗證此更新有效時,優先加載更新的 renderer 資源,否則使用 app.asar 的 renderer 資源。
熱更實現#
我們需要編寫一個 Vite 插件,對 renderer 進行打包,生成一個 assets-render.tar.gz ,算出 sha256,生成一個 manifest.yml。
import { execSync } from "node:child_process"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
import path from "node:path"
import * as tar from "tar"
import type { Plugin } from "vite"
import { calculateMainHash } from "./generate-main-hash"
async function compressDirectory(sourceDir: string, outputFile: string) {
await tar.c(
{
gzip: true,
file: outputFile,
cwd: sourceDir,
},
["renderer"],
)
}
function compressAndFingerprintPlugin(outDir: string): Plugin {
return {
name: "compress-and-fingerprint",
apply: "build",
async closeBundle() {
const outputFile = path.join(outDir, "render-asset.tar.gz")
const manifestFile = path.join(outDir, "manifest.yml")
console.info("壓縮和指紋生成中...")
// 壓縮整個輸出目錄
await compressDirectory(outDir, outputFile)
console.info("壓縮和指紋生成", outDir, "完成")
// 計算文件哈希
const fileBuffer = await fs.readFile(outputFile)
const hashSum = createHash("sha256")
hashSum.update(fileBuffer)
const hex = hashSum.digest("hex")
// 計算主哈希
const mainHash = await calculateMainHash(path.resolve(process.cwd(), "apps/main"))
// 獲取當前 git 標籤版本
let version = "unknown"
try {
version = execSync("git describe --tags").toString().trim()
} catch (error) {
console.warn("無法獲取 git 標籤版本:", error)
}
// 寫入 manifest 文件
const manifestContent = `
version: ${version.startsWith("v") ? version.slice(1) : version}
hash: ${hex}
mainHash: ${mainHash}
commit: ${execSync("git rev-parse HEAD").toString().trim()}
filename: ${path.basename(outputFile)}
`
console.info("寫入 manifest 文件", manifestContent)
await fs.writeFile(manifestFile, manifestContent.trim())
},
}
}
export default compressAndFingerprintPlugin
一個典型的 minifest.yml 是這樣的。
version: 0.2.6-nightly.20241209-12-gb22f3259
hash: d26f1b1e8def1461b39410d70ff10280e17a941ed5582702078b30397774e247
mainHash: 3a5737e82826d4aabcfec851dbec19f099880fe60a59bf59eb0f6fcc5e4fbfa2
commit: b22f32594c4bba16eaf64d1aa3906e2b8e56fd3e
filename: render-asset.tar.gz
- version 代表這個熱更所屬的 renderer 對應的版本(並不是基座 app 的版本)
- hash 是用來驗證 gz 包文件指紋是否一致的
- mainHash 是用於鑑定此熱更包是否可用於當前的 app,如果和基座 app 的 mainHash 不一致則代表不可用
- commit 記錄當前的熱更 git commit
- filename 產生的 gz 文件名
Renderer 熱更的流程:
App 啟動,等待 mainWindow 加載完成,獲取 GitHub latest Release 獲取 manifest,對比當前的 commit 和 manifest 的 commit 是否一致,對比當前的 version 和 manifest 的 version 是否一致,對比當前的 mainHash 和 manifest 的 mainHash 是否一致。滿足之後開始熱更。
解壓更新包,更新完成提示用戶刷新頁面,更新完成。
即使用戶不刷新頁面,下次啟動自動加載新版 renderer 代碼。
::: note
renderer 層的更新後的代碼,不再使用內建的 asar 代碼,而是加載下面的目錄 (典型)
目錄位置:/Users/username/Library/Application Support/Follow/render
(macOS)
:::
和正常發版一樣,熱更需要創建一個新的 Tag。Tag 會觸發對 renderer 的單獨構建。然後發布這兩個文件到 release。
接下來我們來 main 中實現熱更新的邏輯。
首先來實現 canUpdateRender 的邏輯:
export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest | null]> => {
const manifest = await getLatestReleaseManifest()
if (!manifest) return [CanUpdateRenderState.NETWORK_ERROR, null]
const appSupport = mainHash === manifest.mainHash
if (!appSupport) {
logger.info("app 不支持,應該觸發 app 強制更新,app 版本: ", appVersion)
return [CanUpdateRenderState.APP_NOT_SUPPORT, null]
}
const isVersionEqual = appVersion === manifest.version
if (isVersionEqual) {
logger.info("版本相等,跳過更新")
return [CanUpdateRenderState.NO_NEEDED, null]
}
const isCommitEqual = GIT_COMMIT_HASH === manifest.commit
if (isCommitEqual) {
logger.info("commit 相等,跳過更新")
return [CanUpdateRenderState.NO_NEEDED, null]
}
const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
const manifestExist = existsSync(manifestFilePath)
const oldManifest: Manifest | null = manifestExist
? (load(readFileSync(manifestFilePath, "utf-8")) as Manifest)
: null
if (oldManifest) {
if (oldManifest.version === manifest.version) {
logger.info("manifest 版本相等,跳過更新")
return [CanUpdateRenderState.NO_NEEDED, null]
}
if (oldManifest.commit === manifest.commit) {
logger.info("manifest commit 相等,跳過更新")
return [CanUpdateRenderState.NO_NEEDED, null]
}
}
return [CanUpdateRenderState.NEEDED, manifest]
}
這裡比對了 mainHash 一致,commit 一致,version 一致,只有都滿足條件才能進行熱更。
熱更資源的存放路徑可以指定為:
export const HOTUPDATE_RENDER_ENTRY_DIR = path.resolve(app.getPath("userData"), "render")
const downloadRenderAsset = async (manifest: Manifest) => {
hotUpdateDownloadTrack(manifest.version)
const { filename } = manifest
const url = await getFileDownloadUrl(filename)
logger.info(`正在下載 ${url}`)
const res = await fetch(url)
const arrayBuffer = await res.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const filePath = path.resolve(downloadTempDir, filename)
await mkdir(downloadTempDir, { recursive: true })
await writeFile(filePath, buffer)
const sha256 = createHash("sha256")
sha256.update(buffer)
const hash = sha256.digest("hex")
if (hash !== manifest.hash) {
logger.error("哈希不匹配", hash, manifest.hash)
return false
}
return filePath
}
export const hotUpdateRender = async (manifest: Manifest) => {
if (!appUpdaterConfig.enableRenderHotUpdate) return false
if (!manifest) return false
const filePath = await downloadRenderAsset(manifest)
if (!filePath) return false
// 解壓 tar.gz 文件
await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })
await x({
f: filePath,
cwd: HOTUPDATE_RENDER_ENTRY_DIR,
})
// 將 `renderer` 文件夾重命名為 `manifest.version`
await rename(
path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer"),
path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version),
)
await writeFile(
path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml"),
JSON.stringify(manifest),
)
logger.info(`熱更新 renderer 成功,更新到 ${manifest.version}`)
hotUpdateRenderSuccessTrack(manifest.version)
const mainWindow = getMainWindow()
if (!mainWindow) return false
const caller = callWindowExpose(mainWindow)
caller.readyToUpdate()
return true
}
export const getCurrentRenderManifest = () => {
const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
const manifestExist = existsSync(manifestFilePath)
if (!manifestExist) return null
return load(readFileSync(manifestFilePath, "utf-8")) as Manifest
}
export const cleanupOldRender = async () => {
const manifest = getCurrentRenderManifest()
if (!manifest) {
// 清空目錄
await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true })
return
}
const currentRenderVersion = manifest.version
// 清理所有不是當前版本的
const dirs = await readdir(HOTUPDATE_RENDER_ENTRY_DIR)
for (const dir of dirs) {
const isDir = (await stat(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir))).isDirectory()
if (!isDir) continue
if (dir === currentRenderVersion) continue
await rm(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir), { recursive: true, force: true })
}
}
隨後下載熱更資源,然後解壓到指定的路徑。此時就成功了,可以提示用戶刷新頁面,也可以等待下一次打開 app,自動加載新資源。
Note
從這裡開始,app 就會存在兩個版本號,一個是基座 app 的版本,另一個是更新後的 renderer 版本。
進一步,我們結合 app 的本身的更新(使用 Electron updater)。
const upgradeRenderIfNeeded = async () => {
const [state, manifest] = await canUpdateRender()
if (state === CanUpdateRenderState.NO_NEEDED) {
return true
}
if (state === CanUpdateRenderState.NEEDED && manifest) {
await hotUpdateRender(manifest)
return true
}
return false
}
export const checkForAppUpdates = async () => {
if (disabled || checkingUpdate) {
return
}
checkingUpdate = true
try {
if (appUpdaterConfig.enableRenderHotUpdate) {
const isRenderUpgraded = await upgradeRenderIfNeeded()
if (isRenderUpgraded) {
return
}
}
return autoUpdater.checkForUpdates()
} catch (e) {
logger.error("檢查更新時出錯", e)
} finally {
checkingUpdate = false
}
}
這裡需要優先檢查 renderer 的更新,再觸發 app 的更新,如果命中 renderer 更新,則表示 app 更新不再需要。
修改 renderer 加載 index.html 的邏輯。
- 當熱更存在並合法,優先優先加載
- 全無時,默認加載 app.asar 資源
const loadDynamicRenderEntry = () => {
const manifest = getCurrentRenderManifest()
if (!manifest) return
// check main hash is equal to manifest.mainHash
const appSupport = mainHash === manifest.mainHash
if (!appSupport) return
const currentRenderVersion = manifest.version
const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, currentRenderVersion)
const entryFile = path.resolve(dir, "index.html")
const entryFileExists = existsSync(entryFile)
if (!entryFileExists) return
return entryFile
}
// Production entry
const dynamicRenderEntry = loadDynamicRenderEntry()
logger.info("加載動態 renderer 入口", dynamicRenderEntry)
const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")
window.loadFile(appLoadEntry, {
hash: options?.extraPath,
})
通過 loadDynamicRenderEntry
去獲取可用的熱更資源。
後記#
使用 renderer 熱更,可以讓更新更加無感,並無需重啟 app,更新包也只要幾 M 就行。如果 app 是一個重 web 並且不太需要經常修改 main 代碼的,比較推薦這種方式。
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/electron-render-OTA-update