fix(electron): hotfix update logic

- Added ky as a dependency to handle HTTP requests for file downloads.
- Implemented downloadFileWithProgress function to provide progress updates during downloads.
- Updated hot-updater to utilize the new download function, enhancing user experience with download status logging.
- Cleaned up and refactored related code for better maintainability.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-07-30 01:36:23 +08:00
parent 680d7efc26
commit d1e448d602
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
7 changed files with 193 additions and 51 deletions

View File

@ -5,9 +5,6 @@ on:
branches:
- main
env:
NODE_OPTIONS: --max-old-space-size=8192
jobs:
create_tag:
name: Create Release Tag
@ -20,21 +17,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: lts/*
- name: Make script executable
run: chmod +x .github/scripts/extract-release-info.js
run: |
chmod +x .github/scripts/extract-release-info.mjs
- name: Extract release information
id: extract_info
run: ./.github/scripts/extract-release-info.mjs
run: .github/scripts/extract-release-info.mjs
continue-on-error: false
- name: Validate git configuration

View File

@ -38,6 +38,7 @@
"font-list": "1.5.1",
"i18next": "25.3.1",
"js-yaml": "4.1.0",
"ky": "1.8.2",
"linkedom": "0.18.11",
"lowdb": "7.0.1",
"msedge-tts": "2.0.0",

View File

@ -1,9 +1,22 @@
import { createHash } from "node:crypto"
import { createWriteStream } from "node:fs"
import { mkdir } from "node:fs/promises"
import { pipeline } from "node:stream"
import { promisify } from "node:util"
import ky from "ky"
import path from "pathe"
const streamPipeline = promisify(pipeline)
export interface DownloadOptions {
url: string
outputPath: string
expectedHash?: string
onProgress?: (downloadedSize: number, totalSize: number, percentage: number) => void
onLog?: (message: string) => void
}
export async function downloadFile(url: string, dest: string) {
const res = await fetch(url)
@ -16,3 +29,95 @@ export async function downloadFile(url: string, dest: string) {
}
await streamPipeline(res.body as any, createWriteStream(dest))
}
export async function downloadFileWithProgress(options: DownloadOptions): Promise<boolean> {
const { url, outputPath, expectedHash, onProgress, onLog } = options
try {
// Create download directory
await mkdir(path.dirname(outputPath), { recursive: true })
let lastProgressTime = Date.now()
const sha256 = expectedHash ? createHash("sha256") : null
onLog?.(`Starting download: ${path.basename(outputPath)}`)
// Use ky with onDownloadProgress
const response = await ky.get(url, {
onDownloadProgress: (progress) => {
const now = Date.now()
// Call progress callback every 500ms to avoid spam
if (now - lastProgressTime > 500 || progress.percent === 1) {
const percentage = progress.percent * 100
const downloadedMB = (progress.transferredBytes / 1024 / 1024).toFixed(2)
const totalMB = (progress.totalBytes / 1024 / 1024).toFixed(2)
onLog?.(`Download progress: ${percentage.toFixed(1)}% (${downloadedMB}/${totalMB} MB)`)
// Call progress callback if provided
if (onProgress) {
onProgress(progress.transferredBytes, progress.totalBytes, percentage)
}
lastProgressTime = now
}
},
})
if (!response.ok) {
onLog?.(`Failed to download file: ${response.status} ${response.statusText}`)
return false
}
// Get the response as array buffer
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Verify hash if provided
if (expectedHash && sha256) {
sha256.update(buffer)
const hash = sha256.digest("hex")
if (hash !== expectedHash) {
onLog?.(`Hash verification failed. Expected: ${expectedHash}, Got: ${hash}`)
return false
}
onLog?.("Hash verification passed")
}
// Write to file
const writeStream = createWriteStream(outputPath)
return new Promise<boolean>((resolve) => {
writeStream.on("error", (error) => {
onLog?.(`Write stream error: ${error}`)
resolve(false)
})
writeStream.on("finish", () => {
onLog?.(`Download completed: ${outputPath}`)
resolve(true)
})
writeStream.end(buffer)
})
} catch (error) {
onLog?.(`Download error: ${error}`)
return false
}
}
// async function testDownload() {
// console.info("Testing ky onDownloadProgress implementation...")
// const result = await downloadFileWithProgress({
// url: "https://github.com/Innei/Follow/releases/download/desktop/v1.2.5/manifest.yml",
// outputPath: path.resolve(os.tmpdir(), "follow-render-update", "manifest.yml"),
// onLog(message) {
// console.info(`[LOG] ${message}`)
// },
// })
// console.info(`Download result: ${result}`)
// }
// testDownload().catch(console.error)

View File

@ -1,7 +1,6 @@
/**
* @description This file handles hot updates for the electron renderer layer
*/
import { createHash } from "node:crypto"
import { existsSync, readFileSync } from "node:fs"
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
@ -14,6 +13,7 @@ import path from "pathe"
import { x } from "tar"
import { GITHUB_OWNER, GITHUB_REPO, HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app"
import { downloadFileWithProgress } from "~/lib/download"
import { WindowManager } from "~/manager/window"
import { appUpdaterConfig } from "./configs"
@ -25,20 +25,58 @@ const releasesUrl = `${url}/releases`
const releaseApiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases`
const getLatestReleaseTag = async () => {
// Get all releases and filter for desktop releases only
// First try to get the latest release
try {
const latestRes = await fetch(`${releaseApiUrl}/latest`)
if (latestRes.ok) {
const latestRelease = await latestRes.json()
// Check if the latest release is a desktop release
if (
latestRelease.tag_name &&
latestRelease.tag_name.startsWith("desktop/") &&
!latestRelease.draft
) {
return latestRelease.tag_name
}
}
} catch (error) {
logger.warn("Failed to fetch latest release, falling back to all releases", error)
}
// If latest release is not a desktop release or fetch failed, get all releases
const res = await fetch(releaseApiUrl)
if (!res.ok) {
throw new Error(`GitHub API request failed: ${res.status} ${res.statusText}`)
}
const releases = await res.json()
// Check if the response contains an error message
if (releases.message) {
throw new Error(`GitHub API error: ${releases.message}`)
}
// Ensure releases is an array
if (!Array.isArray(releases)) {
throw new TypeError("Invalid response format from GitHub API")
}
// Filter for desktop releases and find the latest one
const desktopReleases = releases.filter(
(release: any) => release.tag_name.startsWith("desktop/") && !release.draft,
(release: any) => release.tag_name && release.tag_name.startsWith("desktop/") && !release.draft,
)
if (desktopReleases.length === 0) {
throw new Error("No desktop releases found")
}
// Return the most recent desktop release (GitHub orders by created_at desc)
// Sort by created_at date in descending order to get the most recent first
desktopReleases.sort((a: any, b: any) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
// Return the most recent desktop release
return desktopReleases[0].tag_name
}
@ -61,6 +99,12 @@ const getLatestReleaseManifest = async () => {
const url = await getFileDownloadUrl("manifest.yml")
logger.info(`Fetching manifest from ${url}`)
const res = await fetch(url)
if (!res.ok) {
logger.error(`Failed to fetch manifest: ${res.status} ${res.statusText}`)
return null
}
const text = await res.text()
const manifest = load(text) as Manifest
if (typeof manifest !== "object") {
@ -88,28 +132,10 @@ export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest
if (!manifest) return [CanUpdateRenderState.NETWORK_ERROR, null]
// const isAppShouldUpdate = shouldUpdateApp(appVersion, manifest.version)
// if (isAppShouldUpdate) {
// logger.info(
// "app should update, skip render update, app version: ",
// appVersion,
// ", the manifest version: ",
// manifest.version,
// )
// return false
// }
const appSupport = mainHash === manifest.mainHash
if (!appSupport) {
logger.info("app not support, should trigger app force update, app version: ", appVersion)
// hotUpdateAppNotSupportTriggerTrack({
// appVersion,
// manifestVersion: manifest.version,
// })
// // Trigger app force update
// checkForAppUpdates().then(() => {
// downloadAppUpdate()
// })
return [CanUpdateRenderState.APP_NOT_SUPPORT, null]
}
@ -146,22 +172,20 @@ export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest
const downloadRenderAsset = async (manifest: Manifest) => {
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
}
logger.info(`Downloading ${url}, Save to ${filePath}`)
const success = await downloadFileWithProgress({
url,
outputPath: filePath,
expectedHash: manifest.hash,
onLog: (message) => {
logger.info(message)
},
})
if (!success) throw new Error("Download hot update render asset failed")
return filePath
}
export const hotUpdateRender = async (manifest: Manifest) => {
@ -170,25 +194,31 @@ export const hotUpdateRender = async (manifest: Manifest) => {
if (!manifest) return false
const filePath = await downloadRenderAsset(manifest)
logger.info(`Downloaded render asset to ${filePath}`)
if (!filePath) return false
// Extract the tar.gz file
await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })
logger.info(`Extracting render asset to ${HOTUPDATE_RENDER_ENTRY_DIR}`)
await x({
f: filePath,
cwd: HOTUPDATE_RENDER_ENTRY_DIR,
})
logger.info(
`Extracted render asset to ${HOTUPDATE_RENDER_ENTRY_DIR}, rename to ${manifest.version}`,
)
// 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),
)
const manifestPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
logger.info(`Write manifest to ${manifestPath}`)
await writeFile(manifestPath, JSON.stringify(manifest))
logger.info(`Hot update render success, update to ${manifest.version}`)
const mainWindow = WindowManager.getMainWindow()

View File

@ -67,6 +67,7 @@ export const checkForAppUpdates = async (): Promise<{ hasUpdate: boolean; error?
// Auto upgrade renderer
upgradeRenderIfNeeded()
return { hasUpdate }
}
}

View File

@ -44,7 +44,7 @@ function compressAndFingerprintPlugin(outDir: string): Plugin {
// Get the current git tag version
let version = "unknown"
try {
version = execSync("git describe --tags").toString().trim()
version = execSync("git describe --tags").toString().trim().replace("desktop/", "")
} catch (error) {
console.warn("Could not retrieve git tag version:", error)
}

View File

@ -389,6 +389,9 @@ importers:
js-yaml:
specifier: 4.1.0
version: 4.1.0
ky:
specifier: 1.8.2
version: 1.8.2
linkedom:
specifier: 0.18.11
version: 0.18.11
@ -11262,6 +11265,10 @@ packages:
kose-font@1.0.0:
resolution: {integrity: sha512-8R1M6ajjPvG0E4UxiNgZe0Hoy4qGTX1RuNZHN96dvqzuUwNM08gkpx1O+Q8/PzT8lp3lqH7w2DCUCGr8Wt7wPA==}
ky@1.8.2:
resolution: {integrity: sha512-XybQJ3d4Ea1kI27DoelE5ZCT3bSJlibYTtQuMsyzKox3TMyayw1asgQdl54WroAm+fIA3ZCr8zXW2RpR7qWVpA==}
engines: {node: '>=18'}
kysely@0.28.2:
resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==}
engines: {node: '>=18.0.0'}
@ -14415,6 +14422,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@ -28049,6 +28057,8 @@ snapshots:
kose-font@1.0.0: {}
ky@1.8.2: {}
kysely@0.28.2: {}
lan-network@0.1.7: {}