banner
innei

innei

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

Concept: Electron Render OTA Update

Motivation#

  • Due to fragmentation in the version distribution of the app, users may not upgrade in a timely manner or may not update at all, leading to prolonged use of an outdated version that cannot receive the latest security or other feature fixes.
  • The full updates of Electron apps require more network traffic, which is not user-friendly.

We plan to use a hot update method to dynamically update the Renderer layer code of Electron.

At the time of writing this document, the implementation below is being experimented with in Follow.

Basic Knowledge#

In a production environment, Electron apps will by default load code packaged in app.asar, which includes the code for the Electron main process and the renderer process. The app.asar file is generally located in the resources directory of the app's unpacked or installation path. One way to update is to directly replace this asar file and then restart the app. This method can update both the renderer and main code, but there are two issues: first, this method requires restarting the app, and second, replacing app.asar can lead to file occupation issues (Windows exclusive) and can compromise the integrity of the app, causing the signature to become invalid (macOS exclusive).

As a compromise, we will only update the renderer code without replacing app.asar and compromising the app's integrity.

The approach is as follows: when hot update resources exist and the update is validated as effective, prioritize loading the updated renderer resources; otherwise, use the renderer resources from app.asar.

Hot Update Implementation#

We need to write a Vite plugin to package the renderer, generating an assets-render.tar.gz, calculating its sha256, and creating a 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("Compressing and fingerprinting...")
      // Compress the entire output directory
      await compressDirectory(outDir, outputFile)
      console.info("Compressing and fingerprinting", outDir, "done")

      // Calculate the file hash
      const fileBuffer = await fs.readFile(outputFile)
      const hashSum = createHash("sha256")
      hashSum.update(fileBuffer)
      const hex = hashSum.digest("hex")

      // Calculate main hash
      const mainHash = await calculateMainHash(path.resolve(process.cwd(), "apps/main"))

      // Get the current git tag version
      let version = "unknown"
      try {
        version = execSync("git describe --tags").toString().trim()
      } catch (error) {
        console.warn("Could not retrieve git tag version:", error)
      }

      // Write the manifest file
      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("Writing manifest file", manifestContent)
      await fs.writeFile(manifestFile, manifestContent.trim())
    },
  }
}

export default compressAndFingerprintPlugin

A typical manifest.yml looks like this.

version: 0.2.6-nightly.20241209-12-gb22f3259
hash: d26f1b1e8def1461b39410d70ff10280e17a941ed5582702078b30397774e247
mainHash: 3a5737e82826d4aabcfec851dbec19f099880fe60a59bf59eb0f6fcc5e4fbfa2
commit: b22f32594c4bba16eaf64d1aa3906e2b8e56fd3e
filename: render-asset.tar.gz
  • version represents the version corresponding to the renderer of this hot update (not the version of the base app)
  • hash is used to verify whether the gz package file fingerprint is consistent
  • mainHash is used to determine whether this hot update package is applicable to the current app; if it does not match the base app's mainHash, it is considered unusable
  • commit records the current hot update git commit
  • filename is the name of the generated gz file

The process for updating the renderer:

The app starts, waits for the mainWindow to load completely, retrieves the latest Release from GitHub to get the manifest, compares the current commit with the manifest's commit, compares the current version with the manifest's version, and compares the current mainHash with the manifest's mainHash. If all conditions are met, the hot update begins.

Unzip the update package, prompt the user to refresh the page upon completion, and the update is complete.

Even if the user does not refresh the page, the new renderer code will automatically load the next time the app is started.

::: note

After the update, the code of the renderer layer will no longer use the built-in asar code but will load from the directory below (typical).

Directory location: /Users/username/Library/Application Support/Follow/render (macOS)

:::

Like a normal release, hot updates need to create a new Tag. The Tag will trigger a separate build for the renderer. Then, these two files will be published to the release.

image

Next, we will implement the hot update logic in the main.

First, let's implement the logic for 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 not support, should trigger app force update, app version: ", appVersion)

    return [CanUpdateRenderState.APP_NOT_SUPPORT, null]
  }

  const isVersionEqual = appVersion === manifest.version
  if (isVersionEqual) {
    logger.info("version is equal, skip update")
    return [CanUpdateRenderState.NO_NEEDED, null]
  }
  const isCommitEqual = GIT_COMMIT_HASH === manifest.commit
  if (isCommitEqual) {
    logger.info("commit is equal, skip update")
    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 version is equal, skip update")
      return [CanUpdateRenderState.NO_NEEDED, null]
    }
    if (oldManifest.commit === manifest.commit) {
      logger.info("manifest commit is equal, skip update")
      return [CanUpdateRenderState.NO_NEEDED, null]
    }
  }
  return [CanUpdateRenderState.NEEDED, manifest]
}

Here, we compare the mainHash, commit, and version for equality; only if all conditions are met can the hot update proceed.

The storage path for hot update resources can be specified as:

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(`Downloading ${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 mismatch", 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

  // Extract the tar.gz file
  await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })
  await x({
    f: filePath,
    cwd: HOTUPDATE_RENDER_ENTRY_DIR,
  })

  // Rename `renderer` folder to `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(`Hot update render success, update to ${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) {
    // Empty the directory
    await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true })
    return
  }

  const currentRenderVersion = manifest.version
  // Clean all not current 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 })
  }
}

Then download the hot update resources and extract them to the specified path. At this point, the process is successful, and the user can be prompted to refresh the page, or the app can wait until the next launch to automatically load the new resources.

Note

From this point on, the app will have two version numbers: one for the base app and another for the updated renderer version.

Furthermore, we combine the app's own updates (using 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("Error checking for updates", e)
  } finally {
    checkingUpdate = false
  }
}

Here, we prioritize checking for renderer updates before triggering app updates. If a renderer update is found, it indicates that an app update is no longer necessary.

Modify the logic for loading index.html in the renderer.

  • When a hot update exists and is valid, prioritize loading it.
  • If none exists, default to loading resources from 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("load dynamic render entry", dynamicRenderEntry)
const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")

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

Use loadDynamicRenderEntry to obtain available hot update resources.

Postscript#

Using renderer hot updates allows for a more seamless update experience without needing to restart the app, and the update package is only a few megabytes. This method is particularly recommended for apps that are heavily web-based and do not require frequent modifications to the main code.

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

This article was synchronously updated to xLog by Mix Space. The original link is https://innei.in/posts/tech/electron-render-OTA-update

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.