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:
parent
680d7efc26
commit
d1e448d602
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const checkForAppUpdates = async (): Promise<{ hasUpdate: boolean; error?
|
|||
|
||||
// Auto upgrade renderer
|
||||
upgradeRenderIfNeeded()
|
||||
return { hasUpdate }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue