banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

構想:Electron Render OTA 更新

動機#

  • アプリのバージョンの普及に断片化が存在するため、ユーザーがアップグレードを適時行わなかったり、更新しなかったりすると、バージョンが長期間古いままとなり、最も迅速なセキュリティやその他の機能修正を受けられなくなります。
  • Electron アプリの全量更新には、より多くのネットワークトラフィックが必要であり、ユーザーにとって不親切です。

熱更新の方法を使用して、Electron の Renderer 層のコードを動的に更新します。

この文を書く時点で、以下の実装が Follow の中で実験されています。

基礎知識#

Electron アプリは、プロダクション環境下で、デフォルトで app.asar にパッケージ化されたコードを読み込みます。これには、electron メインプロセスのコードと、renderer プロセスのコードが含まれています。app.asar は一般的にアプリの解凍またはインストールパスの resources ディレクトリにあります。一つの方法は、この asar ファイルを直接置き換えてアプリを再起動することです。この方法では、renderer を更新するだけでなく、main のコードも更新できますが、二つの問題があります。第一に、この方法ではアプリを再起動する必要があり、また app.asar を置き換える際にはファイルの占有問題を解決する必要があります(Windows 専用)。第二に、app.asar を置き換えるとアプリの完全性が損なわれ、署名が無効になります(macOS 専用)。

両者の折衷として、renderer のコードのみを更新し、app.asar を置き換えず、アプリの完全性を損なわないようにします。

考え方は次の通りです:熱更新リソースが存在し、この更新が有効であると検証された場合、優先的に更新された renderer リソースを読み込みます。そうでなければ、app.asar の renderer リソースを使用します。

熱更新の実装#

renderer をパッケージ化するための Vite プラグインを作成し、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)
      }

      // マニフェストファイルを書き込む
      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("マニフェストファイルを書き込み中", 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 のバージョンを示します(基盤アプリのバージョンではありません)
  • hash は gz パッケージファイルのフィンガープリンティングが一致するかどうかを検証するために使用されます
  • mainHash はこの熱更新パッケージが現在のアプリで使用可能かどうかを判断するために使用されます。基盤アプリの mainHash と一致しない場合は使用できません
  • commit は現在の熱更新の git コミットを記録します
  • filename は生成された gz ファイル名です

Renderer 熱更新のプロセス:

アプリが起動し、mainWindow の読み込みが完了するのを待ち、GitHub の最新リリースを取得してマニフェストを取得し、現在のコミットとマニフェストのコミットが一致するかどうか、現在のバージョンとマニフェストのバージョンが一致するかどうか、現在の mainHash とマニフェストの mainHash が一致するかどうかを比較します。すべて満たされた場合、熱更新を開始します。

更新パッケージを解凍し、更新が完了したらユーザーにページをリフレッシュするように通知します。更新が完了します。

ユーザーがページをリフレッシュしなくても、次回起動時に自動的に新しい renderer コードを読み込みます。

::: note

renderer 層の更新後のコードは、内蔵の asar コードを使用せず、以下のディレクトリを読み込みます(典型的)

ディレクトリ位置:/Users/username/Library/Application Support/Follow/render (macOS)

:::

通常のリリースと同様に、熱更新には新しいタグを作成する必要があります。タグは renderer の個別ビルドをトリガーします。そして、これら二つのファイルをリリースに公開します。

image

次に、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("アプリはサポートされていません。アプリの強制更新をトリガーする必要があります。アプリバージョン: ", 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("コミットが等しいため、更新をスキップします")
    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("マニフェストバージョンが等しいため、更新をスキップします")
      return [CanUpdateRenderState.NO_NEEDED, null]
    }
    if (oldManifest.commit === manifest.commit) {
      logger.info("マニフェストコミットが等しいため、更新をスキップします")
      return [CanUpdateRenderState.NO_NEEDED, null]
    }
  }
  return [CanUpdateRenderState.NEEDED, manifest]
}

ここでは mainHash、一致、コミット、一致、バージョン、一致を比較しており、すべての条件が満たされる場合のみ熱更新を行います。

熱更新リソースの保存パスは次のように指定できます:

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 })
  }
}

その後、熱更新リソースをダウンロードし、指定されたパスに解凍します。この時点で成功し、ユーザーにページをリフレッシュするように通知することも、次回アプリを開いたときに自動的に新しいリソースを読み込むこともできます。

Note

ここから、アプリには二つのバージョン番号が存在します。一つは基盤アプリのバージョン、もう一つは更新された renderer バージョンです。

さらに、アプリ自身の更新(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 の更新を優先的に確認し、その後アプリの更新をトリガーします。renderer 更新がヒットした場合、アプリの更新は不要となります。

renderer が index.html を読み込むロジックを修正します。

  • 熱更新が存在し、合法であれば、優先的に読み込みます
  • 何もない場合は、デフォルトで app.asar リソースを読み込みます

const loadDynamicRenderEntry = () => {
  const manifest = getCurrentRenderManifest()
  if (!manifest) return
  // main ハッシュが 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
}


// プロダクションエントリ
const dynamicRenderEntry = loadDynamicRenderEntry()
logger.info("動的レンダリングエントリを読み込み中", dynamicRenderEntry)
const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")

window.loadFile(appLoadEntry, {
  hash: options?.extraPath,
})

loadDynamicRenderEntry を使用して利用可能な熱更新リソースを取得します。

後記#

renderer 熱更新を使用することで、更新がより無感覚になり、アプリを再起動する必要もなく、更新パッケージも数 MB で済みます。アプリが重いウェブであり、main コードを頻繁に変更する必要がない場合、この方法をお勧めします。

https://github.com/RSSNext/Follow/blob/3499535b4091e3a82734c416528b0766e70f0b63/apps/main/src/updater/hot-updater.ts

この記事は Mix Space によって xLog に同期更新されました。原始リンクは https://innei.in/posts/tech/electron-render-OTA-update

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。