diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 57af2153c..489ab634f 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -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 diff --git a/apps/desktop/layer/main/package.json b/apps/desktop/layer/main/package.json index 51e823f8f..a95d7c90b 100644 --- a/apps/desktop/layer/main/package.json +++ b/apps/desktop/layer/main/package.json @@ -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", diff --git a/apps/desktop/layer/main/src/lib/download.ts b/apps/desktop/layer/main/src/lib/download.ts index d31f9d3a0..602fa21b3 100644 --- a/apps/desktop/layer/main/src/lib/download.ts +++ b/apps/desktop/layer/main/src/lib/download.ts @@ -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 { + 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((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) diff --git a/apps/desktop/layer/main/src/updater/hot-updater.ts b/apps/desktop/layer/main/src/updater/hot-updater.ts index 3d3de2919..afcc22bbb 100644 --- a/apps/desktop/layer/main/src/updater/hot-updater.ts +++ b/apps/desktop/layer/main/src/updater/hot-updater.ts @@ -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() diff --git a/apps/desktop/layer/main/src/updater/index.ts b/apps/desktop/layer/main/src/updater/index.ts index 6d122e527..b12af4ba4 100644 --- a/apps/desktop/layer/main/src/updater/index.ts +++ b/apps/desktop/layer/main/src/updater/index.ts @@ -67,6 +67,7 @@ export const checkForAppUpdates = async (): Promise<{ hasUpdate: boolean; error? // Auto upgrade renderer upgradeRenderIfNeeded() + return { hasUpdate } } } diff --git a/apps/desktop/plugins/vite/compress.ts b/apps/desktop/plugins/vite/compress.ts index c9a6b370f..dfe5ca5cf 100644 --- a/apps/desktop/plugins/vite/compress.ts +++ b/apps/desktop/plugins/vite/compress.ts @@ -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) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f95c3fab6..9fe7718a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}