import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); const failures = []; const args = process.argv.slice(2); const values = (name) => { const found = []; for (let index = 0; index < args.length; index += 1) { if (args[index] === name && args[index + 1]) { found.push(args[index + 1]); index += 1; } } return found; }; const value = (name) => values(name)[0]; const fail = (message) => failures.push(message); const assert = (condition, message) => { if (!condition) fail(message); }; const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT; const outputDir = resolve( frontendDir, value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed", ); const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString(); const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES; const includes = values("--include").map((path) => resolve(frontendDir, path)); assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required."); assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required."); const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined; const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined; const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined; const uploadFiles = []; const readRequiredFile = async (path, description) => { try { return await readFile(path); } catch (error) { fail(`${description} cannot be read: ${path} (${error.message})`); return undefined; } }; const sha256 = async (path) => { const data = await readFile(path); return createHash("sha256").update(data).digest("hex"); }; const normalizedBaseUrl = () => { if (!baseUrlRaw) return undefined; try { const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`); assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS."); assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials."); assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters."); assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`); return url; } catch (error) { fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`); return undefined; } }; const copyIntoOutput = async (sourcePath) => { const destination = resolve(outputDir, basename(sourcePath)); if (sourcePath !== destination) { await copyFile(sourcePath, destination); } return destination; }; if (resolvedArtifactPath && signaturePath) { await readRequiredFile(resolvedArtifactPath, "Updater artifact"); const signature = await readRequiredFile(signaturePath, "Updater artifact signature"); if (signature) { const signatureText = signature.toString("utf8").trim(); assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short."); } } for (const includePath of includes) { try { const metadata = await stat(includePath); assert(metadata.isFile(), `Included release file must be a file: ${includePath}`); } catch (error) { fail(`Included release file cannot be read: ${includePath} (${error.message})`); } } const baseUrl = normalizedBaseUrl(); if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) { await mkdir(outputDir, { recursive: true }); const artifactUrl = new URL(artifactName, baseUrl).toString(); assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json."); assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters."); const signature = (await readFile(signaturePath, "utf8")).trim(); const latest = { version: packageInfo.version, pub_date: pubDate, platforms: { "darwin-aarch64": { signature, url: artifactUrl, }, "darwin-x86_64": { signature, url: artifactUrl, }, }, }; if (notes) { latest.notes = notes; } uploadFiles.push(await copyIntoOutput(resolvedArtifactPath)); uploadFiles.push(await copyIntoOutput(signaturePath)); for (const includePath of includes) { uploadFiles.push(await copyIntoOutput(includePath)); } const latestPath = resolve(outputDir, "latest.json"); await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`); uploadFiles.push(latestPath); const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()]; const checksumLines = []; for (const filePath of uniqueFiles) { checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`); } const checksumPath = resolve(outputDir, "SHA256SUMS.txt"); await writeFile(checksumPath, `${checksumLines.join("\n")}\n`); console.log(`Desktop update feed created in ${outputDir}`); console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`); console.log(` - SHA256SUMS.txt`); } if (failures.length > 0) { console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); process.exitCode = 1; }