diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78f1ddae1..5212f80db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,7 +165,7 @@ jobs: - name: Install Linux native build dependencies run: | sudo apt-get update - sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev + sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev zsync - name: Remove stale uiohook build output run: rm -rf node_modules/uiohook-napi/build @@ -179,6 +179,7 @@ jobs: npx tsc npx vite build npx electron-builder --linux dir AppImage --x64 --publish never + node scripts/embed-appimage-updateinfo.mjs - name: Smoke test packaged Linux paths run: npm run smoke:packaged-binaries @@ -198,6 +199,7 @@ jobs: path: | release/**/*.AppImage release/**/*.blockmap + release/**/*.zsync release/latest-linux.yml release/SHA256SUMS*.txt if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dea29ea6d..14ad742ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -499,7 +499,7 @@ jobs: - name: Install Linux native build dependencies run: | sudo apt-get update - sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev + sudo apt-get install -y libx11-dev libxt-dev libxtst-dev libxkbfile-dev libxi-dev libxrandr-dev libxinerama-dev zsync - name: Install dependencies run: npm ci --ignore-scripts @@ -524,6 +524,7 @@ jobs: npx tsc npx vite build npx electron-builder --linux dir AppImage --x64 --publish never + node scripts/embed-appimage-updateinfo.mjs - name: Smoke test packaged Linux x64 paths run: npm run smoke:packaged-binaries @@ -543,6 +544,7 @@ jobs: path: | release/*.AppImage release/*.blockmap + release/*.zsync release/latest-linux.yml release/SHA256SUMS*.txt if-no-files-found: error @@ -664,6 +666,7 @@ jobs: release-assets/windows-x64/*.blockmap release-assets/linux-x64/*.AppImage release-assets/linux-x64/*.blockmap + release-assets/linux-x64/*.zsync ) if [ "$RELEASE_SCOPE" = "all" ]; then diff --git a/electron-builder.json5 b/electron-builder.json5 index 4a6b6aed1..b0cccc097 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -52,7 +52,7 @@ "publish": [ { "provider": "github", - "owner": "webadderall", + "owner": "webadderallorg", "repo": "Recordly", "tagNamePrefix": "v", "publishAutoUpdate": true diff --git a/package.json b/package.json index 168b09191..d5f59553e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "build:cursor-monitor": "node scripts/build-cursor-monitor.mjs", "build:mac": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --mac", "build:win": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --win", - "build:linux": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --linux", + "build:linux": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --linux && npm run embed:appimage-updateinfo", + "embed:appimage-updateinfo": "node scripts/embed-appimage-updateinfo.mjs", "i18n:check": "node scripts/i18n-check.mjs", "benchmark:export-queues": "node scripts/benchmark-export-queues.mjs", "normalize:electron-main-cjs": "node scripts/normalize-electron-main-cjs.mjs", diff --git a/scripts/embed-appimage-updateinfo.mjs b/scripts/embed-appimage-updateinfo.mjs new file mode 100644 index 000000000..e393b5cbf --- /dev/null +++ b/scripts/embed-appimage-updateinfo.mjs @@ -0,0 +1,178 @@ +import { execFileSync } from "node:child_process"; +import { + closeSync, + existsSync, + openSync, + readdirSync, + readSync, + statSync, + writeSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = process.cwd(); +const releaseRoot = path.join(projectRoot, "release"); + +const DEFAULT_OWNER = "webadderallorg"; +const DEFAULT_REPO = "Recordly"; + +function findElfUpdInfoSection(filePath) { + let fd; + try { + fd = openSync(filePath, "r"); + const headerBuf = Buffer.alloc(64); + readSync(fd, headerBuf, 0, 64, 0); + + if (headerBuf.toString("utf8", 0, 4) !== "\x7fELF") { + return null; + } + + const is64Bit = headerBuf[4] === 2; + if (!is64Bit) { + return null; + } + + const e_shoff = Number(headerBuf.readBigUInt64LE(40)); + const e_shentsize = headerBuf.readUInt16LE(58); + const e_shnum = headerBuf.readUInt16LE(60); + const e_shstrndx = headerBuf.readUInt16LE(62); + + if (e_shnum === 0 || e_shentsize === 0) { + return null; + } + + const shTableBuf = Buffer.alloc(e_shentsize * e_shnum); + readSync(fd, shTableBuf, 0, shTableBuf.length, e_shoff); + + const strtabHeaderOffset = e_shstrndx * e_shentsize; + const strtabOffset = Number(shTableBuf.readBigUInt64LE(strtabHeaderOffset + 24)); + const strtabSize = Number(shTableBuf.readBigUInt64LE(strtabHeaderOffset + 32)); + + const strtabBuf = Buffer.alloc(strtabSize); + readSync(fd, strtabBuf, 0, strtabSize, strtabOffset); + + for (let i = 0; i < e_shnum; i++) { + const off = i * e_shentsize; + const nameIdx = shTableBuf.readUInt32LE(off); + const nameEnd = strtabBuf.indexOf(0, nameIdx); + const name = strtabBuf.toString("utf8", nameIdx, nameEnd === -1 ? undefined : nameEnd); + + if (name === ".upd_info") { + return { + offset: Number(shTableBuf.readBigUInt64LE(off + 24)), + size: Number(shTableBuf.readBigUInt64LE(off + 32)), + }; + } + } + + return null; + } finally { + if (fd !== undefined) { + closeSync(fd); + } + } +} + +export function embedUpdateInfoInAppImage(filePath, updateInfoString) { + const section = findElfUpdInfoSection(filePath); + if (!section) { + console.warn(`[appimage-updateinfo] Warning: .upd_info section not found in ${filePath}`); + return false; + } + + const updateInfoBuffer = Buffer.from(updateInfoString, "utf8"); + if (updateInfoBuffer.length >= section.size) { + throw new Error( + `Update info string too long (${updateInfoBuffer.length} bytes, max ${section.size - 1} bytes)`, + ); + } + + const padded = Buffer.alloc(section.size); + updateInfoBuffer.copy(padded, 0); + + let fd; + try { + fd = openSync(filePath, "r+"); + writeSync(fd, padded, 0, padded.length, section.offset); + console.log( + `[appimage-updateinfo] Embedded update information into ${path.basename(filePath)} (${updateInfoString})`, + ); + return true; + } finally { + if (fd !== undefined) { + closeSync(fd); + } + } +} + +export function generateZsyncFile(appImagePath, zsyncOutputPath) { + const appImageFileName = path.basename(appImagePath); + try { + execFileSync( + "zsyncmake", + ["-u", appImageFileName, "-o", zsyncOutputPath, appImagePath], + { stdio: "inherit" }, + ); + console.log(`[appimage-updateinfo] Generated zsync control file: ${zsyncOutputPath}`); + return true; + } catch (err) { + console.warn( + `[appimage-updateinfo] zsyncmake failed or is not available. Please install 'zsync' to generate delta files: ${err.message}`, + ); + return false; + } +} + +function processReleaseAppImages() { + if (!existsSync(releaseRoot)) { + console.log("[appimage-updateinfo] Release directory does not exist. Skipping."); + return; + } + + const appImageFiles = readdirSync(releaseRoot) + .filter((f) => f.endsWith(".AppImage")) + .map((f) => path.join(releaseRoot, f)) + .filter((f) => statSync(f).isFile()); + + if (appImageFiles.length === 0) { + console.log("[appimage-updateinfo] No .AppImage files found in release directory."); + return; + } + + const owner = process.env.GITHUB_REPOSITORY_OWNER || DEFAULT_OWNER; + const repo = process.env.GITHUB_REPOSITORY?.split("/")[1] || DEFAULT_REPO; + + let hasFailures = false; + + for (const appImagePath of appImageFiles) { + const fileName = path.basename(appImagePath); + const zsyncFileName = `${fileName}.zsync`; + const zsyncOutputPath = path.join(releaseRoot, zsyncFileName); + + const updateInfoString = + process.env.APPIMAGE_UPDATE_INFO || + `gh-releases-zsync|${owner}|${repo}|latest|${zsyncFileName}`; + + const embedded = embedUpdateInfoInAppImage(appImagePath, updateInfoString); + if (!embedded) { + hasFailures = true; + } + + const zsyncGenerated = generateZsyncFile(appImagePath, zsyncOutputPath); + if (!zsyncGenerated) { + hasFailures = true; + } + } + + if (hasFailures && process.env.CI) { + console.error( + "[appimage-updateinfo] Error: Failed to embed update information or generate zsync in CI environment.", + ); + process.exit(1); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + processReleaseAppImages(); +} diff --git a/scripts/write-release-checksums.mjs b/scripts/write-release-checksums.mjs index 747fefb3c..e9e243d04 100644 --- a/scripts/write-release-checksums.mjs +++ b/scripts/write-release-checksums.mjs @@ -6,7 +6,7 @@ const projectRoot = process.cwd(); const releaseRoot = path.join(projectRoot, "release"); const outputFileName = process.argv[2] ?? "SHA256SUMS.txt"; const outputPath = path.join(releaseRoot, outputFileName); -const releaseArtifactExtensions = new Set([".AppImage", ".blockmap", ".dmg", ".exe", ".zip"]); +const releaseArtifactExtensions = new Set([".AppImage", ".blockmap", ".dmg", ".exe", ".zip", ".zsync"]); function relativePath(filePath) { return path.relative(projectRoot, filePath).replaceAll("\\", "/");