From 33c8bb98b691370bfaa68c5b33ed3722e6558376 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Sun, 12 Apr 2026 12:15:42 +0800 Subject: [PATCH] feat(ota): unify desktop updates with ota service --- .github/scripts/build-ota-release.mjs | 408 ++++- .github/scripts/build-ota-release.test.ts | 484 +++++- .../resolve-desktop-release-config.mjs | 142 ++ .../resolve-desktop-release-config.test.ts | 184 +++ .github/workflows/build-desktop.yml | 131 +- .github/workflows/publish-ota.yml | 3 +- .github/workflows/tag.yml | 14 +- apps/desktop/bump.config.ts | 7 +- .../layer/main/src/updater/api.test.ts | 130 ++ apps/desktop/layer/main/src/updater/api.ts | 247 ++- .../src/updater/follow-update-provider.ts | 84 +- .../layer/main/src/updater/hot-updater.ts | 112 +- .../layer/main/src/updater/index.test.ts | 188 +++ apps/desktop/layer/main/src/updater/index.ts | 265 ++-- apps/desktop/layer/main/src/updater/types.ts | 63 + apps/desktop/package.json | 2 + apps/desktop/release-plan.json | 8 + apps/desktop/release.json | 9 + .../scripts/apply-release-config.impl.ts | 110 ++ .../scripts/apply-release-config.test.ts | 127 ++ apps/desktop/scripts/apply-release-config.ts | 16 + apps/ota/README.md | 62 +- apps/ota/src/__tests__/manifest.test.ts | 254 ++- apps/ota/src/__tests__/policy.test.ts | 300 +++- apps/ota/src/__tests__/schema.test.ts | 132 +- apps/ota/src/__tests__/sync.test.ts | 465 +++++- apps/ota/src/__tests__/version.test.ts | 4 +- apps/ota/src/lib/archive.ts | 7 +- apps/ota/src/lib/constants.ts | 10 +- apps/ota/src/lib/desktop.ts | 76 + apps/ota/src/lib/kv.ts | 42 +- apps/ota/src/lib/manifest.ts | 44 +- apps/ota/src/lib/policy.ts | 74 +- apps/ota/src/lib/request.ts | 38 + apps/ota/src/lib/schema.ts | 163 +- apps/ota/src/lib/sync.ts | 96 +- apps/ota/src/lib/version.ts | 8 +- apps/ota/src/routes/manifest.ts | 91 +- apps/ota/src/routes/policy.ts | 52 +- .../2026-04-11-desktop-ota-unification.md | 1387 +++++++++++++++++ packages/internal/shared/src/env.common.ts | 4 + packages/internal/shared/src/env.desktop.ts | 1 + 42 files changed, 5657 insertions(+), 387 deletions(-) create mode 100644 .github/scripts/resolve-desktop-release-config.mjs create mode 100644 .github/scripts/resolve-desktop-release-config.test.ts create mode 100644 apps/desktop/layer/main/src/updater/api.test.ts create mode 100644 apps/desktop/layer/main/src/updater/index.test.ts create mode 100644 apps/desktop/layer/main/src/updater/types.ts create mode 100644 apps/desktop/release-plan.json create mode 100644 apps/desktop/release.json create mode 100644 apps/desktop/scripts/apply-release-config.impl.ts create mode 100644 apps/desktop/scripts/apply-release-config.test.ts create mode 100644 apps/desktop/scripts/apply-release-config.ts create mode 100644 apps/ota/src/lib/desktop.ts create mode 100644 apps/ota/src/lib/request.ts create mode 100644 docs/superpowers/plans/2026-04-11-desktop-ota-unification.md diff --git a/.github/scripts/build-ota-release.mjs b/.github/scripts/build-ota-release.mjs index fcb6dc258..e056fa3c9 100644 --- a/.github/scripts/build-ota-release.mjs +++ b/.github/scripts/build-ota-release.mjs @@ -2,22 +2,26 @@ import { spawn, spawnSync } from "node:child_process" import { createHash } from "node:crypto" -import { existsSync } from "node:fs" +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" import { mkdir, readFile, writeFile } from "node:fs/promises" import { fileURLToPath } from "node:url" +import yaml from "js-yaml" import { dirname, extname, join, resolve } from "pathe" const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..") const DEFAULT_MOBILE_PROJECT_DIR = join(REPO_ROOT, "apps", "mobile") +const DEFAULT_DESKTOP_PROJECT_DIR = join(REPO_ROOT, "apps", "desktop") const OTA_PLATFORMS = ["ios", "android"] +const DESKTOP_PLATFORMS = new Set(["macos", "windows", "linux"]) const CONTENT_TYPES = new Map([ [".aac", "audio/aac"], [".bmp", "image/bmp"], [".bundle", "application/javascript"], [".css", "text/css"], [".gif", "image/gif"], + [".gz", "application/gzip"], [".hbc", "application/javascript"], [".html", "text/html"], [".jpeg", "image/jpeg"], @@ -40,6 +44,7 @@ const CONTENT_TYPES = new Map([ [".woff2", "font/woff2"], [".xml", "application/xml"], ]) +const DESKTOP_MANIFEST_FILE_NAMES = new Set(["latest.yml", "latest-mac.yml", "latest-linux.yml"]) /** * @typedef {{ @@ -227,9 +232,95 @@ export async function buildReleaseAssets(options = {}) { } } +export async function buildDesktopReleaseAssets(options = {}) { + const projectDir = resolveDesktopProjectDir(options.projectDir) + const distDir = join(projectDir, "dist") + const outputPath = join(distDir, "ota-release.json") + const archivePath = join(projectDir, "dist.tar.zst") + const releaseConfig = await readJson(join(projectDir, "release.json")) + const packageJson = await readJson(join(projectDir, "package.json")) + const releaseVersion = releaseConfig.version ?? packageJson.version + const product = process.env.OTA_PRODUCT ?? "desktop" + const gitTag = process.env.OTA_GIT_TAG ?? `${product}/v${releaseVersion}` + const gitCommit = process.env.OTA_GIT_COMMIT ?? execGit(["rev-parse", "HEAD"], REPO_ROOT) + const publishedAt = process.env.OTA_PUBLISHED_AT ?? new Date().toISOString() + const [owner, repo] = resolveRepository(options) + const releaseKind = releaseConfig.mode === "ota" ? "ota" : "binary" + const runtimeVersion = releaseConfig.mode === "ota" ? releaseConfig.runtimeVersion : null + const desktopApp = + releaseConfig.mode === "binary-policy" + ? null + : await collectDesktopAppPayload({ + projectDir, + owner, + repo, + gitTag, + }) + + if (releaseConfig.mode !== "binary-policy" && !desktopApp) { + throw new Error( + `Desktop ${releaseConfig.mode} mode requires direct installer manifests in ${join(projectDir, "out", "make")}`, + ) + } + const desktopRenderer = + releaseConfig.mode === "ota" + ? await collectDesktopRendererPayload({ + projectDir, + owner, + repo, + gitTag, + }) + : null + + await mkdir(distDir, { recursive: true }) + + const otaMetadata = { + schemaVersion: 2, + product, + channel: releaseConfig.channel ?? null, + releaseVersion, + releaseKind, + runtimeVersion, + publishedAt, + git: { + tag: gitTag, + commit: gitCommit, + }, + policy: { + required: releaseConfig.required ?? false, + minSupportedBinaryVersion: process.env.OTA_MIN_SUPPORTED_BINARY_VERSION ?? releaseVersion, + message: releaseConfig.message ?? null, + distributions: Object.fromEntries( + (releaseConfig.distributions ?? []).map((distribution) => [distribution, {}]), + ), + }, + desktop: { + renderer: desktopRenderer, + app: desktopApp, + }, + } + + await writeFile(outputPath, `${JSON.stringify(otaMetadata, null, 2)}\n`, "utf8") + + if (releaseConfig.mode === "ota") { + await createTarZstArchive({ distDir, archivePath }) + } + + return { + projectDir, + distDir, + outputPath, + archivePath: releaseConfig.mode === "ota" ? archivePath : null, + otaMetadata, + } +} + async function main() { try { - const result = await buildReleaseAssets() + const result = + (process.env.OTA_PRODUCT ?? "mobile") === "desktop" + ? await buildDesktopReleaseAssets() + : await buildReleaseAssets() console.info(`Wrote OTA metadata: ${result.outputPath}`) if (result.archivePath) { @@ -362,6 +453,24 @@ function resolveMobileProjectDir(projectDir) { return DEFAULT_MOBILE_PROJECT_DIR } +function resolveDesktopProjectDir(projectDir) { + if (projectDir) { + return resolve(projectDir) + } + + const cwd = resolve(process.cwd()) + + if (existsSync(join(cwd, "package.json")) && existsSync(join(cwd, "release.json"))) { + return cwd + } + + if (existsSync(join(cwd, "apps", "desktop", "package.json"))) { + return join(cwd, "apps", "desktop") + } + + return DEFAULT_DESKTOP_PROJECT_DIR +} + function normalizeAssetPath(assetPath) { const normalized = assetPath .replaceAll("\\", "/") @@ -377,7 +486,10 @@ function normalizeAssetPath(assetPath) { } function resolveContentType(asset) { - const pathExtension = extname(asset.path).toLowerCase() + const normalizedPath = String(asset.path).toLowerCase() + const pathExtension = normalizedPath.endsWith(".tar.gz") + ? ".gz" + : extname(normalizedPath).toLowerCase() const metadataExtension = asset.ext ? `.${asset.ext}` : "" const extension = pathExtension || metadataExtension @@ -480,6 +592,296 @@ async function readJson(path) { } } +async function collectDesktopRendererPayload(input) { + const rendererManifestPath = join(input.projectDir, "dist", "manifest.yml") + + if (!existsSync(rendererManifestPath)) { + throw new Error(`Missing desktop renderer manifest at ${rendererManifestPath}`) + } + + const rendererManifest = parseYamlFile(rendererManifestPath) + + if (typeof rendererManifest.version !== "string" || !rendererManifest.version) { + throw new Error(`Desktop renderer manifest at ${rendererManifestPath} is missing version`) + } + + if (typeof rendererManifest.commit !== "string" || !rendererManifest.commit) { + throw new Error(`Desktop renderer manifest at ${rendererManifestPath} is missing commit`) + } + + const filename = + typeof rendererManifest.filename === "string" && rendererManifest.filename + ? rendererManifest.filename + : "render-asset.tar.gz" + const rendererArchivePath = join(input.projectDir, "dist", filename) + + if (!existsSync(rendererArchivePath)) { + throw new Error(`Missing desktop renderer archive at ${rendererArchivePath}`) + } + + return { + version: rendererManifest.version, + commit: rendererManifest.commit, + manifest: { + name: "manifest.yml", + downloadUrl: buildGitHubAssetUrl({ + owner: input.owner, + repo: input.repo, + tag: input.gitTag, + filename: "manifest.yml", + }), + }, + launchAsset: await resolveFileAsset({ + sourcePath: rendererArchivePath, + metadataPath: join("renderer", filename), + }), + assets: [], + } +} + +async function collectDesktopAppPayload(input) { + const makeDir = join(input.projectDir, "out", "make") + + if (!existsSync(makeDir)) { + return null + } + + const platformEntries = await collectDesktopPlatformEntries({ + makeDir, + owner: input.owner, + repo: input.repo, + gitTag: input.gitTag, + }) + + if (Object.keys(platformEntries).length === 0) { + return null + } + + return { + platforms: platformEntries, + } +} + +async function collectDesktopPlatformEntries(input) { + /** @type {Record} */ + const result = {} + + for (const manifestPath of walkFiles(input.makeDir)) { + const fileName = manifestPath.split("/").at(-1) + if (!fileName || !DESKTOP_MANIFEST_FILE_NAMES.has(fileName)) { + continue + } + + const manifest = parseYamlFile(manifestPath) + const platformKey = inferDesktopPlatformKey(manifestPath, fileName) + + if (!platformKey || !DESKTOP_PLATFORMS.has(platformKey)) { + continue + } + + const files = normalizeDesktopManifestFiles(manifest.files, manifestPath) + if (files.length === 0) { + continue + } + + const nextEntry = { + platform: inferDesktopPlatformIdentifier(platformKey, manifestPath), + releaseDate: normalizeDesktopReleaseDate(manifest.releaseDate), + manifest: { + name: fileName, + path: fileName, + downloadUrl: buildGitHubAssetUrl({ + owner: input.owner, + repo: input.repo, + tag: input.gitTag, + filename: fileName, + }), + }, + files: files.map((file) => ({ + filename: file.url, + sha512: file.sha512, + size: file.size, + downloadUrl: buildGitHubAssetUrl({ + owner: input.owner, + repo: input.repo, + tag: input.gitTag, + filename: file.url, + }), + })), + } + + result[platformKey] = result[platformKey] + ? mergeDesktopPlatformEntries(result[platformKey], nextEntry) + : nextEntry + } + + return result +} + +function inferDesktopPlatformKey(manifestPath, fileName) { + const normalized = manifestPath.replaceAll("\\", "/").toLowerCase() + + if ( + fileName === "latest-mac.yml" || + normalized.includes("/darwin") || + normalized.includes("/mac") + ) { + return "macos" + } + + if (fileName === "latest-linux.yml" || normalized.includes("/linux")) { + return "linux" + } + + if ( + fileName === "latest.yml" || + normalized.includes("/windows") || + normalized.includes("/squirrel.windows") + ) { + return "windows" + } + + return null +} + +function inferDesktopPlatformIdentifier(platformKey, manifestPath) { + const normalized = manifestPath.replaceAll("\\", "/").toLowerCase() + const arch = normalized.includes("/arm64") ? "arm64" : normalized.includes("/x64") ? "x64" : null + + if (!arch) { + return platformKey + } + + return `${platformKey}-${arch}` +} + +function normalizeDesktopManifestFiles(files, manifestPath) { + if (!Array.isArray(files)) { + throw new TypeError(`Desktop manifest at ${manifestPath} is missing files`) + } + + return files.map((file, index) => { + const url = typeof file?.url === "string" ? file.url : null + const sha512 = typeof file?.sha512 === "string" ? file.sha512 : null + const size = + typeof file?.size === "number" ? file.size : Number.parseInt(String(file?.size ?? 0), 10) + + if (!url || !sha512 || !Number.isFinite(size) || size < 0) { + throw new Error(`Desktop manifest at ${manifestPath} has invalid files[${index}] entry`) + } + + return { + url, + sha512, + size, + } + }) +} + +function normalizeDesktopReleaseDate(value) { + if (typeof value === "string") { + return value + } + + if (value instanceof Date) { + return value.toISOString() + } + + return null +} + +function mergeDesktopPlatformEntries(currentEntry, nextEntry) { + const fileByName = new Map(currentEntry.files.map((file) => [file.filename, file])) + + for (const file of nextEntry.files) { + fileByName.set(file.filename, file) + } + + const mergedReleaseDate = pickLatestReleaseDate(currentEntry.releaseDate, nextEntry.releaseDate) + + return { + platform: + currentEntry.platform === nextEntry.platform + ? currentEntry.platform + : (currentEntry.platform.split("-")[0] ?? currentEntry.platform), + releaseDate: mergedReleaseDate, + manifest: currentEntry.manifest, + files: [...fileByName.values()], + } +} + +function pickLatestReleaseDate(currentValue, nextValue) { + const currentTime = currentValue ? Date.parse(currentValue) : Number.NaN + const nextTime = nextValue ? Date.parse(nextValue) : Number.NaN + + if (Number.isFinite(currentTime) && Number.isFinite(nextTime)) { + return currentTime >= nextTime ? currentValue : nextValue + } + + return nextValue ?? currentValue ?? null +} + +function parseYamlFile(path) { + try { + const content = readFileSync(path, "utf8") + return yaml.load(String(content)) ?? {} + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse YAML at ${path}: ${reason}`) + } +} + +async function resolveFileAsset(input) { + const buffer = await readFile(input.sourcePath) + return { + path: normalizeAssetPath(input.metadataPath), + sha256: createHash("sha256").update(buffer).digest("hex"), + contentType: resolveContentType({ path: input.metadataPath }), + } +} + +function buildGitHubAssetUrl({ owner, repo, tag, filename }) { + return `https://github.com/${owner}/${repo}/releases/download/${tag}/${filename}` +} + +function resolveRepository(options) { + if (options.owner && options.repo) { + return [options.owner, options.repo] + } + + const repository = process.env.GITHUB_REPOSITORY + if (repository && repository.includes("/")) { + return repository.split("/", 2) + } + + return ["RSSNext", "Folo"] +} + +function walkFiles(rootDir) { + /** @type {string[]} */ + const results = [] + /** @type {string[]} */ + const pending = [rootDir] + + while (pending.length > 0) { + const current = pending.pop() + if (!current) continue + + for (const entryName of readdirSync(current)) { + const entry = join(current, entryName) + if (!existsSync(entry)) continue + + if (statSync(entry).isDirectory()) { + pending.push(entry) + } else { + results.push(entry) + } + } + } + + return results.sort() +} + function parseBooleanEnv(name, defaultValue) { const value = process.env[name] diff --git a/.github/scripts/build-ota-release.test.ts b/.github/scripts/build-ota-release.test.ts index d873891d2..611259768 100644 --- a/.github/scripts/build-ota-release.test.ts +++ b/.github/scripts/build-ota-release.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "pathe" @@ -16,7 +16,12 @@ afterEach(async () => { delete process.env.OTA_RELEASE_VERSION delete process.env.OTA_RUNTIME_VERSION delete process.env.OTA_CHANNEL + delete process.env.OTA_PRODUCT + delete process.env.OTA_GIT_TAG + delete process.env.OTA_GIT_COMMIT + delete process.env.OTA_PUBLISHED_AT delete process.env.OTA_STORE_REQUIRED + delete process.env.GITHUB_REPOSITORY }) describe("buildOtaMetadata", () => { @@ -49,11 +54,15 @@ describe("buildOtaMetadata", () => { }, }, }, - resolveAsset: async (assetPath: string) => ({ - path: assetPath, - sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64), - contentType: "application/octet-stream", - }), + resolveAsset: async (asset: InputAsset) => { + const assetPath = typeof asset === "string" ? asset : asset.path + + return { + path: assetPath, + sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64), + contentType: "application/octet-stream", + } + }, }) expect(result.releaseVersion).toBe("0.4.2") @@ -139,11 +148,15 @@ describe("buildOtaMetadata", () => { }, }, }, - resolveAsset: async (assetPath: string) => ({ - path: assetPath, - sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64), - contentType: "application/octet-stream", - }), + resolveAsset: async (asset: InputAsset) => { + const assetPath = typeof asset === "string" ? asset : asset.path + + return { + path: assetPath, + sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64), + contentType: "application/octet-stream", + } + }, }), ).rejects.toThrow(/android/i) }) @@ -177,11 +190,15 @@ describe("buildOtaMetadata", () => { }, }, }, - resolveAsset: async (assetPath: string) => ({ - path: assetPath, - sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64), - contentType: "application/octet-stream", - }), + resolveAsset: async (asset: InputAsset) => { + const assetPath = typeof asset === "string" ? asset : asset.path + + return { + path: assetPath, + sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64), + contentType: "application/octet-stream", + } + }, }), ).rejects.toThrow(/ios.*assets/i) }) @@ -286,3 +303,438 @@ describe("buildReleaseAssets", () => { await expect(buildReleaseAssets({ projectDir })).rejects.toThrow(metadataPath) }) }) + +describe("buildDesktopReleaseAssets", () => { + it("builds schemaVersion 2 desktop build metadata with direct app payloads only", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + manifests: [ + { + relativeDir: join("out", "make", "squirrel.windows", "x64"), + manifestName: "latest.yml", + contents: ` +version: 1.5.1 +files: + - url: Folo-1.5.1-windows-x64.exe + sha512: ${"a".repeat(88)} + size: 123456 +releaseDate: 2026-04-11T10:00:00.000Z +`, + }, + ], + }) + + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.schemaVersion).toBe(2) + expect(result.otaMetadata.product).toBe("desktop") + expect(result.otaMetadata.releaseKind).toBe("binary") + expect(result.otaMetadata.runtimeVersion).toBeNull() + expect(result.otaMetadata.channel).toBeNull() + expect(result.otaMetadata.desktop.renderer).toBeNull() + expect(result.otaMetadata.desktop.app.platforms.windows).toEqual( + expect.objectContaining({ + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00.000Z", + manifest: { + name: "latest.yml", + path: "latest.yml", + downloadUrl: expect.stringContaining("/desktop/v1.5.1/latest.yml"), + }, + files: [ + expect.objectContaining({ + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "a".repeat(88), + size: 123456, + downloadUrl: expect.stringContaining("/desktop/v1.5.1/Folo-1.5.1-windows-x64.exe"), + }), + ], + }), + ) + expect(result.archivePath).toBeNull() + + const written = JSON.parse(await readFile(result.outputPath, "utf8")) + expect(written.schemaVersion).toBe(2) + expect(written.desktop.renderer).toBeNull() + }) + + it("builds schemaVersion 2 desktop ota metadata with renderer and direct app payloads", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + rendererArchiveContents: "renderer archive", + rendererManifestContents: ` +version: 1.5.1 +hash: ${"b".repeat(64)} +mainHash: ${"c".repeat(40)} +commit: abcdef1234567890 +filename: custom-renderer.tar.gz +`, + manifests: [ + { + relativeDir: join("out", "make", "squirrel.windows", "x64"), + manifestName: "latest.yml", + contents: ` +version: 1.5.1 +files: + - url: Folo-1.5.1-windows-x64.exe + sha512: ${"d".repeat(88)} + size: 654321 +releaseDate: 2026-04-11T10:00:00.000Z +`, + }, + ], + }) + + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.schemaVersion).toBe(2) + expect(result.otaMetadata.releaseKind).toBe("ota") + expect(result.otaMetadata.runtimeVersion).toBe("1.5.0") + expect(result.otaMetadata.channel).toBe("stable") + expect(result.otaMetadata.desktop.renderer).toEqual( + expect.objectContaining({ + version: "1.5.1", + commit: "abcdef1234567890", + manifest: { + name: "manifest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/manifest.yml", + }, + launchAsset: expect.objectContaining({ + path: "renderer/custom-renderer.tar.gz", + contentType: "application/gzip", + }), + }), + ) + expect(result.otaMetadata.desktop.renderer.launchAsset.sha256).toHaveLength(64) + expect(result.otaMetadata.desktop.app.platforms.windows.files[0]?.downloadUrl).toContain( + "/desktop/v1.5.1/Folo-1.5.1-windows-x64.exe", + ) + expect(result.archivePath).not.toBeNull() + }) + + it("builds desktop binary-policy metadata without dist.tar.zst", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "binary-policy", + runtimeVersion: null, + channel: "stable", + distributions: ["mas", "mss"], + required: true, + message: "Install the latest desktop app.", + }, + }) + + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.schemaVersion).toBe(2) + expect(result.otaMetadata.releaseKind).toBe("binary") + expect(result.otaMetadata.runtimeVersion).toBeNull() + expect(result.otaMetadata.channel).toBe("stable") + expect(result.otaMetadata.policy).toEqual({ + required: true, + minSupportedBinaryVersion: "1.5.1", + message: "Install the latest desktop app.", + distributions: { + mas: {}, + mss: {}, + }, + }) + expect(result.otaMetadata.desktop).toEqual({ + renderer: null, + app: null, + }) + expect(result.archivePath).toBeNull() + }) + + it("keeps desktop binary-policy metadata installer-free even if manifests exist", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "binary-policy", + runtimeVersion: null, + channel: "stable", + distributions: ["direct"], + required: false, + message: "Use the latest direct installer.", + }, + manifests: [ + { + relativeDir: join("out", "make", "squirrel.windows", "x64"), + manifestName: "latest.yml", + contents: ` +version: 1.5.1 +files: + - url: Folo-1.5.1-windows-x64.exe + sha512: ${"1".repeat(88)} + size: 123456 +releaseDate: 2026-04-11T10:00:00.000Z +`, + }, + ], + }) + + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.desktop.app).toBeNull() + expect(result.archivePath).toBeNull() + }) + + it("fails when a desktop manifest contains an invalid file entry", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + manifests: [ + { + relativeDir: join("out", "make", "squirrel.windows", "x64"), + manifestName: "latest.yml", + contents: ` +version: 1.5.1 +files: + - url: Folo-1.5.1-windows-x64.exe + size: 123456 +releaseDate: 2026-04-11T10:00:00.000Z +`, + }, + ], + }) + + await expect( + buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }), + ).rejects.toThrow(/invalid files\[0\] entry/i) + }) + + it("fails for build mode when direct installer manifests are missing", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + }) + + await expect( + buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }), + ).rejects.toThrow(/requires direct installer manifests/i) + }) + + it("fails for ota mode when direct installer manifests are missing", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + rendererArchiveContents: "renderer archive", + rendererManifestContents: ` +version: 1.5.1 +hash: ${"2".repeat(64)} +mainHash: ${"3".repeat(40)} +commit: abcdef1234567890 +filename: custom-renderer.tar.gz +`, + }) + + await expect( + buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }), + ).rejects.toThrow(/requires direct installer manifests/i) + }) + + it("merges duplicate desktop platform manifests instead of overwriting them", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await createDesktopProjectFixture({ + version: "1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + manifests: [ + { + relativeDir: join("out", "make", "squirrel.windows", "x64"), + manifestName: "latest.yml", + contents: ` +version: 1.5.1 +files: + - url: Folo-1.5.1-windows-x64.exe + sha512: ${"e".repeat(88)} + size: 123456 +releaseDate: 2026-04-11T10:00:00.000Z +`, + }, + { + relativeDir: join("out", "make", "squirrel.windows", "arm64"), + manifestName: "latest.yml", + contents: ` +version: 1.5.1 +files: + - url: Folo-1.5.1-windows-arm64.exe + sha512: ${"f".repeat(88)} + size: 234567 +releaseDate: 2026-04-11T11:00:00.000Z +`, + }, + ], + }) + + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.desktop.app.platforms.windows.files).toHaveLength(2) + expect(result.otaMetadata.desktop.app.platforms.windows.releaseDate).toBe( + "2026-04-11T11:00:00.000Z", + ) + }) +}) + +async function createDesktopProjectFixture(input: { + version: string + releaseConfig: { + version: string + mode: "build" | "ota" | "binary-policy" + runtimeVersion: string | null + channel: "stable" | "beta" | "alpha" | null + distributions: Array<"direct" | "mas" | "mss"> + required: boolean + message: string | null + } + manifests?: Array<{ + relativeDir: string + manifestName: string + contents: string + }> + rendererArchiveContents?: string + rendererManifestContents?: string +}) { + const projectDir = await mkdtemp(join(tmpdir(), "build-ota-release-desktop-test-")) + tempDirs.push(projectDir) + + await writeFile( + join(projectDir, "package.json"), + JSON.stringify({ name: "@follow/desktop-test", version: input.version }), + "utf8", + ) + await writeFile( + join(projectDir, "release.json"), + `${JSON.stringify(input.releaseConfig, null, 2)}\n`, + "utf8", + ) + + if (input.rendererArchiveContents != null || input.rendererManifestContents != null) { + await mkdir(join(projectDir, "dist"), { recursive: true }) + } + + if (input.rendererArchiveContents != null) { + await writeFile( + join( + projectDir, + "dist", + input.rendererManifestContents?.includes("custom-renderer.tar.gz") + ? "custom-renderer.tar.gz" + : "render-asset.tar.gz", + ), + input.rendererArchiveContents, + "utf8", + ) + } + + if (input.rendererManifestContents != null) { + await writeFile( + join(projectDir, "dist", "manifest.yml"), + input.rendererManifestContents.trimStart(), + "utf8", + ) + } + + for (const manifest of input.manifests ?? []) { + await mkdir(join(projectDir, manifest.relativeDir), { recursive: true }) + await writeFile( + join(projectDir, manifest.relativeDir, manifest.manifestName), + manifest.contents.trimStart(), + "utf8", + ) + } + + return projectDir +} diff --git a/.github/scripts/resolve-desktop-release-config.mjs b/.github/scripts/resolve-desktop-release-config.mjs new file mode 100644 index 000000000..943e12cbd --- /dev/null +++ b/.github/scripts/resolve-desktop-release-config.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import { appendFileSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { pathToFileURL } from "node:url" + +const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/ +const VALID_MODES = new Set(["build", "ota", "binary-policy"]) +const VALID_CHANNELS = new Set(["stable", "beta", "alpha"]) +const VALID_DISTRIBUTIONS = new Set(["direct", "mas", "mss"]) + +export function resolveDesktopReleaseConfig(input) { + const normalizedReleaseVersion = input.releaseVersion.replace(/^v/, "") + const config = input.releaseConfig + + if (config.version !== normalizedReleaseVersion) { + throw new Error( + `apps/desktop/release.json version ${config.version} does not match release version ${normalizedReleaseVersion}.`, + ) + } + + if (!VALID_MODES.has(config.mode)) { + throw new Error("apps/desktop/release.json mode must be build, ota, or binary-policy.") + } + + if ( + !Array.isArray(config.distributions) || + config.distributions.some((item) => !VALID_DISTRIBUTIONS.has(item)) + ) { + throw new Error( + "apps/desktop/release.json distributions must only contain direct, mas, or mss.", + ) + } + + if (config.mode === "build") { + if (config.runtimeVersion !== null || config.channel !== null) { + throw new Error("desktop build mode must not set runtimeVersion or channel") + } + + return { + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: null, + runtimeVersion: null, + channel: null, + distributions: "", + required: "", + policyMessage: "", + releaseVersion: normalizedReleaseVersion, + } + } + + if (config.mode === "ota") { + if (!config.runtimeVersion || !SEMVER_PATTERN.test(config.runtimeVersion)) { + throw new Error("apps/desktop/release.json runtimeVersion must be a plain x.y.z version.") + } + + if (!config.channel || !VALID_CHANNELS.has(config.channel)) { + throw new Error("apps/desktop/release.json channel must be stable, beta, or alpha.") + } + + return { + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: "ota", + runtimeVersion: config.runtimeVersion, + channel: config.channel, + distributions: config.distributions.join(","), + required: String(config.required ?? false), + policyMessage: "", + releaseVersion: normalizedReleaseVersion, + } + } + + if (!config.channel || !VALID_CHANNELS.has(config.channel)) { + throw new Error("apps/desktop/release.json channel must be stable, beta, or alpha.") + } + + if (config.distributions.length === 0) { + throw new Error( + "apps/desktop/release.json binary-policy mode requires at least one distribution.", + ) + } + + return { + triggerDirectBuild: false, + triggerStoreBuilds: false, + triggerMetadataPublish: true, + releaseKind: "binary", + runtimeVersion: null, + channel: config.channel, + distributions: config.distributions.join(","), + required: String(config.required ?? false), + policyMessage: config.message ?? "", + releaseVersion: normalizedReleaseVersion, + } +} + +async function readReleaseConfig(path) { + const raw = await readFile(path, "utf8") + return JSON.parse(raw) +} + +function setGitHubOutput(key, value) { + if (!process.env.GITHUB_OUTPUT) { + return + } + + const delimiter = `__GITHUB_OUTPUT_${key}_${Date.now()}_${Math.random().toString(16).slice(2)}__` + appendFileSync(process.env.GITHUB_OUTPUT, `${key}<<${delimiter}\n${value}\n${delimiter}\n`) +} + +async function main() { + try { + const configPath = process.env.RELEASE_CONFIG_PATH ?? "apps/desktop/release.json" + const releaseConfig = await readReleaseConfig(configPath) + const result = resolveDesktopReleaseConfig({ + releaseVersion: process.env.RELEASE_VERSION ?? "", + releaseConfig, + }) + + setGitHubOutput("triggerDirectBuild", String(result.triggerDirectBuild)) + setGitHubOutput("triggerStoreBuilds", String(result.triggerStoreBuilds)) + setGitHubOutput("triggerMetadataPublish", String(result.triggerMetadataPublish)) + setGitHubOutput("releaseKind", result.releaseKind ?? "") + setGitHubOutput("runtimeVersion", result.runtimeVersion ?? "") + setGitHubOutput("channel", result.channel ?? "") + setGitHubOutput("distributions", result.distributions ?? "") + setGitHubOutput("required", result.required ?? "") + setGitHubOutput("policyMessage", result.policyMessage ?? "") + setGitHubOutput("releaseVersion", result.releaseVersion) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/.github/scripts/resolve-desktop-release-config.test.ts b/.github/scripts/resolve-desktop-release-config.test.ts new file mode 100644 index 000000000..6f5bbc103 --- /dev/null +++ b/.github/scripts/resolve-desktop-release-config.test.ts @@ -0,0 +1,184 @@ +import { execFile } from "node:child_process" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { promisify } from "node:util" + +import { join } from "pathe" +import { describe, expect, it } from "vitest" + +const execFileAsync = promisify(execFile) + +describe("resolveDesktopReleaseConfig", () => { + it("triggers direct and store builds for build mode", async () => { + const { resolveDesktopReleaseConfig } = await import("./resolve-desktop-release-config.mjs") + + expect( + resolveDesktopReleaseConfig({ + releaseVersion: "v1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + }), + ).toEqual({ + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: null, + runtimeVersion: null, + channel: null, + distributions: "", + required: "", + policyMessage: "", + releaseVersion: "1.5.1", + }) + }) + + it("triggers direct and store builds for ota mode", async () => { + const { resolveDesktopReleaseConfig } = await import("./resolve-desktop-release-config.mjs") + + expect( + resolveDesktopReleaseConfig({ + releaseVersion: "v1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + }), + ).toEqual({ + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: "direct", + required: "false", + policyMessage: "", + releaseVersion: "1.5.1", + }) + }) + + it("triggers metadata publishing only for binary-policy mode", async () => { + const { resolveDesktopReleaseConfig } = await import("./resolve-desktop-release-config.mjs") + + expect( + resolveDesktopReleaseConfig({ + releaseVersion: "v1.5.2", + releaseConfig: { + version: "1.5.2", + mode: "binary-policy", + runtimeVersion: null, + channel: "beta", + distributions: ["mas", "mss"], + required: true, + message: "Install the store binary update.", + }, + }), + ).toEqual({ + triggerDirectBuild: false, + triggerStoreBuilds: false, + triggerMetadataPublish: true, + releaseKind: "binary", + runtimeVersion: null, + channel: "beta", + distributions: "mas,mss", + required: "true", + policyMessage: "Install the store binary update.", + releaseVersion: "1.5.2", + }) + }) + + it("rejects a release config that does not match the release version", async () => { + const { resolveDesktopReleaseConfig } = await import("./resolve-desktop-release-config.mjs") + + expect(() => + resolveDesktopReleaseConfig({ + releaseVersion: "v1.5.2", + releaseConfig: { + version: "1.5.1", + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + }), + ).toThrow(/does not match release version/i) + }) + + it("rejects build mode when runtimeVersion or channel is set", async () => { + const { resolveDesktopReleaseConfig } = await import("./resolve-desktop-release-config.mjs") + + expect(() => + resolveDesktopReleaseConfig({ + releaseVersion: "v1.5.2", + releaseConfig: { + version: "1.5.2", + mode: "build", + runtimeVersion: "1.5.1", + channel: "stable", + distributions: [], + required: false, + message: null, + }, + }), + ).toThrow(/must not set runtimeVersion or channel/i) + }) + + it("writes GitHub outputs safely for multiline policy messages", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "desktop-release-config-output-")) + + try { + const releaseConfigPath = join(projectDir, "release.json") + const githubOutputPath = join(projectDir, "github-output.txt") + + await writeFile( + releaseConfigPath, + `${JSON.stringify( + { + version: "1.5.2", + mode: "binary-policy", + runtimeVersion: null, + channel: "beta", + distributions: ["mas"], + required: true, + message: "Install the new build.\nStore rollout is required.", + }, + null, + 2, + )}\n`, + "utf8", + ) + + await execFileAsync("node", [".github/scripts/resolve-desktop-release-config.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + RELEASE_VERSION: "1.5.2", + RELEASE_CONFIG_PATH: releaseConfigPath, + GITHUB_OUTPUT: githubOutputPath, + }, + }) + + const output = await readFile(githubOutputPath, "utf8") + + expect(output).toContain("policyMessage<<") + expect(output).toContain("Install the new build.\nStore rollout is required.") + expect(output).not.toContain("policyMessage=Install the new build.\n") + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + }) +}) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 73d26f5dd..511dd2e3b 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -289,6 +289,47 @@ jobs: apps/desktop/out/make/**/latest-linux.yml retention-days: 90 + - name: Prepare release assets for aggregation + if: env.RELEASE == 'true' + env: + DESKTOP_RELEASE_ASSET_DIR: ${{ runner.temp }}/desktop-release-assets + run: | + node --input-type=module <<'EOF' + import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs" + import { join } from "node:path" + + const stageDir = process.env.DESKTOP_RELEASE_ASSET_DIR + + if (!stageDir) { + throw new Error("DESKTOP_RELEASE_ASSET_DIR is not set") + } + + rmSync(stageDir, { recursive: true, force: true }) + mkdirSync(join(stageDir, "apps", "desktop", "out"), { recursive: true }) + + const pathsToCopy = [ + ["apps/desktop/out/make", join(stageDir, "apps", "desktop", "out", "make")], + ["apps/desktop/dist", join(stageDir, "apps", "desktop", "dist")], + ] + + for (const [sourcePath, destinationPath] of pathsToCopy) { + if (!existsSync(sourcePath)) { + continue + } + + cpSync(sourcePath, destinationPath, { recursive: true }) + } + EOF + + - name: Upload release assets for aggregation + if: env.RELEASE == 'true' + uses: actions/upload-artifact@v7 + with: + name: desktop-release-assets-${{ runner.os }} + path: ${{ runner.temp }}/desktop-release-assets/apps + if-no-files-found: error + retention-days: 7 + - name: Generate artifact attestation if: env.RELEASE == 'true' continue-on-error: true @@ -303,21 +344,68 @@ jobs: apps/desktop/dist/manifest.yml apps/desktop/dist/*.tar.gz + publish-release: + if: github.secret_source != 'None' && needs.release.result == 'success' && (github.event.inputs.tag_version == 'true' || github.ref_type == 'tag') + needs: release + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Check out Git repository Fully + uses: actions/checkout@v6 + with: + fetch-depth: 0 + lfs: true + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm i + + - name: Download release assets + uses: actions/download-artifact@v5 + with: + pattern: desktop-release-assets-* + path: . + merge-multiple: true + + - name: Ensure zstd + shell: bash + run: | + if command -v zstd >/dev/null 2>&1; then + zstd --version + exit 0 + fi + + sudo apt-get update + sudo apt-get install -y zstd + + - name: Build desktop OTA release assets + env: + OTA_PRODUCT: desktop + run: node .github/scripts/build-ota-release.mjs + - run: npx changelogithub - if: env.RELEASE == 'true' continue-on-error: true env: GITHUB_TOKEN: ${{ github.token }} - name: Setup Version - if: env.RELEASE == 'true' id: version uses: ./.github/actions/setup-version with: type: "desktop" - name: Prepare Release Notes - if: env.RELEASE == 'true' id: release_notes shell: bash run: | @@ -337,8 +425,34 @@ jobs: echo "body_path=${release_notes_file}" >> "$GITHUB_OUTPUT" + - name: Prepare release files + id: release_files + shell: bash + run: | + release_files="$RUNNER_TEMP/desktop-release-files.txt" + + cat <<'EOF' > "$release_files" + apps/desktop/out/make/**/Folo-*.dmg + apps/desktop/out/make/**/Folo-*.zip + apps/desktop/out/make/**/Folo-*.exe + apps/desktop/out/make/**/Folo-*.AppImage + apps/desktop/out/make/**/*.yml + apps/desktop/dist/manifest.yml + apps/desktop/dist/*.tar.gz + apps/desktop/dist/ota-release.json + EOF + + if [ -f apps/desktop/dist.tar.zst ]; then + echo "apps/desktop/dist.tar.zst" >> "$release_files" + fi + + { + echo "files<> "$GITHUB_OUTPUT" + - name: Create Release Draft - if: env.RELEASE == 'true' uses: softprops/action-gh-release@v2 with: name: Desktop v${{ steps.version.outputs.APP_VERSION }} @@ -346,11 +460,4 @@ jobs: prerelease: true tag_name: desktop/v${{ steps.version.outputs.APP_VERSION }} body_path: ${{ steps.release_notes.outputs.body_path }} - files: | - apps/desktop/out/make/**/Folo-*.dmg - apps/desktop/out/make/**/Folo-*.zip - apps/desktop/out/make/**/Folo-*.exe - apps/desktop/out/make/**/Folo-*.AppImage - apps/desktop/out/make/**/*.yml - apps/desktop/dist/manifest.yml - apps/desktop/dist/*.tar.gz + files: ${{ steps.release_files.outputs.files }} diff --git a/.github/workflows/publish-ota.yml b/.github/workflows/publish-ota.yml index a270d50bd..ac5c0f7de 100644 --- a/.github/workflows/publish-ota.yml +++ b/.github/workflows/publish-ota.yml @@ -91,8 +91,7 @@ jobs: run: pnpm run update:export - name: Build OTA release bundle - working-directory: apps/mobile - run: pnpm run update:bundle + run: node .github/scripts/build-ota-release.mjs - name: Publish GitHub release assets env: diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 25cf17972..23e385005 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -98,8 +98,16 @@ jobs: git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Resolve desktop build version - id: desktop_build_version if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' + id: desktop_release_mode + env: + RELEASE_VERSION: ${{ needs.create_tag.outputs.version }} + RELEASE_CONFIG_PATH: apps/desktop/release.json + run: node .github/scripts/resolve-desktop-release-config.mjs + + - name: Resolve desktop build version + id: desktop_build_version + if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' && steps.desktop_release_mode.outputs.triggerStoreBuilds == 'true' run: | git fetch --tags --force latest_tag="$(git tag -l 'desktop-build/v*' --sort=-v:refname | head -n 1)" @@ -123,7 +131,7 @@ jobs: echo "Desktop build version: ${next_build}" - name: Trigger Desktop Tag Version Build - if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' + if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' && steps.desktop_release_mode.outputs.triggerDirectBuild == 'true' uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -141,7 +149,7 @@ jobs: console.log('Desktop Tag Version build triggered successfully'); - name: Trigger Desktop Store Build - if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' + if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' && steps.desktop_release_mode.outputs.triggerStoreBuilds == 'true' uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/desktop/bump.config.ts b/apps/desktop/bump.config.ts index b4d16b9cb..fd8c9ef8b 100644 --- a/apps/desktop/bump.config.ts +++ b/apps/desktop/bump.config.ts @@ -11,7 +11,12 @@ export default defineConfig({ "pnpm prettier --ignore-unknown --write package.json", "git add package.json", ], - trailing: ["git checkout -b release/desktop/${NEW_VERSION}"], + trailing: [ + "tsx scripts/apply-release-config.ts ${NEW_VERSION}", + "git add package.json", + "git add release.json release-plan.json", + "git checkout -b release/desktop/${NEW_VERSION}", + ], finally: [ "git push origin release/desktop/${NEW_VERSION}", "gh pr create --title 'release(desktop): Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base main --head release/desktop/${NEW_VERSION}", diff --git a/apps/desktop/layer/main/src/updater/api.test.ts b/apps/desktop/layer/main/src/updater/api.test.ts new file mode 100644 index 000000000..568401e2f --- /dev/null +++ b/apps/desktop/layer/main/src/updater/api.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@follow/shared/env.desktop", () => ({ + env: { + VITE_OTA_URL: "https://ota.folo.is", + }, +})) + +vi.mock("@follow/utils/headers", () => ({ + createDesktopAPIHeaders: () => ({ + "X-App-Platform": "desktop/windows/exe", + "X-App-Version": "1.5.0", + }), +})) + +vi.mock("@pkg", () => ({ + default: { + version: "1.5.0", + runtimeVersion: "1.5.0", + }, + version: "1.5.0", + runtimeVersion: "1.5.0", +})) + +vi.mock("../env", () => ({ + channel: "stable", +})) + +vi.mock("~/updater/hot-updater", () => ({ + getCurrentRendererManifest: () => null, +})) + +describe("desktop updater api", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()) + }) + + it("returns null when desktop manifest responds 204", async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })) + + const { fetchDesktopManifest } = await import("./api") + const result = await fetchDesktopManifest() + + expect(result).toBeNull() + expect(fetch).toHaveBeenCalledWith( + new URL("/manifest", "https://ota.folo.is"), + expect.objectContaining({ + headers: expect.objectContaining({ + "X-App-Platform": "desktop/windows/exe", + "X-App-Version": "1.5.0", + "X-App-Channel": "stable", + "X-App-Runtime-Version": "1.5.0", + "X-App-Renderer-Version": "1.5.0", + }), + }), + ) + }) + + it("parses desktop manifest responses", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + id: "manifest-id", + createdAt: "2026-04-11T10:00:00.000Z", + product: "desktop", + channel: "stable", + runtimeVersion: "1.5.0", + renderer: { + releaseVersion: "1.5.1", + version: "1.5.1", + commit: "abcdef1234567890", + launchAsset: { + key: "custom-renderer", + hash: "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo", + contentType: "application/gzip", + url: "https://ota.folo.is/assets/desktop/stable/1.5.0/1.5.1/windows/renderer/custom-renderer.tar.gz", + }, + assets: [], + }, + app: null, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + + const { fetchDesktopManifest } = await import("./api") + const result = await fetchDesktopManifest() + + expect(result?.renderer?.version).toBe("1.5.1") + expect(result?.app).toBeNull() + }) + + it("parses desktop policy responses", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + action: "block", + targetVersion: "1.5.1", + message: "Install the latest desktop app.", + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + publishedAt: "2026-04-11T10:00:00.000Z", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + + const { fetchDesktopPolicy } = await import("./api") + const result = await fetchDesktopPolicy() + + expect(result).toEqual({ + action: "block", + targetVersion: "1.5.1", + message: "Install the latest desktop app.", + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + publishedAt: "2026-04-11T10:00:00.000Z", + }) + }) + + it("converts desktop manifest hash values back to hex", async () => { + const { manifestHashToHex } = await import("./api") + + expect(manifestHashToHex("qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo")).toBe( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + }) +}) diff --git a/apps/desktop/layer/main/src/updater/api.ts b/apps/desktop/layer/main/src/updater/api.ts index 1906c1d98..aa70bc9fa 100644 --- a/apps/desktop/layer/main/src/updater/api.ts +++ b/apps/desktop/layer/main/src/updater/api.ts @@ -1,26 +1,239 @@ +import { env } from "@follow/shared/env.desktop" +import { createDesktopAPIHeaders } from "@follow/utils/headers" +import PKG, { runtimeVersion as configuredRuntimeVersion, version as appVersion } from "@pkg" +import { gte } from "semver" + +import { getCurrentRendererManifest } from "~/updater/hot-updater" + +import { channel } from "../env" import type { - DistributionStatusPayload, - GetLatestReleaseQuery, - LatestReleasePayload, -} from "@follow-app/client-sdk" + DesktopManifestResponse, + DesktopPolicyResponse, + DesktopRendererPayload, +} from "./types" -import { apiClient } from "~/lib/api-client" +export { manifestHashToHex } from "./types" -let cachedLatestRelease: LatestReleasePayload | null = null +export const getDesktopRuntimeVersion = () => configuredRuntimeVersion ?? appVersion -export const getUpdateInfo = async ( - query: GetLatestReleaseQuery = {}, -): Promise => { - const response = await apiClient.updates.getLatestRelease(query) - cachedLatestRelease = response.data - return cachedLatestRelease +export const getDesktopRendererVersion = () => { + const rendererManifest = getCurrentRendererManifest() + return gte(rendererManifest?.version ?? appVersion, appVersion) + ? (rendererManifest?.version ?? appVersion) + : appVersion } -export const getDistributionUpdateInfo = async (): Promise => { - const distribution = process.mas ? "mas" : process.windowsStore ? "mss" : undefined - if (!distribution) { +export const buildDesktopOtaHeaders = (includeRenderer = false): Record => { + const headers: Record = { + ...createDesktopAPIHeaders({ version: PKG.version }), + "X-App-Channel": channel, + "X-App-Runtime-Version": getDesktopRuntimeVersion(), + } + + if (includeRenderer) { + headers["X-App-Renderer-Version"] = getDesktopRendererVersion() + } + + return headers +} + +export const fetchDesktopManifest = async (): Promise => { + const response = await fetch(new URL("/manifest", env.VITE_OTA_URL), { + headers: buildDesktopOtaHeaders(true), + cache: "no-store", + }) + + if (response.status === 204) { return null } - const response = await apiClient.updates.getDistributionStatus({ distribution }) - return response.data + + if (!response.ok) { + throw new Error(`Failed to fetch desktop OTA manifest (${response.status})`) + } + + return parseDesktopManifest(await response.json()) +} + +export const fetchDesktopPolicy = async (): Promise => { + const response = await fetch(new URL("/policy", env.VITE_OTA_URL), { + headers: buildDesktopOtaHeaders(false), + cache: "no-store", + }) + + if (!response.ok) { + throw new Error(`Failed to fetch desktop OTA policy (${response.status})`) + } + + return parseDesktopPolicy(await response.json()) +} + +function parseDesktopManifest(payload: unknown): DesktopManifestResponse { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid desktop OTA manifest response") + } + + const data = payload as Record + const { renderer, app } = data + + if ( + data["product"] !== "desktop" || + typeof data["id"] !== "string" || + typeof data["createdAt"] !== "string" || + typeof data["channel"] !== "string" || + typeof data["runtimeVersion"] !== "string" + ) { + throw new Error("Invalid desktop OTA manifest response") + } + + return { + id: data["id"], + createdAt: data["createdAt"], + product: "desktop", + channel: data["channel"], + runtimeVersion: data["runtimeVersion"], + renderer: renderer ? parseRendererPayload(renderer) : null, + app: app ? parseAppPayload(app) : null, + } +} + +function parseDesktopPolicy(payload: unknown): DesktopPolicyResponse { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid desktop OTA policy response") + } + + const data = payload as Record + const { action, distribution } = data + + if (action !== "none" && action !== "prompt" && action !== "block") { + throw new Error("Invalid desktop OTA policy response") + } + + if (distribution !== "direct" && distribution !== "mas" && distribution !== "mss") { + throw new Error("Invalid desktop OTA policy response") + } + + return { + action, + targetVersion: typeof data["targetVersion"] === "string" ? data["targetVersion"] : null, + message: typeof data["message"] === "string" ? data["message"] : null, + distribution, + downloadUrl: typeof data["downloadUrl"] === "string" ? data["downloadUrl"] : null, + storeUrl: typeof data["storeUrl"] === "string" ? data["storeUrl"] : null, + publishedAt: typeof data["publishedAt"] === "string" ? data["publishedAt"] : null, + } +} + +function parseRendererPayload(payload: unknown): DesktopRendererPayload { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid desktop OTA manifest response") + } + + const data = payload as Record + const { launchAsset, assets } = data + + if ( + typeof data["releaseVersion"] !== "string" || + typeof data["version"] !== "string" || + typeof data["commit"] !== "string" || + !launchAsset || + !Array.isArray(assets) + ) { + throw new Error("Invalid desktop OTA manifest response") + } + + return { + releaseVersion: data["releaseVersion"], + version: data["version"], + commit: data["commit"], + launchAsset: parseManifestAsset(launchAsset), + assets: assets.map((asset) => parseManifestAsset(asset)), + } +} + +function parseAppPayload(payload: unknown) { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid desktop OTA manifest response") + } + + const data = payload as Record + const { manifest, files } = data + + if ( + typeof data["releaseVersion"] !== "string" || + typeof data["version"] !== "string" || + typeof data["platform"] !== "string" || + !manifest || + !Array.isArray(files) + ) { + throw new Error("Invalid desktop OTA manifest response") + } + + if ( + typeof (manifest as Record)["name"] !== "string" || + typeof (manifest as Record)["downloadUrl"] !== "string" + ) { + throw new TypeError("Invalid desktop OTA manifest response") + } + + return { + releaseVersion: data["releaseVersion"], + version: data["version"], + platform: data["platform"], + releaseDate: typeof data["releaseDate"] === "string" ? data["releaseDate"] : null, + manifest: { + name: (manifest as Record)["name"] as string, + downloadUrl: (manifest as Record)["downloadUrl"] as string, + ...(typeof (manifest as Record)["path"] === "string" + ? { path: (manifest as Record)["path"] as string } + : {}), + }, + files: files.map((file) => parseAppFile(file)), + } +} + +function parseManifestAsset(payload: unknown) { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid desktop OTA manifest response") + } + + const data = payload as Record + if ( + typeof data["key"] !== "string" || + typeof data["hash"] !== "string" || + typeof data["contentType"] !== "string" || + typeof data["url"] !== "string" + ) { + throw new TypeError("Invalid desktop OTA manifest response") + } + + return { + key: data["key"], + hash: data["hash"], + contentType: data["contentType"], + url: data["url"], + ...(typeof data["fileExtension"] === "string" ? { fileExtension: data["fileExtension"] } : {}), + } +} + +function parseAppFile(payload: unknown) { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid desktop OTA manifest response") + } + + const data = payload as Record + if ( + typeof data["filename"] !== "string" || + typeof data["sha512"] !== "string" || + typeof data["size"] !== "number" || + typeof data["downloadUrl"] !== "string" + ) { + throw new TypeError("Invalid desktop OTA manifest response") + } + + return { + filename: data["filename"], + sha512: data["sha512"], + size: data["size"], + downloadUrl: data["downloadUrl"], + } } diff --git a/apps/desktop/layer/main/src/updater/follow-update-provider.ts b/apps/desktop/layer/main/src/updater/follow-update-provider.ts index 3d669f281..05893329d 100644 --- a/apps/desktop/layer/main/src/updater/follow-update-provider.ts +++ b/apps/desktop/layer/main/src/updater/follow-update-provider.ts @@ -1,11 +1,5 @@ import { URL } from "node:url" -import type { - AppUpdate, - LatestReleasePayload, - PlatformUpdate, - PlatformUpdateFile, -} from "@follow-app/client-sdk" import type { UpdateFileInfo, UpdateInfo } from "builder-util-runtime" import { newError } from "builder-util-runtime" import type { AppUpdater } from "electron-updater" @@ -14,15 +8,15 @@ import { Provider } from "electron-updater/out/providers/Provider" import type { ResolvedUpdateFileInfo } from "electron-updater/out/types" import { logger } from "../logger" -import { getUpdateInfo } from "./api" +import { fetchDesktopManifest } from "./api" +import type { DesktopAppPayload } from "./types" interface FollowProviderOptions { provider: "custom" } type FollowProviderContext = { - payload: LatestReleasePayload - platform: PlatformUpdate + app: DesktopAppPayload } export class FollowUpdateProvider extends Provider { @@ -61,12 +55,12 @@ export class FollowUpdateProvider extends Provider { } private buildUpdateInfo(context: FollowProviderContext): UpdateInfo { - const { payload, platform } = context - const files = this.mapFiles(platform.files) + const { app } = context + const files = this.mapFiles(app.files) if (files.length === 0) { throw newError( - `No downloadable files found for platform ${platform.platform}`, + `No downloadable files found for platform ${app.platform}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", ) } @@ -74,7 +68,7 @@ export class FollowUpdateProvider extends Provider { const primaryFile = files[0] if (!primaryFile) { throw newError( - `Platform ${platform.platform} provides no downloadable file`, + `Platform ${app.platform} provides no downloadable file`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", ) } @@ -88,20 +82,18 @@ export class FollowUpdateProvider extends Provider { } } - const { release } = payload - return { - version: platform.version, + version: app.version, files, path: primaryPath, sha512: primaryFile.sha512, - releaseName: release.name, - releaseNotes: release.body, - releaseDate: platform.releaseDate || release.publishedAt || new Date().toISOString(), + releaseName: `Folo ${app.version}`, + releaseNotes: null, + releaseDate: app.releaseDate || new Date().toISOString(), } } - private mapFiles(files: PlatformUpdate["files"]): UpdateFileInfo[] { + private mapFiles(files: DesktopAppPayload["files"]): UpdateFileInfo[] { if (!files) return [] return files @@ -109,7 +101,7 @@ export class FollowUpdateProvider extends Provider { .filter((file): file is UpdateFileInfo => file !== null) } - private mapFile(file: PlatformUpdateFile): UpdateFileInfo | null { + private mapFile(file: DesktopAppPayload["files"][number]): UpdateFileInfo | null { if (!file.downloadUrl || !file.sha512) { logger.warn("Skip platform file without downloadUrl or sha512", file) return null @@ -146,58 +138,16 @@ export class FollowUpdateProvider extends Provider { } private async fetchContext(): Promise { - const payload = await getUpdateInfo({}) - const { decision } = payload + const manifest = await fetchDesktopManifest() + const app = manifest?.app - if (!decision || decision.type !== "app" || !decision.app) { + if (!app) { throw newError( "No app update metadata available from provider", "ERR_UPDATER_NO_PUBLISHED_VERSIONS", ) } - const platform = this.pickPlatform(decision.app) - if (!platform) { - throw newError( - `No matching platform update for ${process.platform}/${process.arch}`, - "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", - ) - } - - return { payload, platform } - } - - private pickPlatform(appDecision: AppUpdate): PlatformUpdate | null { - const platforms = appDecision.platforms ?? [] - const selected = appDecision.selectedPlatform - if (selected) { - return selected - } - - const candidates = this.resolvePlatformCandidates() - const matched = platforms.find((platform) => - candidates.includes(platform.platform.toLowerCase()), - ) - - return matched ?? platforms[0] ?? null - } - - private resolvePlatformCandidates(): string[] { - const base = new Set() - base.add(process.platform) - base.add(`${process.platform}-${process.arch}`) - base.add(process.arch) - - if (process.platform === "darwin") { - base.add("mac") - base.add("macos") - } - - if (process.platform === "win32") { - base.add("windows") - base.add("win") - } - - return Array.from(base).map((value) => value.toLowerCase()) + return { app } } } diff --git a/apps/desktop/layer/main/src/updater/hot-updater.ts b/apps/desktop/layer/main/src/updater/hot-updater.ts index 3630febae..d92e6de49 100644 --- a/apps/desktop/layer/main/src/updater/hot-updater.ts +++ b/apps/desktop/layer/main/src/updater/hot-updater.ts @@ -1,10 +1,10 @@ import { existsSync, readFileSync } from "node:fs" -import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises" import os from "node:os" +import { URL } from "node:url" import { callWindowExpose } from "@follow/shared/bridge" -import type { LatestReleasePayload, RendererUpdate } from "@follow-app/client-sdk" -import { mainHash, version as appVersion } from "@pkg" +import { runtimeVersion as configuredRuntimeVersion, version as appVersion } from "@pkg" import log from "electron-log" import { dump, load } from "js-yaml" import path from "pathe" @@ -15,11 +15,17 @@ import { downloadFileWithProgress } from "~/lib/download" import { WindowManager } from "~/manager/window" import { appUpdaterConfig } from "./configs" +import type { DesktopManifestResponse, DesktopRendererPayload } from "./types" +import { manifestHashToHex } from "./types" -declare const GIT_COMMIT_HASH: string | undefined - -export type RendererManifest = RendererUpdate & { +export type RendererManifest = { + runtimeVersion: string + version: string + releaseVersion: string + commit: string downloadUrl: string + hash: string + filename: string downloadedAt?: string } @@ -41,19 +47,17 @@ class RendererHotUpdater { private readonly tempDir = path.resolve(os.tmpdir(), "follow-render-update") private readonly manifestPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml") - extractManifest(payload: LatestReleasePayload | null): RendererManifest | null { + extractManifest(payload: DesktopManifestResponse | null): RendererManifest | null { if (!payload) return null - const { decision } = payload - if (!decision || decision.type !== "renderer") { - return null - } - - return this.toManifest(decision.renderer) + return this.toManifest(payload.renderer, payload.runtimeVersion) } - extractManifestFromRendererUpdate(renderer: RendererUpdate | null): RendererManifest | null { - return this.toManifest(renderer) + extractManifestFromRendererUpdate( + renderer: DesktopRendererPayload | null, + runtimeVersion: string, + ): RendererManifest | null { + return this.toManifest(renderer, runtimeVersion) } evaluateManifest(manifest: RendererManifest | null): RendererEligibilityResult { @@ -61,11 +65,12 @@ class RendererHotUpdater { return { status: RendererEligibilityStatus.NoManifest } } - if (manifest.mainHash && manifest.mainHash !== mainHash) { + const appRuntimeVersion = configuredRuntimeVersion ?? appVersion + if (manifest.runtimeVersion !== appRuntimeVersion) { return { status: RendererEligibilityStatus.RequiresFullAppUpdate, manifest, - reason: `Renderer payload requires main hash ${manifest.mainHash}, current main hash is ${mainHash}`, + reason: `Renderer payload requires runtimeVersion ${manifest.runtimeVersion}, current runtimeVersion is ${appRuntimeVersion}`, } } @@ -76,31 +81,11 @@ class RendererHotUpdater { } } - if (manifest.commit && GIT_COMMIT_HASH && manifest.commit === GIT_COMMIT_HASH) { + const installedManifest = this.getCurrentManifest() + if (installedManifest && installedManifest.version === manifest.version) { return { status: RendererEligibilityStatus.AlreadyCurrent, - reason: "Renderer commit matches current main commit", - } - } - - const installedManifest = this.getCurrentManifest() - if (installedManifest) { - if (installedManifest.version === manifest.version) { - return { - status: RendererEligibilityStatus.AlreadyCurrent, - reason: "Installed renderer manifest already at target version", - } - } - - if ( - installedManifest.commit && - manifest.commit && - installedManifest.commit === manifest.commit - ) { - return { - status: RendererEligibilityStatus.AlreadyCurrent, - reason: "Installed renderer manifest commit matches target commit", - } + reason: "Installed renderer manifest already at target version", } } @@ -110,30 +95,39 @@ class RendererHotUpdater { } } - private toManifest(renderer: RendererUpdate | null): RendererManifest | null { + private toManifest( + renderer: DesktopRendererPayload | null, + runtimeVersion: string, + ): RendererManifest | null { if (!renderer) { this.logger.debug("Renderer decision payload missing renderer field") return null } - if (!renderer.downloadUrl) { + if (!renderer.launchAsset?.url) { this.logger.warn("Renderer decision missing downloadUrl, skip renderer hot update") return null } - if (!renderer.filename) { + const filename = this.resolveFilename(renderer.launchAsset.url) + if (!filename) { this.logger.warn("Renderer decision missing filename, skip renderer hot update") return null } - if (!renderer.hash) { + if (!renderer.launchAsset.hash) { this.logger.warn("Renderer decision missing hash, skip renderer hot update") return null } return { - ...renderer, - downloadUrl: renderer.downloadUrl, + runtimeVersion, + version: renderer.version, + releaseVersion: renderer.releaseVersion, + commit: renderer.commit, + downloadUrl: renderer.launchAsset.url, + filename, + hash: manifestHashToHex(renderer.launchAsset.hash), } } @@ -144,16 +138,18 @@ class RendererHotUpdater { } const archivePath = await this.downloadArchive(manifest) + const stagingRoot = await mkdtemp(path.resolve(this.tempDir, "extract-")) + const stagingDir = path.resolve(stagingRoot, "content") - await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true }) - this.logger.info(`Extracting renderer bundle to ${HOTUPDATE_RENDER_ENTRY_DIR}`) + await mkdir(stagingDir, { recursive: true }) + this.logger.info(`Extracting renderer bundle to ${stagingDir}`) await x({ f: archivePath, - cwd: HOTUPDATE_RENDER_ENTRY_DIR, + cwd: stagingDir, }) - const extractedDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer") + const extractedDir = path.resolve(stagingDir, "renderer") const targetDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version) const extractedStats = await stat(extractedDir).catch(() => null) @@ -162,6 +158,7 @@ class RendererHotUpdater { } await rm(targetDir, { recursive: true, force: true }) + await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true }) await rename(extractedDir, targetDir) await this.writeManifest({ ...manifest, downloadedAt: new Date().toISOString() }) @@ -172,6 +169,12 @@ class RendererHotUpdater { this.logger.warn("Failed to clean renderer archive", error) } + try { + await rm(stagingRoot, { recursive: true, force: true }) + } catch (error) { + this.logger.warn("Failed to clean renderer staging directory", error) + } + this.logger.info(`Renderer hot update applied successfully: ${manifest.version}`) const mainWindow = WindowManager.getMainWindow() @@ -231,7 +234,6 @@ class RendererHotUpdater { const manifest = this.getCurrentManifest() if (!manifest) return - if (manifest.mainHash && manifest.mainHash !== mainHash) return const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version) const entryFile = path.resolve(dir, "index.html") @@ -264,6 +266,14 @@ class RendererHotUpdater { private async writeManifest(manifest: RendererManifest) { await writeFile(this.manifestPath, dump(manifest), "utf-8") } + + private resolveFilename(url: string) { + try { + return new URL(url).pathname.split("/").pop() ?? null + } catch { + return null + } + } } export const rendererUpdater = new RendererHotUpdater() diff --git a/apps/desktop/layer/main/src/updater/index.test.ts b/apps/desktop/layer/main/src/updater/index.test.ts new file mode 100644 index 000000000..098f7438d --- /dev/null +++ b/apps/desktop/layer/main/src/updater/index.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest" + +vi.mock("@follow/shared/bridge", () => ({ + callWindowExpose: () => ({ + distributionUpdateAvailable: vi.fn(), + updateDownloaded: vi.fn(), + }), +})) + +vi.mock("@follow/shared/constants", () => ({ + DEV: false, +})) + +vi.mock("@pkg", () => ({ + version: "1.5.0", +})) + +vi.mock("electron-log", () => ({ + default: { + scope: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})) + +vi.mock("electron-updater", () => ({ + autoUpdater: { + setFeedURL: vi.fn(), + on: vi.fn(), + }, +})) + +vi.mock("../env", () => ({ + channel: "stable", + isWindows: false, +})) + +vi.mock("./api", () => ({ + fetchDesktopManifest: vi.fn(), + fetchDesktopPolicy: vi.fn(), + getDesktopRuntimeVersion: () => "1.5.0", +})) + +vi.mock("./configs", () => ({ + appUpdaterConfig: { + enableRenderHotUpdate: true, + enableCoreUpdate: true, + enableAppUpdate: true, + enableDistributionStoreUpdate: false, + app: { + autoCheckUpdate: false, + autoDownloadUpdate: false, + checkUpdateInterval: 900000, + }, + }, +})) + +vi.mock("./follow-update-provider", () => ({ + FollowUpdateProvider: class {}, +})) + +vi.mock("./windows-updater", () => ({ + WindowsUpdater: class {}, +})) + +vi.mock("~/manager/window", () => ({ + WindowManager: { + getMainWindow: () => null, + destroyMainWindow: vi.fn(), + }, +})) + +vi.mock("~/updater/hot-updater", () => ({ + rendererUpdater: {}, + RendererEligibilityStatus: { + NoManifest: 0, + RequiresFullAppUpdate: 1, + AlreadyCurrent: 2, + Eligible: 3, + }, +})) + +describe("finalizeDirectUpdateResult", () => { + it("prefers a successful renderer update result", async () => { + const { finalizeDirectUpdateResult } = await import("./index") + + expect( + finalizeDirectUpdateResult({ + rendererResult: { hasUpdate: true }, + appResult: { hasUpdate: false }, + policy: { + action: "none", + targetVersion: null, + message: null, + distribution: "direct", + downloadUrl: null, + storeUrl: null, + publishedAt: null, + }, + }), + ).toEqual({ hasUpdate: true }) + }) + + it("falls back to a successful app update result", async () => { + const { finalizeDirectUpdateResult } = await import("./index") + + expect( + finalizeDirectUpdateResult({ + rendererResult: { hasUpdate: false, error: "renderer failed" }, + appResult: { hasUpdate: true }, + policy: { + action: "none", + targetVersion: null, + message: null, + distribution: "direct", + downloadUrl: null, + storeUrl: null, + publishedAt: null, + }, + }), + ).toEqual({ hasUpdate: true }) + }) + + it("preserves errors when both renderer and app attempts fail", async () => { + const { finalizeDirectUpdateResult } = await import("./index") + + expect( + finalizeDirectUpdateResult({ + rendererResult: { hasUpdate: false, error: "renderer failed" }, + appResult: { hasUpdate: false, error: "app failed" }, + policy: { + action: "none", + targetVersion: null, + message: null, + distribution: "direct", + downloadUrl: null, + storeUrl: null, + publishedAt: null, + }, + }), + ).toEqual({ + hasUpdate: false, + error: "app failed", + }) + }) + + it("surfaces direct policy availability when no payload can be applied", async () => { + const { finalizeDirectUpdateResult } = await import("./index") + + expect( + finalizeDirectUpdateResult({ + rendererResult: { hasUpdate: false }, + appResult: { hasUpdate: false }, + policy: { + action: "block", + targetVersion: "1.5.1", + message: "Install the latest desktop app.", + distribution: "direct", + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + storeUrl: null, + publishedAt: "2026-04-11T10:00:00.000Z", + }, + }), + ).toEqual({ hasUpdate: true }) + }) + + it("drops stale renderer and app errors once a direct policy provides an update path", async () => { + const { finalizeDirectUpdateResult } = await import("./index") + + expect( + finalizeDirectUpdateResult({ + rendererResult: { hasUpdate: false, error: "renderer failed" }, + appResult: { hasUpdate: false, error: "app failed" }, + policy: { + action: "prompt", + targetVersion: "1.5.1", + message: "Install the latest desktop app.", + distribution: "direct", + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + storeUrl: null, + publishedAt: "2026-04-11T10:00:00.000Z", + }, + }), + ).toEqual({ hasUpdate: true }) + }) +}) diff --git a/apps/desktop/layer/main/src/updater/index.ts b/apps/desktop/layer/main/src/updater/index.ts index 37a771b3c..bc7c6b944 100644 --- a/apps/desktop/layer/main/src/updater/index.ts +++ b/apps/desktop/layer/main/src/updater/index.ts @@ -2,27 +2,26 @@ import { fileURLToPath } from "node:url" import { callWindowExpose } from "@follow/shared/bridge" import { DEV } from "@follow/shared/constants" -import type { - DistributionStatusPayload, - LatestReleasePayload, - PlatformUpdate, - RendererUpdate, -} from "@follow-app/client-sdk" -import { mainHash, version as appVersion } from "@pkg" +import { version as appVersion } from "@pkg" import log from "electron-log" import type { AppUpdater } from "electron-updater" import { autoUpdater as defaultAutoUpdater } from "electron-updater" import { join } from "pathe" -import { gt, valid as isValidSemver } from "semver" import { WindowManager } from "~/manager/window" import type { RendererManifest } from "~/updater/hot-updater" import { RendererEligibilityStatus, rendererUpdater } from "~/updater/hot-updater" import { channel, isWindows } from "../env" -import { getDistributionUpdateInfo, getUpdateInfo } from "./api" +import { fetchDesktopManifest, fetchDesktopPolicy, getDesktopRuntimeVersion } from "./api" import { appUpdaterConfig } from "./configs" import { FollowUpdateProvider } from "./follow-update-provider" +import type { + DesktopAppPayload, + DesktopDistribution, + DesktopManifestResponse, + DesktopPolicyResponse, +} from "./types" import { WindowsUpdater } from "./windows-updater" const logger = log.scope("app-updater") @@ -74,7 +73,7 @@ class FollowUpdater { this.registerAutoUpdaterEvents() if (appUpdaterConfig.app.autoCheckUpdate) { - logger.info("Initial update check, mainHash:", mainHash) + logger.info("Initial update check, runtimeVersion:", getDesktopRuntimeVersion()) void this.checkForUpdates().catch((error) => logger.error("Initial update check failed", error), ) @@ -97,7 +96,7 @@ class FollowUpdater { this.pollingTimer = setInterval(updatePollingHandler, appUpdaterConfig.app.checkUpdateInterval) } - async checkForUpdates(options: UpdateCheckOptions = {}): Promise { + async checkForUpdates(_options: UpdateCheckOptions = {}): Promise { if (this.disabled) { return { hasUpdate: false } } @@ -110,14 +109,14 @@ class FollowUpdater { this.checkingUpdate = true try { + const [manifest, policy] = await Promise.all([fetchDesktopManifest(), fetchDesktopPolicy()]) + if (appUpdaterConfig.enableDistributionStoreUpdate) { - logger.info("Distribution store update enabled, checking for distribution update") - return this.handleDistributionAppDecision() + logger.info("Distribution store update enabled, checking ota manifest and store policy") + return this.handleDistributionAppDecision(manifest, policy) } - const payload = await getUpdateInfo(options.refresh ? { refresh: true } : {}) - - return this.handleDirectAppDecision(payload) + return this.handleDirectAppDecision(manifest, policy) } catch (error) { logger.error("Failed to check for updates", error) return { hasUpdate: false, error: error instanceof Error ? error.message : "Unknown error" } @@ -126,26 +125,46 @@ class FollowUpdater { } } - async handleDirectAppDecision(payload: LatestReleasePayload): Promise { - const { decision } = payload + async handleDirectAppDecision( + manifest: DesktopManifestResponse | null, + policy: DesktopPolicyResponse, + ): Promise { + let rendererResult: UpdateCheckResult | null = null + let appResult: UpdateCheckResult | null = null - if (!decision || decision.type === "none") { - logger.info("Update decision: none") - return { hasUpdate: false } - } - - if (decision.type === "renderer") { + if (manifest?.renderer) { logger.info("Update decision: renderer") - return await this.handleRendererDecision(payload) + rendererResult = await this.handleRendererDecision(manifest, manifest.app) + if (rendererResult.hasUpdate) { + return rendererResult + } } - if (decision.type === "app") { + if (manifest?.app) { logger.info("Update decision: app") - return await this.handleAppDecision(payload) + appResult = await this.handleAppDecision(manifest.app) + if (appResult.hasUpdate) { + return appResult + } } - logger.warn("Unknown update decision type", { type: decision.type }) - return { hasUpdate: false } + const result = finalizeDirectUpdateResult({ + rendererResult, + appResult, + policy, + }) + + if (result.hasUpdate && policy.distribution === "direct" && policy.downloadUrl) { + logger.warn("Direct binary policy requires a separate installer flow") + logger.info("Direct binary policy available", { + action: policy.action, + downloadUrl: policy.downloadUrl, + }) + } else if (!result.hasUpdate) { + logger.info("Update decision: none") + } + + return result } async downloadAppUpdate(): Promise { @@ -176,49 +195,9 @@ class FollowUpdater { }, 1000) } - private resolvePlatformCandidates() { - const base = new Set() - base.add(process.platform) - base.add(`${process.platform}-${process.arch}`) - base.add(process.arch) - - if (process.platform === "darwin") { - base.add("mac") - base.add("macos") - } - - if (process.platform === "win32") { - base.add("windows") - base.add("win") - } - - return Array.from(base).map((value) => value.toLowerCase()) - } - - private pickPlatformUpdate( - platforms: PlatformUpdate[] | null | undefined, - selected?: PlatformUpdate | null, - ): PlatformUpdate | null { - if (!platforms || platforms.length === 0) { - return null - } - - if (selected) { - return selected - } - - const candidates = this.resolvePlatformCandidates() - - const matched = platforms.find((platform) => - candidates.includes(platform.platform.toLowerCase()), - ) - - return matched ?? platforms[0] ?? null - } - - private async handleAppDecision(payload: LatestReleasePayload): Promise { - const appDecision = payload.decision.app - + private async handleAppDecision( + appDecision: DesktopAppPayload | null, + ): Promise { if (!appUpdaterConfig.enableCoreUpdate) { logger.info("Core app update disabled by configuration") return { hasUpdate: false } @@ -229,23 +208,12 @@ class FollowUpdater { return { hasUpdate: false, error: "App update metadata unavailable" } } - const platformUpdate = this.pickPlatformUpdate( - appDecision.platforms, - appDecision.selectedPlatform, - ) - if (!platformUpdate) { - logger.warn("No matching platform update found", { - platform: process.platform, - arch: process.arch, - }) - return { hasUpdate: false, error: "No installer available for this platform" } - } - - FollowUpdateProvider.setContext({ payload, platform: platformUpdate }) - logger.info("FollowUpdateProvider context set", { platform: platformUpdate.platform }) + FollowUpdateProvider.setContext({ app: appDecision }) + logger.info("FollowUpdateProvider context set", { platform: appDecision.platform }) try { - await this.autoUpdater.checkForUpdates() + const result = await this.autoUpdater.checkForUpdates() + return { hasUpdate: result?.isUpdateAvailable ?? false } } catch (error) { logger.warn( "autoUpdater.checkForUpdates failed after preparing FollowUpdateProvider context", @@ -258,38 +226,29 @@ class FollowUpdater { } finally { FollowUpdateProvider.clearContext() } - - return { hasUpdate: true } } - private async handleDistributionAppDecision(): Promise { + private async handleDistributionAppDecision( + manifest: DesktopManifestResponse | null, + policy: DesktopPolicyResponse, + ): Promise { try { if (!appUpdaterConfig.enableDistributionStoreUpdate) { return { hasUpdate: false } } - const info = await getDistributionUpdateInfo() - - if (!info) { - logger.info( - "Distribution update info unavailable for current build, falling back to direct app decision", - ) - const payload = await getUpdateInfo() - return this.handleDirectAppDecision(payload) - } - - const rendererResult = await this.tryDistributionRendererUpdate(info.rendererUpdate) + const rendererResult = await this.tryDistributionRendererUpdate(manifest) if (rendererResult) { return rendererResult } - if (!this.shouldPromptDistributionStoreUpdate(info)) { + if (!this.shouldPromptDistributionStoreUpdate(policy)) { logger.info("Distribution update does not require store action") return { hasUpdate: false } } logger.info("Distribution store update required") - return await this.notifyDistributionUpdate(info) + return await this.notifyDistributionUpdate(policy) } catch (error) { logger.error("Failed to handle distribution app update", error) return { @@ -300,8 +259,9 @@ class FollowUpdater { } private async tryDistributionRendererUpdate( - renderer: RendererUpdate | null, + manifestResponse: DesktopManifestResponse | null, ): Promise { + const renderer = manifestResponse?.renderer ?? null if (!renderer) { return null } @@ -311,7 +271,10 @@ class FollowUpdater { return null } - const manifest = this.renderer.extractManifestFromRendererUpdate(renderer) + const manifest = this.renderer.extractManifestFromRendererUpdate( + renderer, + manifestResponse?.runtimeVersion ?? getDesktopRuntimeVersion(), + ) if (!manifest) { logger.warn("Distribution renderer update missing manifest") return null @@ -369,7 +332,11 @@ class FollowUpdater { } } - private shouldPromptDistributionStoreUpdate(info: DistributionStatusPayload): boolean { + private shouldPromptDistributionStoreUpdate(info: DesktopPolicyResponse): boolean { + if (info.distribution === "direct") { + return false + } + if (!info.storeUrl) { logger.info("Distribution store update skipped: missing store URL", { distribution: info.distribution, @@ -377,47 +344,10 @@ class FollowUpdater { return false } - const { storeVersion } = info - - const currentVersion = appVersion - - if (!storeVersion) { - logger.info("Distribution store update skipped: missing store version") - return false - } - - if (!currentVersion) { - return true - } - - const storeValid = isValidSemver(storeVersion) - const currentValid = isValidSemver(currentVersion) - - if (storeValid && currentValid) { - const needsUpdate = gt(storeVersion, currentVersion) - if (!needsUpdate) { - logger.info("Distribution store version matches current version", { - storeVersion, - currentVersion, - }) - } - return needsUpdate - } - - if (storeVersion === currentVersion) { - logger.info("Distribution store version identical to current version", { - storeVersion, - currentVersion, - }) - return false - } - - return true + return info.action !== "none" } - private async notifyDistributionUpdate( - info: DistributionStatusPayload, - ): Promise { + private async notifyDistributionUpdate(info: DesktopPolicyResponse): Promise { const mainWindow = WindowManager.getMainWindow() if (!mainWindow) { logger.warn("Main window unavailable when notifying distribution update") @@ -432,25 +362,28 @@ class FollowUpdater { } await callWindowExpose(mainWindow).distributionUpdateAvailable({ - distribution: info.distribution, + distribution: info.distribution as Exclude, storeUrl: info.storeUrl, - storeVersion: info.storeVersion ?? null, + storeVersion: info.targetVersion ?? null, currentVersion: appVersion, }) return { hasUpdate: true } } - private async handleRendererDecision(payload: LatestReleasePayload): Promise { + private async handleRendererDecision( + manifestResponse: DesktopManifestResponse | null, + fallbackApp: DesktopAppPayload | null, + ): Promise { if (!appUpdaterConfig.enableRenderHotUpdate) { logger.info("Renderer hot update disabled; falling back to app decision if present") - if (payload.decision.app) { - return this.handleAppDecision(payload) + if (fallbackApp) { + return this.handleAppDecision(fallbackApp) } return { hasUpdate: false } } - const manifest = this.renderer.extractManifest(payload) + const manifest = this.renderer.extractManifest(manifestResponse) const eligibility = this.renderer.evaluateManifest(manifest) switch (eligibility.status) { @@ -471,8 +404,8 @@ class FollowUpdater { "Renderer payload requires main process update, delegating to app updater", ) - if (payload.decision.app) { - return this.handleAppDecision(payload) + if (fallbackApp) { + return this.handleAppDecision(fallbackApp) } logger.warn("Renderer update requested full app upgrade but no app payload provided") return { hasUpdate: false, error: "Renderer update requires full app upgrade" } @@ -544,6 +477,32 @@ class FollowUpdater { const autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater const followUpdater = new FollowUpdater(autoUpdater) +export function finalizeDirectUpdateResult(input: { + rendererResult?: UpdateCheckResult | null + appResult?: UpdateCheckResult | null + policy: DesktopPolicyResponse +}) { + if (input.rendererResult?.hasUpdate) { + return input.rendererResult + } + + if (input.appResult?.hasUpdate) { + return input.appResult + } + + const lastError = input.appResult?.error ?? input.rendererResult?.error + + if ( + input.policy.action !== "none" && + input.policy.distribution === "direct" && + input.policy.downloadUrl + ) { + return { hasUpdate: true } + } + + return { hasUpdate: false, ...(lastError ? { error: lastError } : {}) } +} + export const registerUpdater = () => { followUpdater.register() } diff --git a/apps/desktop/layer/main/src/updater/types.ts b/apps/desktop/layer/main/src/updater/types.ts new file mode 100644 index 000000000..9120180f0 --- /dev/null +++ b/apps/desktop/layer/main/src/updater/types.ts @@ -0,0 +1,63 @@ +export type DesktopDistribution = "direct" | "mas" | "mss" + +export interface DesktopManifestAsset { + key: string + hash: string + fileExtension?: string + contentType: string + url: string +} + +export interface DesktopRendererPayload { + releaseVersion: string + version: string + commit: string + launchAsset: DesktopManifestAsset + assets: DesktopManifestAsset[] +} + +export interface DesktopAppFile { + filename: string + sha512: string + size: number + downloadUrl: string +} + +export interface DesktopAppPayload { + releaseVersion: string + version: string + platform: string + releaseDate: string | null + manifest: { + name: string + path?: string + downloadUrl: string + } + files: DesktopAppFile[] +} + +export interface DesktopManifestResponse { + id: string + createdAt: string + product: "desktop" + channel: string + runtimeVersion: string + renderer: DesktopRendererPayload | null + app: DesktopAppPayload | null +} + +export interface DesktopPolicyResponse { + action: "none" | "prompt" | "block" + targetVersion: string | null + message: string | null + distribution: DesktopDistribution + downloadUrl: string | null + storeUrl: string | null + publishedAt: string | null +} + +export function manifestHashToHex(hash: string) { + const normalized = hash.replaceAll("-", "+").replaceAll("_", "/") + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=") + return Buffer.from(padded, "base64").toString("hex") +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 628232146..5b148b548 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,7 @@ "main": "./dist/main/index.js", "scripts": { "analyze:web": "cross-env analyzer=1 WEB_BUILD=1 vite build", + "apply:release-config": "tsx scripts/apply-release-config.ts", "build:electron": "pnpm run build:electron-vite && electron-forge make", "build:electron-forge": "electron-forge make", "build:electron-forge:macos": "electron-forge make --arch=x64 --platform=darwin && electron-forge make --arch=arm64 --platform=darwin && tsx scripts/merge-yml.ts", @@ -95,6 +96,7 @@ "vite-plugin-route-builder": "0.4.1", "vite-tsconfig-paths": "6.1.1" }, + "runtimeVersion": "1.5.0", "productName": "Folo", "mainHash": "4778cc1cb43d08ed2a5edf391f0d22694641dc15eb27eaefb45bfa09ff176091" } diff --git a/apps/desktop/release-plan.json b/apps/desktop/release-plan.json new file mode 100644 index 000000000..af6d19bd7 --- /dev/null +++ b/apps/desktop/release-plan.json @@ -0,0 +1,8 @@ +{ + "mode": "build", + "runtimeVersion": null, + "channel": null, + "distributions": [], + "required": false, + "message": null +} diff --git a/apps/desktop/release.json b/apps/desktop/release.json new file mode 100644 index 000000000..086e8d0a6 --- /dev/null +++ b/apps/desktop/release.json @@ -0,0 +1,9 @@ +{ + "version": "1.5.0", + "mode": "build", + "runtimeVersion": null, + "channel": null, + "distributions": [], + "required": false, + "message": null +} diff --git a/apps/desktop/scripts/apply-release-config.impl.ts b/apps/desktop/scripts/apply-release-config.impl.ts new file mode 100644 index 000000000..3001ee290 --- /dev/null +++ b/apps/desktop/scripts/apply-release-config.impl.ts @@ -0,0 +1,110 @@ +import { readFile, writeFile } from "node:fs/promises" + +import { join } from "pathe" + +const semverPattern = /^\d+\.\d+\.\d+$/ +const validModes = new Set(["build", "ota", "binary-policy"]) +const validChannels = new Set(["stable", "beta", "alpha"]) +const validDistributions = new Set(["direct", "mas", "mss"]) + +export interface DesktopReleasePlan { + mode: "build" | "ota" | "binary-policy" + runtimeVersion: string | null + channel: "stable" | "beta" | "alpha" | null + distributions: Array<"direct" | "mas" | "mss"> + required: boolean + message: string | null +} + +interface DesktopPackageJson { + runtimeVersion?: string + version: string + [key: string]: unknown +} + +export async function applyReleaseConfig(input: { projectDir: string; version: string }) { + if (!input.version || !semverPattern.test(input.version)) { + throw new Error("Expected a plain x.y.z version argument") + } + + const releasePlanPath = join(input.projectDir, "release-plan.json") + const releaseConfigPath = join(input.projectDir, "release.json") + const packageJsonPath = join(input.projectDir, "package.json") + const plan = await readReleasePlan(releasePlanPath) + validateReleasePlan(plan) + + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as DesktopPackageJson + packageJson.runtimeVersion = plan.mode === "ota" ? plan.runtimeVersion! : input.version + + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8") + await writeFile( + releaseConfigPath, + `${JSON.stringify( + { + version: input.version, + ...plan, + }, + null, + 2, + )}\n`, + "utf8", + ) + await writeFile( + releasePlanPath, + `${JSON.stringify(createDefaultReleasePlan(), null, 2)}\n`, + "utf8", + ) +} + +async function readReleasePlan(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as DesktopReleasePlan +} + +function validateReleasePlan(plan: DesktopReleasePlan) { + if (!validModes.has(plan.mode)) { + throw new Error("release-plan.json mode must be build, ota, or binary-policy") + } + + if (plan.channel !== null && !validChannels.has(plan.channel)) { + throw new Error("release-plan.json channel must be stable, beta, alpha, or null") + } + + if ( + !Array.isArray(plan.distributions) || + plan.distributions.some((item) => !validDistributions.has(item)) + ) { + throw new Error("release-plan.json distributions must only contain direct, mas, or mss") + } + + if (plan.mode === "build") { + if (plan.runtimeVersion !== null || plan.channel !== null) { + throw new Error("desktop build mode must not set runtimeVersion or channel") + } + return + } + + if (plan.mode === "ota") { + if (!plan.runtimeVersion || !semverPattern.test(plan.runtimeVersion)) { + throw new Error("desktop ota mode requires a plain x.y.z runtimeVersion") + } + if (!plan.channel) { + throw new Error("desktop ota mode requires a channel") + } + return + } + + if (!plan.channel || plan.distributions.length === 0) { + throw new Error("desktop binary-policy mode requires channel and distributions") + } +} + +export function createDefaultReleasePlan(): DesktopReleasePlan { + return { + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + } +} diff --git a/apps/desktop/scripts/apply-release-config.test.ts b/apps/desktop/scripts/apply-release-config.test.ts new file mode 100644 index 000000000..5746d6bc6 --- /dev/null +++ b/apps/desktop/scripts/apply-release-config.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" + +import { join } from "pathe" +import { afterEach, describe, expect, it } from "vitest" + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe("applyDesktopReleaseConfig", () => { + it("writes release.json for ota mode, resets release-plan.json, and persists the plan runtimeVersion", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "desktop-release-config-")) + tempDirs.push(projectDir) + + await writeFile( + join(projectDir, "package.json"), + `${JSON.stringify({ name: "Folo", version: "1.5.0" }, null, 2)}\n`, + "utf8", + ) + await writeFile( + join(projectDir, "release-plan.json"), + `${JSON.stringify( + { + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + null, + 2, + )}\n`, + "utf8", + ) + + const { applyReleaseConfig } = await import("./apply-release-config.impl.ts") + + await applyReleaseConfig({ projectDir, version: "1.5.1" }) + + await expect(readFile(join(projectDir, "release.json"), "utf8")).resolves.toContain( + '"mode": "ota"', + ) + await expect(readFile(join(projectDir, "release.json"), "utf8")).resolves.toContain( + '"runtimeVersion": "1.5.0"', + ) + await expect(readFile(join(projectDir, "release-plan.json"), "utf8")).resolves.toContain( + '"mode": "build"', + ) + await expect(readFile(join(projectDir, "package.json"), "utf8")).resolves.toContain( + '"runtimeVersion": "1.5.0"', + ) + }) + + it("persists the new app version as runtimeVersion for build mode", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "desktop-release-config-")) + tempDirs.push(projectDir) + + await writeFile( + join(projectDir, "package.json"), + `${JSON.stringify({ name: "Folo", version: "1.5.0", runtimeVersion: "1.4.9" }, null, 2)}\n`, + "utf8", + ) + await writeFile( + join(projectDir, "release-plan.json"), + `${JSON.stringify( + { + mode: "build", + runtimeVersion: null, + channel: null, + distributions: [], + required: false, + message: null, + }, + null, + 2, + )}\n`, + "utf8", + ) + + const { applyReleaseConfig } = await import("./apply-release-config.impl.ts") + + await applyReleaseConfig({ projectDir, version: "1.5.1" }) + + await expect(readFile(join(projectDir, "package.json"), "utf8")).resolves.toContain( + '"runtimeVersion": "1.5.1"', + ) + }) + + it("rejects binary-policy mode when distributions are missing", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "desktop-release-config-")) + tempDirs.push(projectDir) + + await writeFile( + join(projectDir, "package.json"), + `${JSON.stringify({ name: "Folo", version: "1.5.0" }, null, 2)}\n`, + "utf8", + ) + await writeFile( + join(projectDir, "release-plan.json"), + `${JSON.stringify( + { + mode: "binary-policy", + runtimeVersion: null, + channel: "stable", + distributions: [], + required: true, + message: "Install the required binary update.", + }, + null, + 2, + )}\n`, + "utf8", + ) + + const { applyReleaseConfig } = await import("./apply-release-config.impl.ts") + + await expect(() => applyReleaseConfig({ projectDir, version: "1.5.1" })).rejects.toThrow( + /binary-policy mode requires channel and distributions/i, + ) + }) +}) diff --git a/apps/desktop/scripts/apply-release-config.ts b/apps/desktop/scripts/apply-release-config.ts new file mode 100644 index 000000000..1f8fa4b16 --- /dev/null +++ b/apps/desktop/scripts/apply-release-config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url" + +import { dirname } from "pathe" + +import { applyReleaseConfig } from "./apply-release-config.impl" + +const projectDir = dirname(dirname(fileURLToPath(import.meta.url))) + +async function main() { + await applyReleaseConfig({ + projectDir, + version: process.argv[2] ?? "", + }) +} + +void main() diff --git a/apps/ota/README.md b/apps/ota/README.md index 71799909f..0d591c9c1 100644 --- a/apps/ota/README.md +++ b/apps/ota/README.md @@ -14,6 +14,17 @@ This runbook covers release rollout verification and rollback for the `apps/ota` - `POST /internal/sync` - `GET /internal/health` +Desktop release automation is file-driven: + +- `apps/desktop/release-plan.json` +- `apps/desktop/release.json` + +Desktop release modes: + +- `build`: publish direct installer assets only +- `ota`: publish renderer OTA assets and direct installer assets together +- `binary-policy`: publish distribution-specific binary policy without rebuilding installers + ## Request Shape `/manifest`: @@ -23,12 +34,27 @@ This runbook covers release rollout verification and rollback for the `apps/ota` - Accepts `product`; defaults to `mobile`. - Returns `204` when no compatible OTA release exists. +Desktop `/manifest`: + +- Uses `X-App-Platform`, `X-App-Version`, and `X-App-Channel`. +- Accepts optional `X-App-Runtime-Version`; defaults to `X-App-Version`. +- Accepts optional `X-App-Renderer-Version`. +- Returns desktop JSON with `renderer` and optional `app` payloads. +- Never returns direct `app` payloads for `mas` or `mss`. + `/policy`: - Requires `installedBinaryVersion`. - Accepts `channel`; defaults to `production`. - Accepts `product`; defaults to `mobile`. +Desktop `/policy`: + +- Uses `X-App-Platform`, `X-App-Version`, and `X-App-Channel`. +- Resolves `distribution` from `X-App-Platform`. +- Prefers `policy:::` and falls back to `policy::`. +- Returns `none`, `prompt`, or `block` plus distribution-specific `downloadUrl` or `storeUrl`. + ## Release Checklist 1. Confirm the Git tag exists for the target release, for example `mobile/v0.4.2`. @@ -46,8 +72,9 @@ This runbook covers release rollout verification and rollback for the `apps/ota` 2. Read the current KV pointers before changing anything. 3. Overwrite the affected `latest::::` keys with the previous good `releaseVersion`. 4. If the issue is a bad store policy release, overwrite `policy::` with the previous good store release record. -5. Re-run the manual verification commands for `/manifest` and `/policy`. -6. Correct the GitHub Release source of truth before the next sync. +5. If the issue is a bad desktop distribution policy release, overwrite `policy:::` with the previous good policy record. +6. Re-run the manual verification commands for `/manifest` and `/policy`. +7. Correct the GitHub Release source of truth before the next sync. ## Verified v1 Limitation @@ -72,6 +99,7 @@ export OTA_PRODUCT="mobile" export OTA_CHANNEL="production" export OTA_RUNTIME_VERSION="0.4.1" export OTA_INSTALLED_BINARY_VERSION="0.4.1" +export OTA_DESKTOP_PLATFORM="desktop/windows/exe" export OTA_RELEASE_VERSION="0.4.2" export OTA_SYNC_TOKEN_HEADER="x-ota-sync-token" export OTA_SYNC_TOKEN="" @@ -135,6 +163,34 @@ curl --fail --silent --show-error \ jq . /tmp/ota-policy.json ``` +Verify desktop `/manifest` for a direct Windows build: + +```bash +curl --fail --silent --show-error \ + -H "X-App-Platform: $OTA_DESKTOP_PLATFORM" \ + -H "X-App-Version: 1.5.0" \ + -H "X-App-Runtime-Version: 1.5.0" \ + -H "X-App-Renderer-Version: 1.5.0" \ + -H "X-App-Channel: stable" \ + "$OTA_BASE_URL/manifest" \ + | tee /tmp/desktop-ota-manifest.json + +jq . /tmp/desktop-ota-manifest.json +``` + +Verify desktop `/policy` for MAS: + +```bash +curl --fail --silent --show-error \ + -H "X-App-Platform: desktop/macos/mas" \ + -H "X-App-Version: 1.5.0" \ + -H "X-App-Channel: stable" \ + "$OTA_BASE_URL/policy" \ + | tee /tmp/desktop-ota-policy.json + +jq . /tmp/desktop-ota-policy.json +``` + ## Manual Rollback Commands Inspect current pointers: @@ -187,6 +243,8 @@ Run these from the repository root: ```bash pnpm --filter @follow/ota test pnpm --filter @follow/ota typecheck +pnpm --dir apps/desktop/layer/main exec vitest run src/updater/api.test.ts src/updater/index.test.ts +pnpm --dir apps/desktop/layer/main typecheck pnpm --filter @follow/mobile exec vitest run src/modules/ota/__tests__/client.test.ts src/modules/ota/__tests__/store.test.ts src/modules/ota/__tests__/provider.test.ts pnpm --filter @follow/mobile typecheck pnpm exec prettier --check .github/workflows/publish-ota.yml .github/workflows/tag.yml .github/scripts/trigger-ota-sync.mjs .github/scripts/trigger-ota-sync.test.ts diff --git a/apps/ota/src/__tests__/manifest.test.ts b/apps/ota/src/__tests__/manifest.test.ts index 650bbdd89..6aea43c7e 100644 --- a/apps/ota/src/__tests__/manifest.test.ts +++ b/apps/ota/src/__tests__/manifest.test.ts @@ -6,7 +6,7 @@ import type { Env } from "../env" import otaWorker from "../index" import { KV_KEYS } from "../lib/constants" import { buildManifest } from "../lib/manifest" -import type { OtaRelease } from "../lib/schema" +import type { DesktopOtaRelease, MobileOtaRelease } from "../lib/schema" const textEncoder = new TextEncoder() @@ -43,7 +43,7 @@ describe("buildManifest", () => { ], }, }, - } satisfies OtaRelease, + } satisfies MobileOtaRelease, { origin: "https://ota.folo.is", platform: "ios", @@ -63,6 +63,28 @@ describe("buildManifest", () => { expect(manifest.assets[0]?.hash).toBe("u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7s") expect(manifest.assets[0]?.fileExtension).toBe(".png") }) + + it("uses a deterministic id when updateId is absent", () => { + const release = { + ...createRelease({ + product: "mobile", + git: { tag: "mobile/v0.4.2", commit: "abcdef1234567890" }, + }), + } + + delete (release as Partial).updateId + + const first = buildManifest(release, { + origin: "https://ota.folo.is", + platform: "ios", + }) + const second = buildManifest(release, { + origin: "https://ota.folo.is", + platform: "ios", + }) + + expect(first.id).toBe(second.id) + }) }) describe("/manifest", () => { @@ -104,6 +126,20 @@ describe("/manifest", () => { }) }) + it("rejects legacy query-based desktop manifest requests", async () => { + const response = await fetchWorker("/manifest?product=desktop", { + headers: { + "expo-platform": "ios", + "expo-runtime-version": "0.4.1", + }, + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: "Invalid product query parameter", + }) + }) + it("does not serve a store release as OTA", async () => { const release = createRelease({ releaseKind: "store", @@ -246,6 +282,107 @@ describe("/manifest", () => { error: "OTA code signing is not configured", }) }) + + it("returns renderer and app payloads for desktop direct builds", async () => { + const response = await fetchWorker( + "/manifest", + { + headers: { + "x-app-platform": "desktop/windows/exe", + "x-app-version": "1.5.0", + "x-app-runtime-version": "1.5.0", + "x-app-renderer-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [KV_KEYS.latest("desktop", "stable", "1.5.0", "windows"), { releaseVersion: "1.5.1" }], + [KV_KEYS.release("desktop", "1.5.1"), createDesktopRelease()], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + product: "desktop", + channel: "stable", + runtimeVersion: "1.5.0", + renderer: { + releaseVersion: "1.5.1", + version: "1.5.1", + commit: "abcdef1234567890", + }, + app: { + platform: "windows-x64", + version: "1.5.1", + manifest: { + name: "latest.yml", + }, + }, + }) + }) + + it("returns only renderer for desktop store distributions", async () => { + const response = await fetchWorker( + "/manifest", + { + headers: { + "x-app-platform": "desktop/macos/mas", + "x-app-version": "1.5.0", + "x-app-runtime-version": "1.5.0", + "x-app-renderer-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [KV_KEYS.latest("desktop", "stable", "1.5.0", "macos"), { releaseVersion: "1.5.1" }], + [KV_KEYS.release("desktop", "1.5.1"), createDesktopRelease()], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + product: "desktop", + renderer: { + releaseVersion: "1.5.1", + version: "1.5.1", + }, + app: null, + }) + }) + + it("never returns a direct app payload for desktop mss distributions", async () => { + const response = await fetchWorker( + "/manifest", + { + headers: { + "x-app-platform": "desktop/windows/ms", + "x-app-version": "1.5.0", + "x-app-runtime-version": "1.5.0", + "x-app-renderer-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [KV_KEYS.latest("desktop", "stable", "1.5.0", "windows"), { releaseVersion: "1.5.1" }], + [KV_KEYS.release("desktop", "1.5.1"), createDesktopRelease()], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + product: "desktop", + renderer: { + version: "1.5.1", + }, + app: null, + }) + }) }) describe("/assets/*", () => { @@ -303,7 +440,7 @@ describe("/policy", () => { }) }) -function createRelease(overrides: Partial = {}): OtaRelease { +function createRelease(overrides: Partial = {}): MobileOtaRelease { return { schemaVersion: 1, product: "mobile", @@ -341,6 +478,117 @@ function createRelease(overrides: Partial = {}): OtaRelease { } } +function createDesktopRelease(overrides: Partial = {}): DesktopOtaRelease { + return { + schemaVersion: 2, + product: "desktop", + channel: "stable", + releaseVersion: "1.5.1", + releaseKind: "ota", + runtimeVersion: "1.5.0", + publishedAt: "2026-04-11T10:00:00Z", + git: { + tag: "desktop/v1.5.1", + commit: "abcdef1234567890", + }, + policy: { + required: false, + minSupportedBinaryVersion: "1.5.0", + message: null, + distributions: { + direct: { + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + }, + mas: { + storeUrl: "https://apps.apple.com/app/id123456789", + }, + mss: { + storeUrl: "ms-windows-store://pdp/?ProductId=9NBLGGH12345", + }, + }, + }, + desktop: { + renderer: { + version: "1.5.1", + commit: "abcdef1234567890", + manifest: { + name: "manifest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/manifest.yml", + }, + launchAsset: { + path: "renderer/custom-renderer.tar.gz", + sha256: "a".repeat(64), + contentType: "application/gzip", + }, + assets: [], + }, + app: { + platforms: { + macos: { + platform: "darwin", + releaseDate: "2026-04-11T10:00:00Z", + manifest: { + name: "latest-mac.yml", + path: "latest-mac.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/latest-mac.yml", + }, + files: [ + { + filename: "Folo-1.5.1-mac.zip", + sha512: "c".repeat(88), + size: 543210, + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-mac.zip", + }, + ], + }, + windows: { + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00Z", + manifest: { + name: "latest.yml", + path: "latest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/latest.yml", + }, + files: [ + { + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "d".repeat(88), + size: 654321, + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-windows-x64.exe", + }, + ], + }, + linux: { + platform: "linux-x64", + releaseDate: "2026-04-11T10:00:00Z", + manifest: { + name: "latest-linux.yml", + path: "latest-linux.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/latest-linux.yml", + }, + files: [ + { + filename: "Folo-1.5.1.AppImage", + sha512: "e".repeat(88), + size: 765432, + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1.AppImage", + }, + ], + }, + }, + }, + }, + ...overrides, + } as DesktopOtaRelease +} + async function fetchWorker( path: string, init?: RequestInit, diff --git a/apps/ota/src/__tests__/policy.test.ts b/apps/ota/src/__tests__/policy.test.ts index adbf68345..dddd8b5db 100644 --- a/apps/ota/src/__tests__/policy.test.ts +++ b/apps/ota/src/__tests__/policy.test.ts @@ -1,13 +1,17 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" -import { evaluateStorePolicy } from "../lib/policy" -import type { OtaRelease } from "../lib/schema" +import type { Env } from "../env" +import otaWorker from "../index" +import { KV_KEYS } from "../lib/constants" +import type { BinaryPolicyRecord } from "../lib/kv" +import { evaluateBinaryPolicy, evaluateStorePolicy } from "../lib/policy" +import type { MobileOtaRelease } from "../lib/schema" function createStoreRelease( overrides: Partial< - Pick + Pick > = {}, -): Pick { +): Pick { return { releaseVersion: "0.4.3", releaseKind: "store", @@ -114,3 +118,289 @@ describe("evaluateStorePolicy", () => { }) }) }) + +function createBinaryPolicyRecord(overrides: Partial = {}): BinaryPolicyRecord { + return { + releaseVersion: "1.5.1", + required: false, + minSupportedBinaryVersion: "1.5.0", + message: "A desktop update is available.", + publishedAt: "2026-04-11T10:00:00Z", + distribution: null, + downloadUrl: null, + storeUrl: null, + ...overrides, + } +} + +describe("evaluateBinaryPolicy", () => { + it("returns none when there is no matching desktop policy record", () => { + expect( + evaluateBinaryPolicy( + { + targeted: null, + generic: null, + }, + { + installedBinaryVersion: "1.5.0", + distribution: "direct", + }, + ), + ).toEqual({ + action: "none", + targetVersion: null, + message: null, + distribution: "direct", + downloadUrl: null, + storeUrl: null, + publishedAt: null, + }) + }) + + it("prefers a distribution-specific desktop policy record", () => { + const policy = evaluateBinaryPolicy( + { + targeted: createBinaryPolicyRecord({ + required: true, + distribution: "mas", + storeUrl: "https://apps.apple.com/app/id123456789", + }), + generic: createBinaryPolicyRecord({ + distribution: null, + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + }), + }, + { + installedBinaryVersion: "1.5.0", + distribution: "mas", + }, + ) + + expect(policy).toEqual({ + action: "block", + targetVersion: "1.5.1", + message: "A desktop update is available.", + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + publishedAt: "2026-04-11T10:00:00Z", + }) + }) + + it("falls back to the generic desktop policy record", () => { + const policy = evaluateBinaryPolicy( + { + targeted: null, + generic: createBinaryPolicyRecord({ + required: false, + distribution: null, + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + }), + }, + { + installedBinaryVersion: "1.5.0", + distribution: "mss", + }, + ) + + expect(policy).toEqual({ + action: "prompt", + targetVersion: "1.5.1", + message: "A desktop update is available.", + distribution: "mss", + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + storeUrl: null, + publishedAt: "2026-04-11T10:00:00Z", + }) + }) + + it("blocks when the installed desktop binary is below the minimum supported version", () => { + const policy = evaluateBinaryPolicy( + { + targeted: createBinaryPolicyRecord({ + distribution: "direct", + required: false, + minSupportedBinaryVersion: "1.5.1", + downloadUrl: "https://ota.folo.is/Folo-1.5.2.exe", + releaseVersion: "1.5.2", + publishedAt: "2026-04-11T11:00:00Z", + }), + generic: null, + }, + { + installedBinaryVersion: "1.5.0", + distribution: "direct", + }, + ) + + expect(policy).toEqual({ + action: "block", + targetVersion: "1.5.2", + message: "A desktop update is available.", + distribution: "direct", + downloadUrl: "https://ota.folo.is/Folo-1.5.2.exe", + storeUrl: null, + publishedAt: "2026-04-11T11:00:00Z", + }) + }) + + it("returns none when the installed desktop binary is already current", () => { + const policy = evaluateBinaryPolicy( + { + targeted: createBinaryPolicyRecord({ + distribution: "direct", + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + }), + generic: null, + }, + { + installedBinaryVersion: "1.5.1", + distribution: "direct", + }, + ) + + expect(policy).toEqual({ + action: "none", + targetVersion: null, + message: null, + distribution: "direct", + downloadUrl: null, + storeUrl: null, + publishedAt: null, + }) + }) +}) + +describe("/policy", () => { + it("rejects legacy query-based desktop policy requests", async () => { + const response = await fetchWorker( + "/policy?product=desktop&channel=stable&installedBinaryVersion=1.5.0", + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: "Invalid product query parameter", + }) + }) + + it("returns distribution-specific desktop policy for mas", async () => { + const response = await fetchWorker( + "/policy", + { + headers: { + "x-app-platform": "desktop/macos/mas", + "x-app-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [ + KV_KEYS.policy("desktop", "stable"), + createBinaryPolicyRecord({ + distribution: null, + required: false, + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + }), + ], + [ + KV_KEYS.policy("desktop", "stable", "mas"), + createBinaryPolicyRecord({ + distribution: "mas", + required: true, + storeUrl: "https://apps.apple.com/app/id123456789", + downloadUrl: null, + }), + ], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + action: "block", + targetVersion: "1.5.1", + message: "A desktop update is available.", + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + publishedAt: "2026-04-11T10:00:00Z", + }) + }) + + it("falls back to generic desktop policy for mss", async () => { + const response = await fetchWorker( + "/policy", + { + headers: { + "x-app-platform": "desktop/windows/ms", + "x-app-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [ + KV_KEYS.policy("desktop", "stable"), + createBinaryPolicyRecord({ + distribution: null, + required: false, + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + }), + ], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + action: "prompt", + targetVersion: "1.5.1", + message: "A desktop update is available.", + distribution: "mss", + downloadUrl: "https://ota.folo.is/Folo-1.5.1.exe", + storeUrl: null, + publishedAt: "2026-04-11T10:00:00Z", + }) + }) +}) + +async function fetchWorker( + path: string, + init?: RequestInit, + options?: { + kvEntries?: Map + }, +) { + return otaWorker.fetch( + new Request(`https://ota.folo.is${path}`, init), + createEnv(options), + createExecutionContext(), + ) +} + +function createEnv(options?: { kvEntries?: Map }): Env { + return { + OTA_KV: createKvNamespace(options?.kvEntries), + OTA_BUCKET: {} as R2Bucket, + GITHUB_OWNER: "", + GITHUB_REPO: "", + GITHUB_TOKEN: "", + OTA_SYNC_TOKEN: "", + OTA_SYNC_TOKEN_HEADER: "x-ota-sync-token", + } +} + +function createKvNamespace(entries = new Map()): KVNamespace { + return { + get: vi.fn(async (key: string) => entries.get(key) ?? null), + put: vi.fn(async () => {}), + } as unknown as KVNamespace +} + +function createExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as ExecutionContext +} diff --git a/apps/ota/src/__tests__/schema.test.ts b/apps/ota/src/__tests__/schema.test.ts index ae0ce70c2..30b4e0f1c 100644 --- a/apps/ota/src/__tests__/schema.test.ts +++ b/apps/ota/src/__tests__/schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" +import type { DesktopOtaRelease } from "../lib/schema" import { otaReleaseSchema } from "../lib/schema" describe("otaReleaseSchema", () => { @@ -31,7 +32,7 @@ describe("otaReleaseSchema", () => { assets: [], }, }, - }) + }) as DesktopOtaRelease expect(parsed.releaseVersion).toBe("0.4.2") expect(parsed.runtimeVersion).toBe("0.4.1") @@ -81,7 +82,7 @@ describe("otaReleaseSchema", () => { message: "Install 0.4.3 from the store.", }, platforms: {}, - }) + }) as DesktopOtaRelease expect(parsed.releaseKind).toBe("store") expect(parsed.platforms).toEqual({}) @@ -112,4 +113,131 @@ describe("otaReleaseSchema", () => { expect(result.success).toBe(false) }) + + it("accepts desktop schemaVersion 2 ota metadata", () => { + const parsed = otaReleaseSchema.parse({ + schemaVersion: 2, + product: "desktop", + channel: "stable", + releaseVersion: "1.5.1", + releaseKind: "ota", + runtimeVersion: "1.5.0", + publishedAt: "2026-04-11T10:00:00Z", + git: { + tag: "desktop/v1.5.1", + commit: "abcdef1234567890", + }, + policy: { + required: false, + minSupportedBinaryVersion: "1.5.0", + message: null, + distributions: { + direct: { + downloadUrl: "https://ota.folo.is/Folo-1.5.1-macos.dmg", + }, + }, + }, + desktop: { + renderer: { + version: "1.5.1", + commit: "abcdef1234567890", + launchAsset: { + path: "renderer/custom-renderer.tar.gz", + sha256: "a".repeat(64), + contentType: "application/gzip", + }, + assets: [ + { + path: "renderer/manifest.yml", + sha256: "b".repeat(64), + contentType: "text/yaml", + }, + ], + }, + app: { + platforms: { + windows: { + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00Z", + manifest: { + name: "latest.yml", + downloadUrl: "https://ota.folo.is/latest.yml", + }, + files: [ + { + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "c".repeat(88), + size: 123456, + downloadUrl: "https://ota.folo.is/Folo-1.5.1-windows-x64.exe", + }, + ], + }, + }, + }, + }, + }) as DesktopOtaRelease + + expect(parsed.releaseKind).toBe("ota") + expect(parsed.product).toBe("desktop") + expect(parsed.desktop.renderer?.version).toBe("1.5.1") + expect(parsed.platforms.windows?.launchAsset.path).toBe("renderer/custom-renderer.tar.gz") + }) + + it("aliases legacy desktop store metadata to binary", () => { + const parsed = otaReleaseSchema.parse({ + schemaVersion: 2, + product: "desktop", + channel: "stable", + releaseVersion: "1.5.1", + releaseKind: "store", + runtimeVersion: null, + publishedAt: "2026-04-11T10:00:00Z", + git: { + tag: "desktop/v1.5.1", + commit: "abcdef1234567890", + }, + policy: { + required: true, + minSupportedBinaryVersion: "1.5.0", + message: "Install the latest desktop app.", + distributions: { + mas: { + storeUrl: "https://apps.apple.com/app/id123456789", + }, + }, + }, + desktop: { + renderer: null, + app: null, + }, + }) as DesktopOtaRelease + + expect(parsed.releaseKind).toBe("binary") + expect(parsed.policy.distributions.mas?.storeUrl).toContain("apps.apple.com") + expect(parsed.platforms).toEqual({}) + }) + + it("rejects schemaVersion 1 desktop metadata", () => { + const result = otaReleaseSchema.safeParse({ + schemaVersion: 1, + product: "desktop", + channel: "stable", + releaseVersion: "1.5.1", + releaseKind: "ota", + runtimeVersion: "1.5.0", + publishedAt: "2026-04-11T10:00:00Z", + git: { + tag: "desktop/v1.5.1", + commit: "abcdef1234567890", + }, + policy: { + storeRequired: false, + minSupportedBinaryVersion: "1.5.0", + message: null, + }, + platforms: {}, + }) + + expect(result.success).toBe(false) + }) }) diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts index d33d28d29..7641a5313 100644 --- a/apps/ota/src/__tests__/sync.test.ts +++ b/apps/ota/src/__tests__/sync.test.ts @@ -8,7 +8,8 @@ import { KV_KEYS } from "../lib/constants" import type { GitHubRequestError } from "../lib/github" import { listPublishedOtaReleases } from "../lib/github" import { IMMUTABLE_ASSET_CACHE_CONTROL, putMirroredFiles } from "../lib/r2" -import type { OtaRelease } from "../lib/schema" +import type { DesktopOtaRelease, MobileOtaRelease, OtaRelease } from "../lib/schema" +import { otaReleaseSchema } from "../lib/schema" import { mirrorReleaseToStorage, syncGitHubReleases } from "../lib/sync" vi.mock("fzstd", () => { @@ -700,6 +701,392 @@ describe("syncGitHubReleases", () => { expect(kvEntries.get(KV_KEYS.release("mobile", "0.4.3"))).toBe(JSON.stringify(storeRelease)) }) + it("writes distribution-aware policy keys for desktop binary metadata", async () => { + const kvEntries = new Map() + const binaryRelease = createDesktopReleaseMetadata({ + releaseKind: "binary", + runtimeVersion: null, + desktop: { + renderer: null, + app: null, + }, + policy: { + required: true, + minSupportedBinaryVersion: "1.5.0", + message: "Install the latest desktop app.", + distributions: { + direct: { + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + }, + mas: { + storeUrl: "https://apps.apple.com/app/id123456789", + }, + mss: { + storeUrl: "ms-windows-store://pdp/?ProductId=9NBLGGH12345", + }, + }, + }, + }) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases") { + return new Response( + JSON.stringify([ + createGitHubReleaseAssetSet( + "desktop/v1.5.1", + "https://example.com/desktop-binary.json", + null, + ), + ]), + { status: 200 }, + ) + } + + if (url === "https://example.com/desktop-binary.json") { + return new Response(JSON.stringify(binaryRelease), { + headers: { + "Content-Type": "application/json", + }, + }) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await syncGitHubReleases( + createEnv({ + kvEntries, + envOverrides: { + GITHUB_OWNER: "RSSNext", + GITHUB_REPO: "Folo", + GITHUB_TOKEN: "token", + }, + }), + ) + + expect(kvEntries.get(KV_KEYS.release("desktop", "1.5.1"))).toBe(JSON.stringify(binaryRelease)) + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable"))).toEqual(expect.any(String)) + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable", "direct"))).toEqual(expect.any(String)) + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable", "mas"))).toEqual(expect.any(String)) + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable", "mss"))).toEqual(expect.any(String)) + + expect( + JSON.parse(String(kvEntries.get(KV_KEYS.policy("desktop", "stable", "direct")))), + ).toEqual( + expect.objectContaining({ + distribution: "direct", + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + storeUrl: null, + }), + ) + expect(JSON.parse(String(kvEntries.get(KV_KEYS.policy("desktop", "stable", "mas"))))).toEqual( + expect.objectContaining({ + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + }), + ) + }) + + it("clears stale desktop distribution policy keys that disappear from a newer binary release", async () => { + const kvEntries = new Map([ + [ + KV_KEYS.policy("desktop", "stable", "mas"), + JSON.stringify({ + releaseVersion: "1.5.0", + required: false, + minSupportedBinaryVersion: "1.5.0", + message: "Old store policy.", + publishedAt: "2026-04-11T09:00:00Z", + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + }), + ], + ]) + + const binaryRelease = createDesktopReleaseMetadata({ + releaseKind: "binary", + runtimeVersion: null, + desktop: { + renderer: null, + app: null, + }, + policy: { + required: false, + minSupportedBinaryVersion: "1.5.0", + message: "Use the latest direct installer.", + distributions: { + direct: { + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + }, + }, + }, + }) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases") { + return new Response( + JSON.stringify([ + createGitHubReleaseAssetSet( + "desktop/v1.5.1", + "https://example.com/desktop-binary.json", + null, + ), + ]), + { status: 200 }, + ) + } + + if (url === "https://example.com/desktop-binary.json") { + return new Response(JSON.stringify(binaryRelease), { + headers: { + "Content-Type": "application/json", + }, + }) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await syncGitHubReleases( + createEnv({ + kvEntries, + envOverrides: { + GITHUB_OWNER: "RSSNext", + GITHUB_REPO: "Folo", + GITHUB_TOKEN: "token", + }, + }), + ) + + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable", "direct"))).toEqual(expect.any(String)) + expect(kvEntries.has(KV_KEYS.policy("desktop", "stable", "mas"))).toBe(false) + }) + + it("does not delete newer desktop distribution policy keys when an older binary release syncs later", async () => { + const kvEntries = new Map([ + [ + KV_KEYS.policy("desktop", "stable", "mas"), + JSON.stringify({ + releaseVersion: "1.5.2", + required: false, + minSupportedBinaryVersion: "1.5.0", + message: "Newer store policy.", + publishedAt: "2026-04-11T10:00:00Z", + distribution: "mas", + downloadUrl: null, + storeUrl: "https://apps.apple.com/app/id123456789", + }), + ], + ]) + + const olderBinaryRelease = createDesktopReleaseMetadata({ + releaseVersion: "1.5.1", + releaseKind: "binary", + runtimeVersion: null, + desktop: { + renderer: null, + app: null, + }, + policy: { + required: false, + minSupportedBinaryVersion: "1.5.0", + message: "Older direct installer policy.", + distributions: { + direct: { + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + }, + }, + }, + }) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases") { + return new Response( + JSON.stringify([ + createGitHubReleaseAssetSet( + "desktop/v1.5.1", + "https://example.com/desktop-older-binary.json", + null, + ), + ]), + { status: 200 }, + ) + } + + if (url === "https://example.com/desktop-older-binary.json") { + return new Response(JSON.stringify(olderBinaryRelease), { + headers: { + "Content-Type": "application/json", + }, + }) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await syncGitHubReleases( + createEnv({ + kvEntries, + envOverrides: { + GITHUB_OWNER: "RSSNext", + GITHUB_REPO: "Folo", + GITHUB_TOKEN: "token", + }, + }), + ) + + expect(JSON.parse(String(kvEntries.get(KV_KEYS.policy("desktop", "stable", "mas"))))).toEqual( + expect.objectContaining({ + releaseVersion: "1.5.2", + distribution: "mas", + }), + ) + }) + + it("mirrors only desktop renderer archives and writes latest pointers for desktop ota", async () => { + const kvEntries = new Map() + const bucketEntries = new Map }>() + const rendererBundle = textEncoder.encode("desktop renderer bundle") + const desktopOtaRelease = createDesktopReleaseMetadata({ + releaseKind: "ota", + runtimeVersion: "1.5.0", + desktop: { + renderer: { + version: "1.5.1", + commit: "abcdef1234567890", + manifest: { + name: "manifest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/manifest.yml", + }, + launchAsset: { + path: "renderer/custom-renderer.tar.gz", + sha256: await sha256Hex(rendererBundle), + contentType: "application/gzip", + }, + assets: [], + }, + app: { + platforms: { + windows: { + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00Z", + manifest: { + name: "latest.yml", + path: "latest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/latest.yml", + }, + files: [ + { + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "d".repeat(88), + size: 654321, + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-windows-x64.exe", + }, + ], + }, + }, + }, + }, + }) + const desktopArchive = await createTarArchive([ + { + name: "renderer/custom-renderer.tar.gz", + body: rendererBundle, + }, + ]) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url === "https://api.github.com/repos/RSSNext/Folo/releases") { + return new Response( + JSON.stringify([ + createGitHubReleaseAssetSet( + "desktop/v1.5.1", + "https://example.com/desktop-ota.json", + "https://example.com/desktop-ota.tar.zst", + ), + ]), + { status: 200 }, + ) + } + + if (url === "https://example.com/desktop-ota.json") { + return new Response(JSON.stringify(desktopOtaRelease), { + headers: { + "Content-Type": "application/json", + }, + }) + } + + if (url === "https://example.com/desktop-ota.tar.zst") { + const archivePayload = new Uint8Array(desktopArchive.byteLength) + archivePayload.set(desktopArchive) + return new Response(archivePayload.buffer) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await syncGitHubReleases( + createEnv({ + kvEntries, + bucketEntries, + envOverrides: { + GITHUB_OWNER: "RSSNext", + GITHUB_REPO: "Folo", + GITHUB_TOKEN: "token", + }, + }), + ) + + expect(kvEntries.get(KV_KEYS.release("desktop", "1.5.1"))).toBe( + JSON.stringify(desktopOtaRelease), + ) + expect(kvEntries.get(KV_KEYS.latest("desktop", "stable", "1.5.0", "macos"))).toBe( + JSON.stringify({ releaseVersion: "1.5.1" }), + ) + expect(kvEntries.get(KV_KEYS.latest("desktop", "stable", "1.5.0", "windows"))).toBe( + JSON.stringify({ releaseVersion: "1.5.1" }), + ) + expect(kvEntries.get(KV_KEYS.latest("desktop", "stable", "1.5.0", "linux"))).toBe( + JSON.stringify({ releaseVersion: "1.5.1" }), + ) + expect( + bucketEntries.has("desktop/stable/1.5.0/1.5.1/macos/renderer/custom-renderer.tar.gz"), + ).toBe(true) + expect( + bucketEntries.has("desktop/stable/1.5.0/1.5.1/windows/renderer/custom-renderer.tar.gz"), + ).toBe(true) + expect( + bucketEntries.has("desktop/stable/1.5.0/1.5.1/linux/renderer/custom-renderer.tar.gz"), + ).toBe(true) + expect(bucketEntries.has("desktop/stable/1.5.0/1.5.1/windows/latest.yml")).toBe(false) + }) + it("downloads private release assets through authenticated GitHub asset API requests", async () => { const kvEntries = new Map() const bucketEntries = new Map }>() @@ -1266,6 +1653,9 @@ function createKvNamespace(entries = new Map()): KVNamespace { put: vi.fn(async (key: string, value: string) => { entries.set(key, value) }), + delete: vi.fn(async (key: string) => { + entries.delete(key) + }), } as unknown as KVNamespace } @@ -1326,7 +1716,9 @@ function createExecutionContext(): ExecutionContext { } as unknown as ExecutionContext } -async function createReleaseMetadata(overrides: Partial = {}): Promise { +async function createReleaseMetadata( + overrides: Partial = {}, +): Promise { return { schemaVersion: 1, product: "mobile", @@ -1358,6 +1750,75 @@ async function createReleaseMetadata(overrides: Partial = {}): Promi } } +function createDesktopReleaseMetadata( + overrides: Partial = {}, +): DesktopOtaRelease { + return otaReleaseSchema.parse({ + schemaVersion: 2, + product: "desktop", + channel: "stable", + releaseVersion: "1.5.1", + releaseKind: "ota", + runtimeVersion: "1.5.0", + publishedAt: "2026-04-11T10:00:00Z", + git: { + tag: "desktop/v1.5.1", + commit: "abcdef1234567890", + }, + policy: { + required: false, + minSupportedBinaryVersion: "1.5.0", + message: null, + distributions: { + direct: { + downloadUrl: "https://ota.folo.is/Folo-1.5.1.dmg", + }, + }, + }, + desktop: { + renderer: { + version: "1.5.1", + commit: "abcdef1234567890", + manifest: { + name: "manifest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/manifest.yml", + }, + launchAsset: { + path: "renderer/custom-renderer.tar.gz", + sha256: "a".repeat(64), + contentType: "application/gzip", + }, + assets: [], + }, + app: { + platforms: { + windows: { + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00Z", + manifest: { + name: "latest.yml", + path: "latest.yml", + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/latest.yml", + }, + files: [ + { + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "d".repeat(88), + size: 654321, + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-windows-x64.exe", + }, + ], + }, + }, + }, + }, + ...overrides, + }) as DesktopOtaRelease +} + function createGitHubReleaseAssetSet( tag: string, metadataUrl: string, diff --git a/apps/ota/src/__tests__/version.test.ts b/apps/ota/src/__tests__/version.test.ts index 443b9f9fd..41ba35f96 100644 --- a/apps/ota/src/__tests__/version.test.ts +++ b/apps/ota/src/__tests__/version.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import type { OtaRelease } from "../lib/schema" +import type { MobileOtaRelease } from "../lib/schema" import { compareSemver, selectLatestCompatibleRelease } from "../lib/version" function createPlatform(path: string, sha256Seed: string) { @@ -14,7 +14,7 @@ function createPlatform(path: string, sha256Seed: string) { } } -function createRelease(overrides: Partial = {}): OtaRelease { +function createRelease(overrides: Partial = {}): MobileOtaRelease { return { schemaVersion: 1, product: "mobile", diff --git a/apps/ota/src/lib/archive.ts b/apps/ota/src/lib/archive.ts index e034030cb..5d9c58526 100644 --- a/apps/ota/src/lib/archive.ts +++ b/apps/ota/src/lib/archive.ts @@ -1,11 +1,11 @@ import { Decompress } from "fzstd" import tar from "tar-stream" -import type { OtaPlatform, OtaRelease } from "./schema" +import type { OtaPlatform, OtaPlatformPayload, OtaProjectedPlatforms, OtaRelease } from "./schema" const OTA_PLATFORMS: OtaPlatform[] = ["ios", "android", "macos", "windows", "linux"] -type PlatformPayload = NonNullable +type PlatformPayload = OtaPlatformPayload type PlatformAsset = PlatformPayload["launchAsset"] | PlatformPayload["assets"][number] interface MirroredFileRequest { @@ -149,9 +149,10 @@ export async function extractMirroredFiles(input: { function createMirroredFileRequests(release: OtaRelease): MirroredFileRequest[] { const requests: MirroredFileRequest[] = [] + const platforms = release.platforms as OtaProjectedPlatforms for (const platform of OTA_PLATFORMS) { - const platformPayload = release.platforms[platform] + const platformPayload = platforms[platform] if (!platformPayload) { continue diff --git a/apps/ota/src/lib/constants.ts b/apps/ota/src/lib/constants.ts index 7262afc7e..309dabf58 100644 --- a/apps/ota/src/lib/constants.ts +++ b/apps/ota/src/lib/constants.ts @@ -1,4 +1,4 @@ -import type { OtaPlatform, OtaRelease } from "./schema" +import type { DesktopDistribution, OtaPlatform, OtaRelease } from "./schema" export const KV_KEYS = { release: (product: OtaRelease["product"], releaseVersion: OtaRelease["releaseVersion"]) => @@ -9,8 +9,12 @@ export const KV_KEYS = { runtimeVersion: OtaRelease["runtimeVersion"], platform: OtaPlatform, ) => `latest:${product}:${channel}:${runtimeVersion}:${platform}`, - policy: (product: OtaRelease["product"], channel: OtaRelease["channel"]) => - `policy:${product}:${channel}`, + policy: ( + product: OtaRelease["product"], + channel: OtaRelease["channel"], + distribution?: DesktopDistribution, + ) => + distribution ? `policy:${product}:${channel}:${distribution}` : `policy:${product}:${channel}`, githubEtag: "github:etag:releases", syncLastSuccessAt: "sync:last-success-at", } as const diff --git a/apps/ota/src/lib/desktop.ts b/apps/ota/src/lib/desktop.ts new file mode 100644 index 000000000..593746c6d --- /dev/null +++ b/apps/ota/src/lib/desktop.ts @@ -0,0 +1,76 @@ +import { buildManifest, resolveManifestId } from "./manifest" +import type { DesktopOtaRelease, OtaRelease } from "./schema" +import { compareSemver } from "./version" + +export function buildDesktopManifestResponse( + release: DesktopOtaRelease, + input: { + origin: string + platform: "macos" | "windows" | "linux" + distribution: "direct" | "mas" | "mss" + installedBinaryVersion: string | null + rendererVersion: string | null + }, +) { + const rendererManifest = buildManifest(release, { + origin: input.origin, + platform: input.platform, + }) + + const renderer = + release.desktop.renderer && + isVersionOutdated(input.rendererVersion, release.desktop.renderer.version) + ? { + releaseVersion: release.releaseVersion, + version: release.desktop.renderer.version, + commit: release.desktop.renderer.commit, + launchAsset: rendererManifest.launchAsset, + assets: rendererManifest.assets, + } + : null + + const desktopApp = release.desktop.app?.platforms[input.platform] + const app = + input.distribution === "direct" && + desktopApp && + isVersionOutdated(input.installedBinaryVersion, release.releaseVersion) + ? { + releaseVersion: release.releaseVersion, + version: release.releaseVersion, + platform: desktopApp.platform, + releaseDate: desktopApp.releaseDate, + manifest: desktopApp.manifest, + files: desktopApp.files, + } + : null + + if (!renderer && !app) { + return null + } + + return { + id: resolveManifestId(release, input.platform), + createdAt: release.publishedAt, + product: "desktop" as const, + channel: release.channel, + runtimeVersion: release.runtimeVersion, + renderer, + app, + } +} + +export function isDesktopRelease(release: OtaRelease): release is DesktopOtaRelease { + return release.product === "desktop" +} + +function isVersionOutdated(current: string | null, latest: string | null) { + if (!latest) { + return false + } + + if (!current) { + return true + } + + return compareSemver(current, latest) < 0 +} diff --git a/apps/ota/src/lib/kv.ts b/apps/ota/src/lib/kv.ts index c3734f090..29552ce9a 100644 --- a/apps/ota/src/lib/kv.ts +++ b/apps/ota/src/lib/kv.ts @@ -1,5 +1,5 @@ import { KV_KEYS } from "./constants" -import type { OtaPlatform, OtaRelease } from "./schema" +import type { DesktopDistribution, OtaPlatform, OtaRelease } from "./schema" export interface LatestReleasePointerRecord { releaseVersion: OtaRelease["releaseVersion"] @@ -7,6 +7,17 @@ export interface LatestReleasePointerRecord { export type ReleaseRecord = OtaRelease +export interface BinaryPolicyRecord { + releaseVersion: OtaRelease["releaseVersion"] + required: boolean + minSupportedBinaryVersion: string + message: string | null + publishedAt: string | null + distribution: DesktopDistribution | null + downloadUrl: string | null + storeUrl: string | null +} + export async function getLatestReleasePointer( kv: KVNamespace, input: { @@ -30,3 +41,32 @@ export async function putReleaseRecord( ) { await kv.put(KV_KEYS.release(product, releaseVersion), JSON.stringify(value)) } + +export async function getBinaryPolicyRecord( + kv: KVNamespace, + input: { + product: OtaRelease["product"] + channel: OtaRelease["channel"] + distribution?: DesktopDistribution + }, +) { + return kv.get( + KV_KEYS.policy(input.product, input.channel, input.distribution), + "json", + ) +} + +export async function putBinaryPolicyRecord( + kv: KVNamespace, + input: { + product: OtaRelease["product"] + channel: OtaRelease["channel"] + distribution?: DesktopDistribution + value: BinaryPolicyRecord + }, +) { + await kv.put( + KV_KEYS.policy(input.product, input.channel, input.distribution), + JSON.stringify(input.value), + ) +} diff --git a/apps/ota/src/lib/manifest.ts b/apps/ota/src/lib/manifest.ts index 9f461ac25..866201017 100644 --- a/apps/ota/src/lib/manifest.ts +++ b/apps/ota/src/lib/manifest.ts @@ -1,7 +1,9 @@ -import { buildMirroredAssetKey } from "./archive" -import type { OtaPlatform, OtaRelease } from "./schema" +import { createHash } from "node:crypto" -type PlatformPayload = NonNullable +import { buildMirroredAssetKey } from "./archive" +import type { OtaPlatform, OtaPlatformPayload, OtaProjectedPlatforms, OtaRelease } from "./schema" + +type PlatformPayload = OtaPlatformPayload type PlatformAsset = PlatformPayload["launchAsset"] | PlatformPayload["assets"][number] export interface ManifestAsset { @@ -34,7 +36,8 @@ export function buildManifest( platform: OtaPlatform }, ): OtaManifest { - const platformPayload = release.platforms[input.platform] + const platforms = release.platforms as OtaProjectedPlatforms + const platformPayload = platforms[input.platform] if (!platformPayload) { throw new Error( @@ -43,16 +46,16 @@ export function buildManifest( } return { - id: release.updateId ?? crypto.randomUUID(), + id: resolveManifestId(release, input.platform), createdAt: release.publishedAt, - runtimeVersion: release.runtimeVersion, + runtimeVersion: release.runtimeVersion ?? release.releaseVersion, launchAsset: toManifestAsset( release, input.origin, input.platform, platformPayload.launchAsset, ), - assets: platformPayload.assets.map((asset) => + assets: platformPayload.assets.map((asset: PlatformAsset) => toManifestAsset(release, input.origin, input.platform, asset), ), metadata: { @@ -65,6 +68,16 @@ export function buildManifest( } } +export function resolveManifestId(release: OtaRelease, platform: OtaPlatform) { + if (release.updateId) { + return release.updateId + } + + return createDeterministicUuid( + `${release.product}:${release.channel}:${release.releaseVersion}:${release.runtimeVersion}:${platform}`, + ) +} + function toManifestAsset( release: OtaRelease, origin: string, @@ -160,3 +173,20 @@ function inferExtensionFromContentType(contentType: string) { } } } + +function createDeterministicUuid(seed: string) { + const digest = createHash("sha1").update(seed).digest() + const bytes = Uint8Array.from(digest.subarray(0, 16)) + + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + + const hex = Buffer.from(bytes).toString("hex") + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join("-") +} diff --git a/apps/ota/src/lib/policy.ts b/apps/ota/src/lib/policy.ts index 1c1231df1..d36a84c40 100644 --- a/apps/ota/src/lib/policy.ts +++ b/apps/ota/src/lib/policy.ts @@ -1,8 +1,9 @@ -import type { OtaRelease } from "./schema" +import type { BinaryPolicyRecord } from "./kv" +import type { DesktopDistribution, MobileOtaRelease, OtaRelease } from "./schema" import { compareSemver } from "./version" type StoreReleasePolicyInput = Pick< - OtaRelease, + MobileOtaRelease, "releaseVersion" | "releaseKind" | "runtimeVersion" | "policy" > @@ -51,3 +52,72 @@ export function evaluateStorePolicy( message: latestStoreRelease.policy.message, } } + +type BinaryPolicyEvaluation = { + action: "none" | "prompt" | "block" + targetVersion: OtaRelease["releaseVersion"] | null + message: string | null + distribution: DesktopDistribution + downloadUrl: string | null + storeUrl: string | null + publishedAt: string | null +} + +export function evaluateBinaryPolicy( + policies: { + targeted: BinaryPolicyRecord | null + generic: BinaryPolicyRecord | null + }, + input: { + installedBinaryVersion: OtaRelease["releaseVersion"] + distribution: DesktopDistribution + }, +): BinaryPolicyEvaluation { + const policy = policies.targeted ?? policies.generic + + if (!policy) { + return { + action: "none", + targetVersion: null, + message: null, + distribution: input.distribution, + downloadUrl: null, + storeUrl: null, + publishedAt: null, + } + } + + if (compareSemver(input.installedBinaryVersion, policy.minSupportedBinaryVersion) < 0) { + return { + action: "block", + targetVersion: policy.releaseVersion, + message: policy.message, + distribution: input.distribution, + downloadUrl: policy.downloadUrl, + storeUrl: policy.storeUrl, + publishedAt: policy.publishedAt, + } + } + + if (compareSemver(policy.releaseVersion, input.installedBinaryVersion) <= 0) { + return { + action: "none", + targetVersion: null, + message: null, + distribution: input.distribution, + downloadUrl: null, + storeUrl: null, + publishedAt: null, + } + } + + return { + action: policy.required ? "block" : "prompt", + targetVersion: policy.releaseVersion, + message: policy.message, + distribution: input.distribution, + downloadUrl: policy.downloadUrl, + storeUrl: policy.storeUrl, + publishedAt: policy.publishedAt, + } +} diff --git a/apps/ota/src/lib/request.ts b/apps/ota/src/lib/request.ts new file mode 100644 index 000000000..9cca644a1 --- /dev/null +++ b/apps/ota/src/lib/request.ts @@ -0,0 +1,38 @@ +import type { Context } from "hono" + +import type { Env } from "../env" +import type { DesktopDistribution, OtaPlatform } from "./schema" + +const DESKTOP_PLATFORM_MAP: Record< + string, + { + platform: Extract + distribution: DesktopDistribution + } +> = { + "desktop/macos/dmg": { platform: "macos", distribution: "direct" }, + "desktop/macos/mas": { platform: "macos", distribution: "mas" }, + "desktop/windows/exe": { platform: "windows", distribution: "direct" }, + "desktop/windows/ms": { platform: "windows", distribution: "mss" }, + "desktop/linux": { platform: "linux", distribution: "direct" }, +} + +export function parseDesktopRequest(c: Context<{ Bindings: Env }>) { + const platformHeader = c.req.header("x-app-platform") + const mapped = platformHeader ? DESKTOP_PLATFORM_MAP[platformHeader] : undefined + const installedBinaryVersion = c.req.header("x-app-version") ?? null + const runtimeVersion = c.req.header("x-app-runtime-version") ?? installedBinaryVersion + const rendererVersion = c.req.header("x-app-renderer-version") ?? null + const channel = c.req.header("x-app-channel") ?? null + + return { + platformHeader, + product: "desktop" as const, + platform: mapped?.platform ?? null, + distribution: mapped?.distribution ?? null, + installedBinaryVersion, + runtimeVersion, + rendererVersion, + channel, + } +} diff --git a/apps/ota/src/lib/schema.ts b/apps/ota/src/lib/schema.ts index fff259ce1..06a253a85 100644 --- a/apps/ota/src/lib/schema.ts +++ b/apps/ota/src/lib/schema.ts @@ -2,6 +2,7 @@ import { z } from "zod" const semver = z.string().regex(/^\d+\.\d+\.\d+$/) const sha256 = z.string().regex(/^[a-f0-9]{64}$/) +const sha512 = z.string().regex(/^[A-Z0-9+/=]+$/i) const assetSchema = z.object({ path: z.string().min(1), @@ -9,26 +10,32 @@ const assetSchema = z.object({ contentType: z.string().min(1), }) -const platformSchema = z.object({ +const platformAssetSchema = z.object({ launchAsset: assetSchema, assets: z.array(assetSchema), }) -const platformsSchema = z +const projectedPlatformsSchema = z .object({ - ios: platformSchema.optional(), - android: platformSchema.optional(), - macos: platformSchema.optional(), - windows: platformSchema.optional(), - linux: platformSchema.optional(), + ios: platformAssetSchema.optional(), + android: platformAssetSchema.optional(), + macos: platformAssetSchema.optional(), + windows: platformAssetSchema.optional(), + linux: platformAssetSchema.optional(), }) .strict() -export const otaReleaseSchema = z +const mobilePolicySchema = z.object({ + storeRequired: z.boolean(), + minSupportedBinaryVersion: semver, + message: z.string().nullable(), +}) + +const mobileReleaseSchema = z .object({ schemaVersion: z.literal(1), updateId: z.string().uuid().optional(), - product: z.enum(["mobile", "desktop"]), + product: z.literal("mobile"), channel: z.string().min(1), releaseVersion: semver, releaseKind: z.enum(["ota", "store"]), @@ -38,12 +45,8 @@ export const otaReleaseSchema = z tag: z.string().min(1), commit: z.string().min(7), }), - policy: z.object({ - storeRequired: z.boolean(), - minSupportedBinaryVersion: semver, - message: z.string().nullable(), - }), - platforms: platformsSchema, + policy: mobilePolicySchema, + platforms: projectedPlatformsSchema, }) .superRefine((release, ctx) => { if (release.releaseKind === "ota" && !Object.values(release.platforms).some(Boolean)) { @@ -55,5 +58,135 @@ export const otaReleaseSchema = z } }) +const desktopDistributionSchema = z.enum(["direct", "mas", "mss"]) + +const desktopPolicyDistributionSchema = z.object({ + downloadUrl: z.string().url().nullable().optional(), + storeUrl: z.string().url().nullable().optional(), +}) + +const desktopRendererSchema = z.object({ + version: semver, + commit: z.string().min(7), + manifest: z + .object({ + name: z.string().min(1), + downloadUrl: z.string().url(), + }) + .optional(), + launchAsset: assetSchema, + assets: z.array(assetSchema), +}) + +const desktopAppFileSchema = z.object({ + filename: z.string().min(1), + sha512, + size: z.number().int().nonnegative(), + downloadUrl: z.string().url(), +}) + +const desktopAppPlatformSchema = z.object({ + platform: z.string().min(1), + releaseDate: z.string().datetime().nullable(), + manifest: z.object({ + name: z.string().min(1), + path: z.string().min(1).optional(), + downloadUrl: z.string().url(), + }), + files: z.array(desktopAppFileSchema), +}) + +const desktopPolicySchema = z.object({ + required: z.boolean(), + minSupportedBinaryVersion: semver, + message: z.string().nullable(), + distributions: z.record(desktopDistributionSchema, desktopPolicyDistributionSchema).default({}), +}) + +const desktopReleaseInputSchema = z + .object({ + schemaVersion: z.literal(2), + updateId: z.string().uuid().optional(), + product: z.literal("desktop"), + channel: z.string().min(1), + releaseVersion: semver, + releaseKind: z.enum(["ota", "binary", "store"]), + runtimeVersion: semver.nullable(), + publishedAt: z.string().datetime(), + git: z.object({ + tag: z.string().min(1), + commit: z.string().min(7), + }), + policy: desktopPolicySchema, + desktop: z.object({ + renderer: desktopRendererSchema.nullable(), + app: z + .object({ + platforms: z + .object({ + macos: desktopAppPlatformSchema.optional(), + windows: desktopAppPlatformSchema.optional(), + linux: desktopAppPlatformSchema.optional(), + }) + .strict(), + }) + .nullable(), + }), + }) + .superRefine((release, ctx) => { + if (release.releaseKind === "ota" && release.runtimeVersion == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Desktop OTA releases require a runtimeVersion", + path: ["runtimeVersion"], + }) + } + + if (release.releaseKind === "ota" && release.desktop.renderer == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Desktop OTA releases require renderer payload", + path: ["desktop", "renderer"], + }) + } + }) + +const desktopReleaseSchema = desktopReleaseInputSchema.transform((release) => ({ + ...release, + releaseKind: release.releaseKind === "store" ? "binary" : release.releaseKind, + platforms: projectDesktopPlatforms(release.desktop.renderer), +})) + +export const otaReleaseSchema = z.union([mobileReleaseSchema, desktopReleaseSchema]) + export type OtaPlatform = "ios" | "android" | "macos" | "windows" | "linux" +export type OtaAsset = z.infer +export type OtaPlatformPayload = z.infer +export type OtaProjectedPlatforms = z.infer +export type MobileOtaRelease = z.infer +export type DesktopOtaRelease = z.infer export type OtaRelease = z.infer +export type DesktopDistribution = z.infer + +function projectDesktopPlatforms( + renderer: z.infer | null, +): OtaProjectedPlatforms { + if (!renderer) { + return {} + } + + return { + macos: { + launchAsset: renderer.launchAsset, + assets: renderer.assets, + }, + windows: { + launchAsset: renderer.launchAsset, + assets: renderer.assets, + }, + linux: { + launchAsset: renderer.launchAsset, + assets: renderer.assets, + }, + } +} diff --git a/apps/ota/src/lib/sync.ts b/apps/ota/src/lib/sync.ts index c823fa839..9d101a4f6 100644 --- a/apps/ota/src/lib/sync.ts +++ b/apps/ota/src/lib/sync.ts @@ -3,10 +3,10 @@ import type { MirroredFile } from "./archive" import { buildMirroredAssetKey, extractMirroredFiles } from "./archive" import { KV_KEYS } from "./constants" import { listPublishedOtaReleases } from "./github" -import type { LatestReleasePointerRecord } from "./kv" -import { putReleaseRecord } from "./kv" +import type { BinaryPolicyRecord, LatestReleasePointerRecord } from "./kv" +import { putBinaryPolicyRecord, putReleaseRecord } from "./kv" import { putMirroredFiles } from "./r2" -import type { OtaPlatform, OtaRelease } from "./schema" +import type { DesktopDistribution, OtaPlatform, OtaProjectedPlatforms, OtaRelease } from "./schema" import { otaReleaseSchema } from "./schema" import { compareSemver } from "./version" @@ -141,7 +141,8 @@ function hasCompleteMirroredPayload( platform: OtaPlatform, mirroredFileKeys: ReadonlySet, ) { - const platformPayload = release.platforms[platform] + const platforms = release.platforms as OtaProjectedPlatforms + const platformPayload = platforms[platform] if (!platformPayload) { return false @@ -157,6 +158,11 @@ function hasCompleteMirroredPayload( } async function putLatestPolicyRecord(kv: KVNamespace, release: OtaRelease) { + if (release.product === "desktop") { + await putDesktopPolicyRecords(kv, release) + return + } + const existingPolicyRecord = await kv.get( KV_KEYS.policy(release.product, release.channel), "json", @@ -169,6 +175,88 @@ async function putLatestPolicyRecord(kv: KVNamespace, release: OtaRelease) { await kv.put(KV_KEYS.policy(release.product, release.channel), JSON.stringify(release)) } +async function putDesktopPolicyRecords(kv: KVNamespace, release: OtaRelease) { + if (release.product !== "desktop" || release.releaseKind !== "binary") { + return + } + + const genericRecord: BinaryPolicyRecord = { + releaseVersion: release.releaseVersion, + required: release.policy.required, + minSupportedBinaryVersion: release.policy.minSupportedBinaryVersion, + message: release.policy.message, + publishedAt: release.publishedAt, + distribution: null, + downloadUrl: null, + storeUrl: null, + } + + const existingGenericRecord = await kv.get( + KV_KEYS.policy(release.product, release.channel), + "json", + ) + + if (shouldPersistReleaseVersion(existingGenericRecord?.releaseVersion, release.releaseVersion)) { + await putBinaryPolicyRecord(kv, { + product: release.product, + channel: release.channel, + value: genericRecord, + }) + } + + for (const distribution of Object.keys(release.policy.distributions) as DesktopDistribution[]) { + const value = release.policy.distributions[distribution] + if (!value) { + continue + } + const policyRecord: BinaryPolicyRecord = { + releaseVersion: release.releaseVersion, + required: release.policy.required, + minSupportedBinaryVersion: release.policy.minSupportedBinaryVersion, + message: release.policy.message, + publishedAt: release.publishedAt, + distribution, + downloadUrl: value.downloadUrl ?? null, + storeUrl: value.storeUrl ?? null, + } + + const existingRecord = await kv.get( + KV_KEYS.policy(release.product, release.channel, distribution), + "json", + ) + + if (!shouldPersistReleaseVersion(existingRecord?.releaseVersion, release.releaseVersion)) { + continue + } + + await putBinaryPolicyRecord(kv, { + product: release.product, + channel: release.channel, + distribution, + value: policyRecord, + }) + } + + const knownDistributions: DesktopDistribution[] = ["direct", "mas", "mss"] + + for (const distribution of knownDistributions) { + if (distribution in release.policy.distributions) { + continue + } + + const existingRecord = await kv.get( + KV_KEYS.policy(release.product, release.channel, distribution), + "json", + ) + + if (!shouldPersistReleaseVersion(existingRecord?.releaseVersion, release.releaseVersion)) { + continue + } + + await kv.delete(KV_KEYS.policy(release.product, release.channel, distribution)) + } +} + async function fetchReleaseMetadata( url: string, env: Pick, diff --git a/apps/ota/src/lib/version.ts b/apps/ota/src/lib/version.ts index 6bc5ffe0e..bfa372259 100644 --- a/apps/ota/src/lib/version.ts +++ b/apps/ota/src/lib/version.ts @@ -1,4 +1,4 @@ -import type { OtaPlatform, OtaRelease } from "./schema" +import type { OtaPlatform, OtaProjectedPlatforms, OtaRelease } from "./schema" export function compareSemver(left: string, right: string) { const leftParts = left.split(".").map(Number) @@ -18,8 +18,8 @@ export function selectLatestCompatibleRelease( releases: OtaRelease[], input: { product: OtaRelease["product"] - channel: OtaRelease["channel"] - runtimeVersion: OtaRelease["runtimeVersion"] + channel: string + runtimeVersion: string | null platform: OtaPlatform }, ) { @@ -29,7 +29,7 @@ export function selectLatestCompatibleRelease( .filter((release) => release.channel === input.channel) .filter((release) => release.releaseKind === "ota") .filter((release) => release.runtimeVersion === input.runtimeVersion) - .filter((release) => Boolean(release.platforms[input.platform])) + .filter((release) => Boolean((release.platforms as OtaProjectedPlatforms)[input.platform])) .sort((left, right) => compareSemver(right.releaseVersion, left.releaseVersion))[0] ?? null ) } diff --git a/apps/ota/src/routes/manifest.ts b/apps/ota/src/routes/manifest.ts index a1cf55793..2df8f35a7 100644 --- a/apps/ota/src/routes/manifest.ts +++ b/apps/ota/src/routes/manifest.ts @@ -4,13 +4,14 @@ import { z } from "zod" import type { Env } from "../env" import { createExpoSignatureHeader, OtaCodeSigningError } from "../lib/code-signing" import { KV_KEYS } from "../lib/constants" +import { buildDesktopManifestResponse, isDesktopRelease } from "../lib/desktop" import { getLatestReleasePointer } from "../lib/kv" import { buildManifest } from "../lib/manifest" -import type { OtaPlatform, OtaRelease } from "../lib/schema" +import { parseDesktopRequest } from "../lib/request" +import type { OtaPlatform, OtaProjectedPlatforms, OtaRelease } from "../lib/schema" import { otaReleaseSchema } from "../lib/schema" const OTA_PLATFORMS: readonly OtaPlatform[] = ["ios", "android", "macos", "windows", "linux"] -const OTA_PRODUCTS = ["mobile", "desktop"] as const const semverSchema = z.string().regex(/^\d+\.\d+\.\d+$/) const latestReleasePointerSchema = z.object({ releaseVersion: semverSchema, @@ -19,6 +20,88 @@ const latestReleasePointerSchema = z.object({ export const manifestRoute = new Hono<{ Bindings: Env }>() manifestRoute.get("/manifest", async (c) => { + const desktopRequest = parseDesktopRequest(c) + + if (desktopRequest.platformHeader) { + const { distribution } = desktopRequest + + if (!desktopRequest.platform || !distribution) { + return c.json({ error: "Invalid x-app-platform header" }, 400) + } + + if ( + !desktopRequest.runtimeVersion || + !semverSchema.safeParse(desktopRequest.runtimeVersion).success + ) { + return c.json({ error: "Invalid x-app-runtime-version header" }, 400) + } + + if (!desktopRequest.channel) { + return c.json({ error: "Missing x-app-channel header" }, 400) + } + + const pointerRecord = await getLatestReleasePointer(c.env.OTA_KV, { + product: "desktop", + channel: desktopRequest.channel, + runtimeVersion: desktopRequest.runtimeVersion, + platform: desktopRequest.platform, + }) + + if (!pointerRecord) { + return c.body(null, 204) + } + + const parsedPointer = latestReleasePointerSchema.safeParse(pointerRecord) + + if (!parsedPointer.success) { + return c.body(null, 204) + } + + const releaseRecord = await c.env.OTA_KV.get( + KV_KEYS.release("desktop", parsedPointer.data.releaseVersion), + "json", + ) + + if (!releaseRecord) { + return c.body(null, 204) + } + + const parsedRelease = otaReleaseSchema.safeParse(releaseRecord) + + if (!parsedRelease.success || !isDesktopRelease(parsedRelease.data)) { + return c.body(null, 204) + } + + const release = parsedRelease.data + + if ( + release.releaseKind !== "ota" || + release.channel !== desktopRequest.channel || + release.releaseVersion !== parsedPointer.data.releaseVersion || + release.runtimeVersion !== desktopRequest.runtimeVersion + ) { + return c.body(null, 204) + } + + const payload = buildDesktopManifestResponse(release, { + origin: new URL(c.req.url).origin, + platform: desktopRequest.platform, + distribution, + installedBinaryVersion: desktopRequest.installedBinaryVersion, + rendererVersion: desktopRequest.rendererVersion, + }) + + if (!payload) { + return c.body(null, 204) + } + + return c.json(payload, 200, { + "cache-control": "private, max-age=0", + "content-type": "application/json; charset=utf-8", + vary: "x-app-platform, x-app-version, x-app-runtime-version, x-app-renderer-version, x-app-channel", + }) + } + const platformHeader = c.req.header("expo-platform") const platform = parsePlatform(platformHeader) const runtimeVersion = c.req.header("expo-runtime-version") @@ -86,7 +169,7 @@ manifestRoute.get("/manifest", async (c) => { release.channel !== channel || release.releaseVersion !== pointer.releaseVersion || release.runtimeVersion !== runtimeVersion || - !release.platforms[platform] + !(release.platforms as OtaProjectedPlatforms)[platform] ) { return c.body(null, 204) } @@ -142,5 +225,5 @@ function parseProduct(value: string | undefined): OtaRelease["product"] | null { return "mobile" } - return OTA_PRODUCTS.find((product) => product === value) ?? null + return value === "mobile" ? "mobile" : null } diff --git a/apps/ota/src/routes/policy.ts b/apps/ota/src/routes/policy.ts index 3f85a64ca..ce2958f4d 100644 --- a/apps/ota/src/routes/policy.ts +++ b/apps/ota/src/routes/policy.ts @@ -3,7 +3,9 @@ import { z } from "zod" import type { Env } from "../env" import { KV_KEYS } from "../lib/constants" -import { evaluateStorePolicy } from "../lib/policy" +import { getBinaryPolicyRecord } from "../lib/kv" +import { evaluateBinaryPolicy, evaluateStorePolicy } from "../lib/policy" +import { parseDesktopRequest } from "../lib/request" import type { OtaRelease } from "../lib/schema" const storePolicyReleaseSchema = z.object({ @@ -17,12 +19,56 @@ const storePolicyReleaseSchema = z.object({ }), }) type StorePolicyRelease = z.infer -const OTA_PRODUCTS = ["mobile", "desktop"] as const const semverSchema = z.string().regex(/^\d+\.\d+\.\d+$/) export const policyRoute = new Hono<{ Bindings: Env }>() policyRoute.get("/policy", async (c) => { + const desktopRequest = parseDesktopRequest(c) + + if (desktopRequest.platformHeader) { + if (!desktopRequest.platform || !desktopRequest.distribution) { + return c.json({ error: "Invalid x-app-platform header" }, 400) + } + + if (!desktopRequest.installedBinaryVersion) { + return c.json({ error: "Missing x-app-version header" }, 400) + } + + if (!semverSchema.safeParse(desktopRequest.installedBinaryVersion).success) { + return c.json({ error: "Invalid x-app-version header" }, 400) + } + + if (!desktopRequest.channel) { + return c.json({ error: "Missing x-app-channel header" }, 400) + } + + const [targeted, generic] = await Promise.all([ + getBinaryPolicyRecord(c.env.OTA_KV, { + product: "desktop", + channel: desktopRequest.channel, + distribution: desktopRequest.distribution, + }), + getBinaryPolicyRecord(c.env.OTA_KV, { + product: "desktop", + channel: desktopRequest.channel, + }), + ]) + + return c.json( + evaluateBinaryPolicy( + { + targeted, + generic, + }, + { + installedBinaryVersion: desktopRequest.installedBinaryVersion, + distribution: desktopRequest.distribution, + }, + ), + ) + } + const product = parseProduct(c.req.query("product")) const channel = c.req.query("channel") ?? "production" const installedBinaryVersion = c.req.query("installedBinaryVersion") @@ -57,5 +103,5 @@ function parseProduct(value: string | undefined): OtaRelease["product"] | null { return "mobile" } - return OTA_PRODUCTS.find((product) => product === value) ?? null + return value === "mobile" ? "mobile" : null } diff --git a/docs/superpowers/plans/2026-04-11-desktop-ota-unification.md b/docs/superpowers/plans/2026-04-11-desktop-ota-unification.md new file mode 100644 index 000000000..995ab8d8e --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-desktop-ota-unification.md @@ -0,0 +1,1387 @@ +# Desktop OTA Unification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the new desktop client onto `apps/ota`, keep old desktop clients on `follow-server`, replace desktop OTA `mainHash` compatibility with explicit `runtimeVersion`, and align desktop release orchestration with the mobile `release-plan.json` and `release.json` workflow. + +**Architecture:** Desktop release intent becomes file-driven with `apps/desktop/release-plan.json` and `apps/desktop/release.json`, just like mobile. Desktop release automation produces a new `ota-release.json` schema that `apps/ota` can sync and index for renderer OTA payloads, direct installer payloads, and distribution-specific binary policies, while still publishing the legacy desktop YAML assets so old clients and `follow-server` continue working unchanged. The new desktop updater fetches `manifest` and `policy` from `apps/ota` with `X-App-*` headers, applies renderer OTA when available, falls back to direct full-app updates for `direct` builds, and uses `policy` for `direct`, `mas`, and `mss` binary upgrade guidance. + +**Tech Stack:** GitHub Actions, Node.js scripts, nbump, Electron Forge, electron-updater, Cloudflare Workers, Hono, KV, R2, Zod, Vitest, TypeScript + +--- + +## File Structure + +### New files + +- `apps/desktop/release-plan.json` + Default desktop release intent config for the next version bump. +- `apps/desktop/release.json` + Resolved desktop release config committed with the release PR. +- `apps/desktop/scripts/apply-release-config.impl.ts` + Desktop release-plan reader, validator, and `release.json` writer. +- `apps/desktop/scripts/apply-release-config.ts` + Small CLI wrapper for the desktop release-config writer. +- `apps/desktop/scripts/apply-release-config.test.ts` + Desktop release-config writer tests. +- `.github/scripts/resolve-desktop-release-config.mjs` + Reads `apps/desktop/release.json` and exposes workflow outputs for desktop release orchestration. +- `.github/scripts/resolve-desktop-release-config.test.ts` + Resolver tests for `build`, `ota`, and `binary-policy`. +- `apps/ota/src/lib/request.ts` + Shared desktop/mobile request parsing for route handlers. +- `apps/ota/src/lib/desktop.ts` + Desktop manifest and policy response builders. +- `apps/desktop/layer/main/src/updater/types.ts` + Local typed models for `apps/ota` desktop `manifest` and `policy` responses. +- `apps/desktop/layer/main/src/updater/api.test.ts` + Desktop OTA HTTP client tests for headers and response parsing. +- `apps/desktop/layer/main/src/updater/index.test.ts` + Desktop updater behavior tests for renderer/direct/policy decisions. + +### Modified files + +- `apps/desktop/bump.config.ts` + Run the new desktop release-config writer during version bumps and stage `release.json` / `release-plan.json`. +- `apps/desktop/package.json` + Add a desktop release-config script, persist `runtimeVersion`, and keep the legacy `update:main-hash` script that old desktop releases still need. +- `.github/workflows/tag.yml` + Resolve desktop release mode from `apps/desktop/release.json` and dispatch direct builds, store builds, and metadata publication accordingly. +- `.github/workflows/build-desktop.yml` + Read the resolved desktop release mode, generate desktop OTA metadata for `ota` mode, and upload `ota-release.json` plus `dist.tar.zst` with the existing desktop assets. +- `.github/workflows/publish-ota.yml` + Generalize the metadata publish workflow so it can handle desktop `binary-policy` and mobile releases with the same script entrypoint. +- `.github/scripts/build-ota-release.mjs` + Support both mobile and desktop metadata generation with desktop `build`, `ota`, and `binary-policy` modes. +- `.github/scripts/build-ota-release.test.ts` + Cover schema version 2 desktop metadata, direct installer payloads, and binary-policy-only output. +- `packages/internal/shared/src/env.common.ts` + Add a default OTA base URL for desktop. +- `packages/internal/shared/src/env.desktop.ts` + Expose `VITE_OTA_URL` to desktop main and renderer code. +- `apps/ota/src/lib/schema.ts` + Extend metadata schema to support desktop payloads, distribution-aware policy, and `binary` release kind while aliasing legacy `store`. +- `apps/ota/src/lib/constants.ts` + Add distribution-aware policy key builders. +- `apps/ota/src/lib/kv.ts` + Store and read distribution-aware policy records in addition to latest OTA pointers. +- `apps/ota/src/lib/sync.ts` + Persist desktop metadata, mirror desktop renderer archives, and write policy keys for `direct`, `mas`, and `mss`. +- `apps/ota/src/lib/manifest.ts` + Preserve mobile manifest generation and add desktop response assembly helpers. +- `apps/ota/src/lib/policy.ts` + Generalize store-policy evaluation into binary-policy evaluation with distribution-aware URLs. +- `apps/ota/src/routes/manifest.ts` + Continue serving mobile Expo manifests and add desktop `X-App-*` request handling. +- `apps/ota/src/routes/policy.ts` + Continue serving mobile policy responses and add desktop policy responses keyed by `X-App-Platform`. +- `apps/ota/src/__tests__/schema.test.ts` + Cover schema version 2 desktop metadata and legacy `store` alias parsing. +- `apps/ota/src/__tests__/sync.test.ts` + Cover desktop sync, direct installer metadata persistence, and distribution-specific policy keys. +- `apps/ota/src/__tests__/manifest.test.ts` + Cover desktop `manifest` response shapes for `direct`, `mas`, and `mss`. +- `apps/ota/src/__tests__/policy.test.ts` + Cover distribution-aware `none`, `prompt`, and `block`. +- `apps/ota/README.md` + Document the new desktop headers, desktop release modes, and distribution-aware binary policy behavior. +- `apps/desktop/layer/main/src/updater/api.ts` + Replace `follow-server` update API calls with direct `apps/ota` `manifest` and `policy` fetchers. +- `apps/desktop/layer/main/src/updater/follow-update-provider.ts` + Reuse the provider implementation for direct installer downloads, but feed it from the new desktop `app` payload instead of the old `follow-server` response. +- `apps/desktop/layer/main/src/updater/hot-updater.ts` + Remove `mainHash` gating from the new updater path and rely on server-side `runtimeVersion` compatibility. +- `apps/desktop/layer/main/src/updater/index.ts` + Swap `follow-server` decision handling for `apps/ota` `manifest + policy` handling. + +## Task 1: Add Desktop Release Config Files and Workflow Resolver + +**Files:** + +- Create: `apps/desktop/release-plan.json` +- Create: `apps/desktop/release.json` +- Create: `apps/desktop/scripts/apply-release-config.impl.ts` +- Create: `apps/desktop/scripts/apply-release-config.ts` +- Create: `apps/desktop/scripts/apply-release-config.test.ts` +- Create: `.github/scripts/resolve-desktop-release-config.mjs` +- Create: `.github/scripts/resolve-desktop-release-config.test.ts` +- Modify: `apps/desktop/bump.config.ts` +- Modify: `apps/desktop/package.json` +- Modify: `.github/workflows/tag.yml` + +- [ ] **Step 1: Write failing tests for desktop release config resolution** + +```ts +import { mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "pathe" +import { describe, expect, it } from "vitest" + +describe("applyDesktopReleaseConfig", () => { + it("writes release.json for ota mode and resets release-plan.json", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "desktop-release-config-")) + await writeFile( + join(projectDir, "package.json"), + `${JSON.stringify({ name: "Folo", version: "1.5.0" }, null, 2)}\n`, + "utf8", + ) + await writeFile( + join(projectDir, "release-plan.json"), + `${JSON.stringify( + { + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + null, + 2, + )}\n`, + "utf8", + ) + + const { applyReleaseConfig } = await import("./apply-release-config.impl.ts") + await applyReleaseConfig({ projectDir, version: "1.5.1" }) + + await expect(readFile(join(projectDir, "release.json"), "utf8")).resolves.toContain( + `"mode": "ota"`, + ) + await expect(readFile(join(projectDir, "release.json"), "utf8")).resolves.toContain( + `"runtimeVersion": "1.5.0"`, + ) + await expect(readFile(join(projectDir, "release-plan.json"), "utf8")).resolves.toContain( + `"mode": "build"`, + ) + await expect(readFile(join(projectDir, "package.json"), "utf8")).resolves.toContain( + `"runtimeVersion": "1.5.0"`, + ) + }) +}) +``` + +```ts +import { describe, expect, it } from "vitest" + +describe("resolveDesktopReleaseConfig", () => { + it("triggers direct and store builds for ota mode", async () => { + const { resolveDesktopReleaseConfig } = await import("./resolve-desktop-release-config.mjs") + + expect( + resolveDesktopReleaseConfig({ + releaseVersion: "v1.5.1", + releaseConfig: { + version: "1.5.1", + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + }), + ).toMatchObject({ + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + }) + }) +}) +``` + +- [ ] **Step 2: Run the new tests and verify they fail** + +Run: `pnpm exec vitest run apps/desktop/scripts/apply-release-config.test.ts .github/scripts/resolve-desktop-release-config.test.ts` + +Expected: FAIL because the desktop release-config files and resolver do not exist yet. + +- [ ] **Step 3: Implement desktop release-plan defaults and the apply-release-config writer** + +```json +{ + "mode": "build", + "runtimeVersion": null, + "channel": null, + "distributions": [], + "required": false, + "message": null +} +``` + +```ts +export interface DesktopReleasePlan { + mode: "build" | "ota" | "binary-policy" + runtimeVersion: string | null + channel: "stable" | "beta" | "alpha" | null + distributions: Array<"direct" | "mas" | "mss"> + required: boolean + message: string | null +} + +function validateDesktopReleasePlan(plan: DesktopReleasePlan) { + if (plan.mode === "build") { + if (plan.runtimeVersion !== null || plan.channel !== null) { + throw new Error("desktop build mode must not set runtimeVersion or channel") + } + return + } + + if (plan.mode === "ota") { + if (!plan.runtimeVersion?.match(/^\d+\.\d+\.\d+$/)) { + throw new Error("desktop ota mode requires a plain x.y.z runtimeVersion") + } + if (!plan.channel) { + throw new Error("desktop ota mode requires a channel") + } + return + } + + if (!plan.channel || plan.distributions.length === 0) { + throw new Error("desktop binary-policy mode requires channel and distributions") + } +} +``` + +```ts +const packageJsonPath = join(input.projectDir, "package.json") +const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) +packageJson.runtimeVersion = plan.mode === "ota" ? plan.runtimeVersion : input.version +await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8") + +await writeFile( + releaseConfigPath, + `${JSON.stringify({ version: input.version, ...plan }, null, 2)}\n`, + "utf8", +) +await writeFile(releasePlanPath, `${JSON.stringify(createDefaultReleasePlan(), null, 2)}\n`, "utf8") +``` + +- [ ] **Step 4: Implement the desktop release resolver and wire the version bump flow** + +```ts +export function resolveDesktopReleaseConfig(input) { + const normalizedReleaseVersion = input.releaseVersion.replace(/^v/, "") + const config = input.releaseConfig + + if (config.version !== normalizedReleaseVersion) { + throw new Error( + `apps/desktop/release.json version ${config.version} does not match release version ${normalizedReleaseVersion}.`, + ) + } + + if (config.mode === "build") { + return { + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: null, + runtimeVersion: null, + channel: null, + distributions: "", + required: "", + policyMessage: "", + } + } + + if (config.mode === "ota") { + return { + triggerDirectBuild: true, + triggerStoreBuilds: true, + triggerMetadataPublish: false, + releaseKind: "ota", + runtimeVersion: config.runtimeVersion, + channel: config.channel, + distributions: "direct", + required: "false", + policyMessage: "", + } + } + + return { + triggerDirectBuild: false, + triggerStoreBuilds: false, + triggerMetadataPublish: true, + releaseKind: "binary", + runtimeVersion: null, + channel: config.channel, + distributions: config.distributions.join(","), + required: String(config.required), + policyMessage: config.message ?? "", + } +} +``` + +```ts +trailing: [ + "tsx scripts/apply-release-config.ts ${NEW_VERSION}", + "git add package.json", + "git add release.json release-plan.json", + "git checkout -b release/desktop/${NEW_VERSION}", +] +``` + +```yaml +- name: Resolve Desktop Release Config + id: desktop_release_mode + if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' + env: + RELEASE_VERSION: ${{ needs.create_tag.outputs.version }} + RELEASE_CONFIG_PATH: apps/desktop/release.json + run: node .github/scripts/resolve-desktop-release-config.mjs + +- name: Trigger Desktop Tag Version Build + if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' && steps.desktop_release_mode.outputs.triggerDirectBuild == 'true' + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: "build-desktop.yml", + ref: "main", + inputs: { + tag_version: "true", + store: "false", + release_version: "${{ needs.create_tag.outputs.version }}" + } + }); +``` + +- [ ] **Step 5: Run the tests again and verify they pass** + +Run: `pnpm exec vitest run apps/desktop/scripts/apply-release-config.test.ts .github/scripts/resolve-desktop-release-config.test.ts` + +Expected: PASS with cases for `build`, `ota`, and `binary-policy`. + +- [ ] **Step 6: Commit the release-config work** + +Run: + +```bash +git add \ + apps/desktop/release-plan.json \ + apps/desktop/release.json \ + apps/desktop/scripts/apply-release-config.impl.ts \ + apps/desktop/scripts/apply-release-config.ts \ + apps/desktop/scripts/apply-release-config.test.ts \ + .github/scripts/resolve-desktop-release-config.mjs \ + .github/scripts/resolve-desktop-release-config.test.ts \ + apps/desktop/bump.config.ts \ + apps/desktop/package.json \ + .github/workflows/tag.yml +git commit -m "feat(release): add desktop release config flow" +``` + +## Task 2: Extend the Metadata Builder and Desktop Release Workflow + +**Files:** + +- Modify: `.github/scripts/build-ota-release.mjs` +- Modify: `.github/scripts/build-ota-release.test.ts` +- Modify: `.github/workflows/build-desktop.yml` +- Modify: `.github/workflows/publish-ota.yml` + +- [ ] **Step 1: Write failing tests for desktop OTA metadata generation** + +```ts +it("builds schemaVersion 2 desktop ota metadata with renderer and direct app payloads", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const projectDir = await mkdtemp(join(tmpdir(), "desktop-ota-build-")) + + await mkdir(join(projectDir, "dist"), { recursive: true }) + await mkdir(join(projectDir, "out", "make", "squirrel.windows", "x64"), { recursive: true }) + + await writeFile( + join(projectDir, "release.json"), + `${JSON.stringify( + { + version: "1.5.1", + mode: "ota", + runtimeVersion: "1.5.0", + channel: "stable", + distributions: ["direct"], + required: false, + message: null, + }, + null, + 2, + )}\n`, + ) + + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.schemaVersion).toBe(2) + expect(result.otaMetadata.releaseKind).toBe("ota") + expect(result.otaMetadata.desktop.renderer.version).toBe("1.5.1") + expect(result.otaMetadata.desktop.app.platforms.windows.files[0]?.downloadUrl).toContain( + "/desktop/v1.5.1/", + ) +}) +``` + +```ts +it("builds desktop binary-policy metadata without dist.tar.zst", async () => { + const { buildDesktopReleaseAssets } = await import("./build-ota-release.mjs") + const result = await buildDesktopReleaseAssets({ + projectDir, + owner: "RSSNext", + repo: "Folo", + }) + + expect(result.otaMetadata.releaseKind).toBe("binary") + expect(result.archivePath).toBeNull() +}) +``` + +- [ ] **Step 2: Run the metadata builder tests and verify they fail** + +Run: `pnpm exec vitest run .github/scripts/build-ota-release.test.ts` + +Expected: FAIL because the builder does not understand desktop release config or schema version 2 yet. + +- [ ] **Step 3: Implement a desktop branch in `build-ota-release.mjs`** + +```js +function buildGitHubAssetUrl({ owner, repo, tag, filename }) { + return `https://github.com/${owner}/${repo}/releases/download/${tag}/${filename}` +} + +async function buildDesktopReleaseAssets({ projectDir, owner, repo }) { + const releaseConfig = await readJson(join(projectDir, "release.json")) + const packageJson = await readJson(join(projectDir, "package.json")) + const releaseVersion = releaseConfig.version + const tag = `desktop/v${releaseVersion}` + + if (releaseConfig.mode === "binary-policy") { + return { + otaMetadata: { + schemaVersion: 2, + product: "desktop", + releaseVersion, + releaseKind: "binary", + runtimeVersion: null, + channel: releaseConfig.channel, + publishedAt: new Date().toISOString(), + git: { + tag, + commit: execGit(["rev-parse", "HEAD"], REPO_ROOT), + }, + policy: { + required: releaseConfig.required, + minSupportedBinaryVersion: packageJson.version, + message: releaseConfig.message, + distributions: Object.fromEntries( + releaseConfig.distributions.map((distribution) => [distribution, {}]), + ), + }, + desktop: { + renderer: null, + app: null, + }, + }, + archivePath: null, + } + } +} +``` + +```js +const latestYaml = yaml.load( + await readFile(join(projectDir, "out", "make", "squirrel.windows", "x64", "latest.yml"), "utf8"), +) +const desktopMetadata = { + schemaVersion: 2, + product: "desktop", + releaseVersion, + releaseKind: releaseConfig.mode === "ota" ? "ota" : "binary", + runtimeVersion: releaseConfig.mode === "ota" ? releaseConfig.runtimeVersion : null, + channel: releaseConfig.mode === "ota" ? releaseConfig.channel : null, + desktop: { + renderer: + releaseConfig.mode === "ota" + ? { + version: releaseVersion, + commit: execGit(["rev-parse", "HEAD"], REPO_ROOT), + launchAsset: await resolveAsset({ path: "renderer/render-asset.tar.gz" }), + assets: [], + } + : null, + app: { + platforms: { + windows: { + platform: "windows-x64", + releaseDate: latestYaml.releaseDate, + manifest: { + name: "latest.yml", + downloadUrl: buildGitHubAssetUrl({ owner, repo, tag, filename: "latest.yml" }), + }, + files: latestYaml.files.map((file) => ({ + filename: file.url, + sha512: file.sha512, + size: file.size, + downloadUrl: buildGitHubAssetUrl({ owner, repo, tag, filename: file.url }), + })), + }, + }, + }, + }, +} +``` + +- [ ] **Step 4: Generate metadata inside the desktop release workflow** + +```yaml +on: + workflow_dispatch: + inputs: + release_version: + type: string + description: "Resolved desktop version from release.json" +``` + +```yaml +- name: Resolve desktop release config + if: env.RELEASE == 'true' + id: desktop_release_mode + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + RELEASE_CONFIG_PATH: apps/desktop/release.json + run: node .github/scripts/resolve-desktop-release-config.mjs +``` + +```yaml +- name: Build desktop OTA metadata + if: runner.os == 'Linux' && env.RELEASE == 'true' && steps.desktop_release_mode.outputs.release_kind == 'ota' + working-directory: apps/desktop + env: + OTA_PRODUCT: desktop + OTA_RELEASE_KIND: ota + OTA_RUNTIME_VERSION: ${{ steps.desktop_release_mode.outputs.runtime_version }} + OTA_CHANNEL: ${{ steps.desktop_release_mode.outputs.channel }} + GITHUB_OWNER: ${{ github.repository_owner }} + GITHUB_REPO: ${{ github.event.repository.name }} + run: pnpm exec node ../../.github/scripts/build-ota-release.mjs +``` + +```yaml +files: | + apps/desktop/out/make/**/Folo-*.dmg + apps/desktop/out/make/**/Folo-*.zip + apps/desktop/out/make/**/Folo-*.exe + apps/desktop/out/make/**/Folo-*.AppImage + apps/desktop/out/make/**/*.yml + apps/desktop/dist/manifest.yml + apps/desktop/dist/*.tar.gz + apps/desktop/dist/ota-release.json + apps/desktop/dist.tar.zst +``` + +```yaml +product: + type: choice + required: true + options: + - mobile + - desktop +``` + +- [ ] **Step 5: Re-run the builder tests and check the updated workflows** + +Run: `pnpm exec vitest run .github/scripts/build-ota-release.test.ts` + +Expected: PASS with desktop `build`, `ota`, and `binary-policy` coverage. + +Run: `pnpm exec prettier --check .github/workflows/build-desktop.yml .github/workflows/publish-ota.yml .github/scripts/build-ota-release.mjs` + +Expected: PASS with no formatting changes required. + +- [ ] **Step 6: Commit the metadata builder and workflow changes** + +Run: + +```bash +git add \ + .github/scripts/build-ota-release.mjs \ + .github/scripts/build-ota-release.test.ts \ + .github/workflows/build-desktop.yml \ + .github/workflows/publish-ota.yml +git commit -m "feat(ota): add desktop metadata publishing" +``` + +## Task 3: Extend `apps/ota` Schema, Storage, and Sync for Desktop + +**Files:** + +- Modify: `apps/ota/src/lib/schema.ts` +- Modify: `apps/ota/src/lib/constants.ts` +- Modify: `apps/ota/src/lib/kv.ts` +- Modify: `apps/ota/src/lib/policy.ts` +- Modify: `apps/ota/src/lib/sync.ts` +- Modify: `apps/ota/src/__tests__/schema.test.ts` +- Modify: `apps/ota/src/__tests__/policy.test.ts` +- Modify: `apps/ota/src/__tests__/sync.test.ts` + +- [ ] **Step 1: Write failing worker tests for desktop schema and distribution-aware policy keys** + +```ts +it("accepts desktop schemaVersion 2 ota metadata and legacy store aliases", () => { + const parsed = otaReleaseSchema.parse({ + schemaVersion: 2, + product: "desktop", + channel: "stable", + releaseVersion: "1.5.1", + releaseKind: "binary", + runtimeVersion: null, + publishedAt: "2026-04-11T10:00:00Z", + git: { + tag: "desktop/v1.5.1", + commit: "abcdef1234567890", + }, + policy: { + required: true, + minSupportedBinaryVersion: "1.5.0", + message: "Install the latest desktop app.", + distributions: { + mas: { + storeUrl: "https://apps.apple.com/app/id123456789", + }, + }, + }, + desktop: { + renderer: null, + app: null, + }, + }) + + expect(parsed.releaseKind).toBe("binary") + expect(parsed.policy.distributions.mas.storeUrl).toContain("apps.apple.com") +}) +``` + +```ts +it("writes distribution-aware policy keys for desktop binary metadata", async () => { + await syncGitHubReleases(env) + + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable", "mas"))).toBeDefined() + expect(kvEntries.get(KV_KEYS.policy("desktop", "stable", "mss"))).toBeUndefined() +}) +``` + +- [ ] **Step 2: Run the worker tests and verify they fail** + +Run: `pnpm --filter @follow/ota exec vitest run src/__tests__/schema.test.ts src/__tests__/policy.test.ts src/__tests__/sync.test.ts` + +Expected: FAIL because the schema only supports mobile shape and policy keys are not distribution-aware. + +- [ ] **Step 3: Extend the OTA metadata schema and KV key model** + +```ts +const binaryKindSchema = z.enum(["ota", "binary", "store"]) + +const desktopDistributionSchema = z.enum(["direct", "mas", "mss"]) + +const desktopPolicyDistributionSchema = z.object({ + downloadUrl: z.string().url().optional(), + storeUrl: z.string().url().optional(), +}) + +const desktopReleaseSchema = z.object({ + schemaVersion: z.literal(2), + product: z.literal("desktop"), + channel: z.enum(["stable", "beta", "alpha"]), + releaseVersion: semver, + releaseKind: binaryKindSchema.transform((value) => (value === "store" ? "binary" : value)), + runtimeVersion: semver.nullable(), + publishedAt: z.string().datetime(), + git: z.object({ + tag: z.string().min(1), + commit: z.string().min(7), + }), + policy: z.object({ + required: z.boolean(), + minSupportedBinaryVersion: semver, + message: z.string().nullable(), + distributions: z.record(desktopDistributionSchema, desktopPolicyDistributionSchema).default({}), + }), + desktop: z.object({ + renderer: z + .object({ + version: semver, + commit: z.string().min(7), + launchAsset: assetSchema, + assets: z.array(assetSchema), + }) + .nullable(), + app: z + .object({ + platforms: z.record( + z.enum(["macos", "windows", "linux"]), + z.object({ + platform: z.string().min(1), + releaseDate: z.string().datetime().nullable(), + manifest: z.object({ + name: z.string().min(1), + downloadUrl: z.string().url(), + }), + files: z.array( + z.object({ + filename: z.string().min(1), + sha512: z.string().min(1), + size: z.number().int().nonnegative(), + downloadUrl: z.string().url(), + }), + ), + }), + ), + }) + .nullable(), + }), +}) + +export const otaReleaseSchema = z.union([mobileReleaseSchema, desktopReleaseSchema]) +``` + +```ts +policy: ( + product: OtaRelease["product"], + channel: string, + distribution?: "direct" | "mas" | "mss", +) => distribution ? `policy:${product}:${channel}:${distribution}` : `policy:${product}:${channel}`, +``` + +- [ ] **Step 4: Persist desktop binary policy records and only mirror renderer OTA archives** + +```ts +if (release.product === "desktop" && release.releaseKind === "binary") { + await putReleaseRecord(env.OTA_KV, release.product, release.releaseVersion, release) + + for (const [distribution, value] of Object.entries(release.policy.distributions)) { + await env.OTA_KV.put( + KV_KEYS.policy(release.product, release.channel, distribution), + JSON.stringify({ + releaseVersion: release.releaseVersion, + required: release.policy.required, + minSupportedBinaryVersion: release.policy.minSupportedBinaryVersion, + message: release.policy.message, + publishedAt: release.publishedAt, + distribution, + ...value, + }), + ) + } + + continue +} +``` + +```ts +function hasCompleteMirroredPayload( + release: OtaRelease, + platform: OtaPlatform, + mirroredFileKeys: ReadonlySet, +) { + if (release.product === "desktop") { + const renderer = release.desktop?.renderer + if (!renderer || platform === "ios" || platform === "android") { + return false + } + + return mirroredFileKeys.has(buildMirroredAssetKey(release, platform, renderer.launchAsset.path)) + } + + // existing mobile branch +} +``` + +- [ ] **Step 5: Run the worker tests again and verify they pass** + +Run: `pnpm --filter @follow/ota exec vitest run src/__tests__/schema.test.ts src/__tests__/policy.test.ts src/__tests__/sync.test.ts` + +Expected: PASS with desktop schema, alias, and distribution-aware policy coverage. + +- [ ] **Step 6: Commit the schema and sync changes** + +Run: + +```bash +git add \ + apps/ota/src/lib/schema.ts \ + apps/ota/src/lib/constants.ts \ + apps/ota/src/lib/kv.ts \ + apps/ota/src/lib/policy.ts \ + apps/ota/src/lib/sync.ts \ + apps/ota/src/__tests__/schema.test.ts \ + apps/ota/src/__tests__/policy.test.ts \ + apps/ota/src/__tests__/sync.test.ts +git commit -m "feat(ota): add desktop metadata sync and policy storage" +``` + +## Task 4: Add Desktop `manifest` and `policy` Routes to `apps/ota` + +**Files:** + +- Create: `apps/ota/src/lib/request.ts` +- Create: `apps/ota/src/lib/desktop.ts` +- Modify: `apps/ota/src/lib/manifest.ts` +- Modify: `apps/ota/src/routes/manifest.ts` +- Modify: `apps/ota/src/routes/policy.ts` +- Modify: `apps/ota/src/__tests__/manifest.test.ts` +- Modify: `apps/ota/src/__tests__/policy.test.ts` + +- [ ] **Step 1: Write failing route tests for desktop `direct`, `mas`, and `mss`** + +```ts +it("returns renderer and app payloads for desktop direct builds", async () => { + const response = await fetchWorker( + "/manifest", + { + headers: { + "x-app-platform": "desktop/windows/exe", + "x-app-version": "1.5.0", + "x-app-runtime-version": "1.5.0", + "x-app-renderer-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [KV_KEYS.latest("desktop", "stable", "1.5.0", "windows"), { releaseVersion: "1.5.1" }], + [KV_KEYS.release("desktop", "1.5.1"), createDesktopOtaRelease()], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + product: "desktop", + renderer: { + version: "1.5.1", + }, + app: { + version: "1.5.1", + platform: "windows-x64", + }, + }) +}) +``` + +```ts +it("returns only renderer for desktop store distributions", async () => { + const response = await fetchWorker( + "/manifest", + { + headers: { + "x-app-platform": "desktop/macos/mas", + "x-app-version": "1.5.0", + "x-app-runtime-version": "1.5.0", + "x-app-renderer-version": "1.5.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [KV_KEYS.latest("desktop", "stable", "1.5.0", "macos"), { releaseVersion: "1.5.1" }], + [KV_KEYS.release("desktop", "1.5.1"), createDesktopOtaRelease()], + ]), + }, + ) + + await expect(response.json()).resolves.toMatchObject({ + renderer: { + version: "1.5.1", + }, + app: null, + }) +}) +``` + +```ts +it("returns distribution-specific desktop policy and falls back to generic policy", () => { + const policy = evaluateBinaryPolicy( + { + installedBinaryVersion: "1.5.0", + distribution: "mas", + }, + { + targeted: { + targetVersion: "1.5.1", + required: true, + storeUrl: "https://apps.apple.com/app/id123456789", + }, + generic: null, + }, + ) + + expect(policy).toEqual({ + action: "block", + targetVersion: "1.5.1", + distribution: "mas", + storeUrl: "https://apps.apple.com/app/id123456789", + downloadUrl: null, + message: null, + publishedAt: null, + }) +}) +``` + +- [ ] **Step 2: Run the route tests and verify they fail** + +Run: `pnpm --filter @follow/ota exec vitest run src/__tests__/manifest.test.ts src/__tests__/policy.test.ts` + +Expected: FAIL because the routes only parse Expo headers and return mobile-only responses. + +- [ ] **Step 3: Parse `X-App-*` headers into desktop request context** + +```ts +export function parseDesktopRequest(c: Context<{ Bindings: Env }>) { + const platformHeader = c.req.header("x-app-platform") + const version = c.req.header("x-app-version") + const runtimeVersion = c.req.header("x-app-runtime-version") ?? version + const rendererVersion = c.req.header("x-app-renderer-version") + const channel = c.req.header("x-app-channel") + + const mapping = { + "desktop/macos/dmg": { platform: "macos", distribution: "direct" }, + "desktop/macos/mas": { platform: "macos", distribution: "mas" }, + "desktop/windows/exe": { platform: "windows", distribution: "direct" }, + "desktop/windows/ms": { platform: "windows", distribution: "mss" }, + "desktop/linux": { platform: "linux", distribution: "direct" }, + }[platformHeader ?? ""] + + return { + product: "desktop", + platform: mapping?.platform ?? null, + distribution: mapping?.distribution ?? null, + installedBinaryVersion: version ?? null, + runtimeVersion: runtimeVersion ?? null, + rendererVersion: rendererVersion ?? null, + channel: channel ?? null, + } +} +``` + +- [ ] **Step 4: Build desktop `manifest` and `policy` responses** + +```ts +export function buildDesktopManifest( + release: DesktopOtaRelease, + input: { + platform: "macos" | "windows" | "linux" + distribution: "direct" | "mas" | "mss" + installedBinaryVersion: string | null + rendererVersion: string | null + origin: string + }, +) { + const renderer = + compareSemver(release.desktop.renderer.version, input.rendererVersion ?? "0.0.0") > 0 + ? { + releaseVersion: release.releaseVersion, + version: release.desktop.renderer.version, + commit: release.desktop.renderer.commit, + launchAsset: toManifestAsset( + release, + input.origin, + input.platform, + release.desktop.renderer.launchAsset, + ), + assets: release.desktop.renderer.assets.map((asset) => + toManifestAsset(release, input.origin, input.platform, asset), + ), + } + : null + + const app = + input.distribution === "direct" && release.desktop.app?.platforms[input.platform] + ? release.desktop.app.platforms[input.platform] + : null + + return renderer || app + ? { + id: release.updateId ?? crypto.randomUUID(), + createdAt: release.publishedAt, + product: "desktop", + channel: release.channel, + runtimeVersion: release.runtimeVersion, + renderer, + app, + } + : null +} +``` + +```ts +const desktopPolicy = evaluateBinaryPolicy( + { + installedBinaryVersion, + distribution, + }, + { + targeted: distribution + ? await getPolicyRecord(c.env.OTA_KV, product, channel, distribution) + : null, + generic: await getPolicyRecord(c.env.OTA_KV, product, channel), + }, +) +``` + +- [ ] **Step 5: Re-run the route tests and verify they pass** + +Run: `pnpm --filter @follow/ota exec vitest run src/__tests__/manifest.test.ts src/__tests__/policy.test.ts` + +Expected: PASS for desktop `direct`, `mas`, `mss`, and existing mobile coverage. + +- [ ] **Step 6: Commit the route and request-parsing work** + +Run: + +```bash +git add \ + apps/ota/src/lib/request.ts \ + apps/ota/src/lib/desktop.ts \ + apps/ota/src/lib/manifest.ts \ + apps/ota/src/routes/manifest.ts \ + apps/ota/src/routes/policy.ts \ + apps/ota/src/__tests__/manifest.test.ts \ + apps/ota/src/__tests__/policy.test.ts +git commit -m "feat(ota): add desktop manifest and policy routes" +``` + +## Task 5: Move the New Desktop Updater to `apps/ota` + +**Files:** + +- Create: `apps/desktop/layer/main/src/updater/types.ts` +- Create: `apps/desktop/layer/main/src/updater/api.test.ts` +- Create: `apps/desktop/layer/main/src/updater/index.test.ts` +- Modify: `packages/internal/shared/src/env.common.ts` +- Modify: `packages/internal/shared/src/env.desktop.ts` +- Modify: `apps/desktop/layer/main/src/updater/api.ts` +- Modify: `apps/desktop/layer/main/src/updater/follow-update-provider.ts` +- Modify: `apps/desktop/layer/main/src/updater/hot-updater.ts` +- Modify: `apps/desktop/layer/main/src/updater/index.ts` + +- [ ] **Step 1: Write failing tests for the desktop OTA API client and decision flow** + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest" + +describe("fetchDesktopManifest", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: "test", + createdAt: "2026-04-11T10:00:00.000Z", + product: "desktop", + channel: "stable", + runtimeVersion: "1.5.0", + renderer: null, + app: { + releaseVersion: "1.5.1", + version: "1.5.1", + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00.000Z", + manifest: { + name: "latest.yml", + downloadUrl: "https://ota.folo.is/latest.yml", + }, + files: [ + { + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "base64sha512", + size: 123, + downloadUrl: "https://ota.folo.is/Folo-1.5.1-windows-x64.exe", + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ) + }) + + it("sends only X-App headers to the OTA endpoint", async () => { + const { fetchDesktopManifest } = await import("./api") + await fetchDesktopManifest() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/manifest"), + expect.objectContaining({ + headers: expect.objectContaining({ + "X-App-Platform": expect.any(String), + "X-App-Version": expect.any(String), + "X-App-Channel": expect.any(String), + }), + }), + ) + }) +}) +``` + +```ts +it("prefers renderer OTA over direct full-app updates", async () => { + const result = await updater.handleManifestDecision({ + renderer: { + version: "1.5.1", + releaseVersion: "1.5.1", + commit: "abcdef123456", + launchAsset: { + url: "https://ota.folo.is/assets/renderer.tar.gz", + hash: "rendererhash", + key: "render-asset", + contentType: "application/gzip", + }, + assets: [], + }, + app: { + version: "1.5.1", + releaseVersion: "1.5.1", + platform: "windows-x64", + releaseDate: "2026-04-11T10:00:00.000Z", + manifest: { + name: "latest.yml", + downloadUrl: "https://ota.folo.is/latest.yml", + }, + files: [ + { + filename: "Folo-1.5.1-windows-x64.exe", + sha512: "base64sha512", + size: 123, + downloadUrl: "https://ota.folo.is/Folo-1.5.1-windows-x64.exe", + }, + ], + }, + }) + + expect(result).toEqual({ hasUpdate: true, kind: "renderer" }) +}) +``` + +- [ ] **Step 2: Run the desktop updater tests and verify they fail** + +Run: `pnpm --dir apps/desktop/layer/main exec vitest run src/updater/api.test.ts src/updater/index.test.ts` + +Expected: FAIL because the updater still fetches `follow-server` SDK routes and expects old `LatestReleasePayload`. + +- [ ] **Step 3: Add a dedicated OTA base URL and local updater response types** + +```ts +VITE_OTA_URL: z.string().url().default("https://ota.folo.is"), +``` + +```ts +export interface DesktopManifestResponse { + id: string + createdAt: string + product: "desktop" + channel: "stable" | "beta" | "alpha" + runtimeVersion: string + renderer: DesktopRendererPayload | null + app: DesktopAppPayload | null +} + +export interface DesktopPolicyResponse { + action: "none" | "prompt" | "block" + targetVersion: string | null + message: string | null + distribution: "direct" | "mas" | "mss" | null + downloadUrl: string | null + storeUrl: string | null + publishedAt: string | null +} +``` + +- [ ] **Step 4: Replace `follow-server` updater calls with `apps/ota` fetchers** + +```ts +export const fetchDesktopManifest = async (): Promise => { + const response = await fetch(new URL("/manifest", env.VITE_OTA_URL), { + headers: { + ...createDesktopAPIHeaders({ version: appVersion }), + "X-App-Channel": channel, + "X-App-Runtime-Version": appRuntimeVersion, + "X-App-Renderer-Version": rendererVersion, + }, + cache: "no-store", + }) + + if (response.status === 204) { + return null + } + + return desktopManifestSchema.parse(await response.json()) +} + +export const fetchDesktopPolicy = async (): Promise => { + const response = await fetch(new URL("/policy", env.VITE_OTA_URL), { + headers: { + ...createDesktopAPIHeaders({ version: appVersion }), + "X-App-Channel": channel, + }, + cache: "no-store", + }) + + return desktopPolicySchema.parse(await response.json()) +} +``` + +```ts +evaluateManifest(manifest: RendererManifest | null): RendererEligibilityResult { + if (!manifest) { + return { status: RendererEligibilityStatus.NoManifest } + } + + if (manifest.version === appVersion) { + return { + status: RendererEligibilityStatus.AlreadyCurrent, + reason: "Renderer version matches current app version", + } + } + + // keep commit and installed-manifest checks, but remove mainHash checks +} +``` + +```ts +const manifest = await fetchDesktopManifest() +const policy = await fetchDesktopPolicy() + +if (manifest?.renderer) { + return this.handleRendererPayload(manifest.renderer, manifest.app) +} + +if (manifest?.app) { + return this.handleDirectAppPayload(manifest.app) +} + +if (policy.action !== "none") { + return this.notifyBinaryPolicy(policy) +} +``` + +- [ ] **Step 5: Re-run updater tests and typecheck** + +Run: `pnpm --dir apps/desktop/layer/main exec vitest run src/updater/api.test.ts src/updater/index.test.ts` + +Expected: PASS for renderer-first behavior, direct-installer fallback, and policy handling. + +Run: `pnpm --dir apps/desktop/layer/main typecheck` + +Expected: PASS with no `@follow-app/client-sdk` update-response dependency left in updater files. + +- [ ] **Step 6: Commit the desktop updater migration** + +Run: + +```bash +git add \ + packages/internal/shared/src/env.common.ts \ + packages/internal/shared/src/env.desktop.ts \ + apps/desktop/layer/main/src/updater/types.ts \ + apps/desktop/layer/main/src/updater/api.ts \ + apps/desktop/layer/main/src/updater/api.test.ts \ + apps/desktop/layer/main/src/updater/index.ts \ + apps/desktop/layer/main/src/updater/index.test.ts \ + apps/desktop/layer/main/src/updater/follow-update-provider.ts \ + apps/desktop/layer/main/src/updater/hot-updater.ts +git commit -m "feat(desktop): switch updater to ota service" +``` + +## Task 6: Update Runbooks and Run Full Verification + +**Files:** + +- Modify: `apps/ota/README.md` + +- [ ] **Step 1: Document desktop headers, release modes, and distribution-aware policy behavior** + +```md +Desktop requests use the existing `X-App-*` headers: + +- `X-App-Platform` +- `X-App-Version` +- `X-App-Channel` +- optional `X-App-Runtime-Version` +- optional `X-App-Renderer-Version` + +Desktop release modes: + +- `build`: direct installers only +- `ota`: renderer OTA plus direct installers +- `binary-policy`: distribution-specific policy metadata only +``` + +- [ ] **Step 2: Run focused verification before the full repo checks** + +Run: + +```bash +pnpm exec vitest run \ + apps/desktop/scripts/apply-release-config.test.ts \ + .github/scripts/resolve-desktop-release-config.test.ts \ + .github/scripts/build-ota-release.test.ts +pnpm --filter @follow/ota test +pnpm --dir apps/desktop/layer/main exec vitest run src/updater/api.test.ts src/updater/index.test.ts +pnpm --filter @follow/ota typecheck +pnpm --dir apps/desktop/layer/main typecheck +``` + +Expected: PASS for release config, metadata generation, worker routing, and updater behavior. + +- [ ] **Step 3: Run repository quality gates in the required order** + +Run: + +```bash +pnpm run typecheck +pnpm run lint:fix +pnpm run test +npm exec turbo run format:check typecheck lint +npm exec turbo run test +``` + +Expected: PASS across the monorepo with no new lint, type, or test failures. + +- [ ] **Step 4: Commit docs and verification updates** + +Run: + +```bash +git add apps/ota/README.md +git commit -m "docs(ota): document desktop update flow" +``` + +## Self-Review + +### Spec Coverage + +- Release config workflow in the spec is covered by Task 1. +- Unified metadata publication and file-driven release execution are covered by Task 2. +- Desktop schema, `binary` release kind, and distribution-aware policy persistence are covered by Task 3. +- Desktop `manifest` and `policy` routes with `X-App-*` headers are covered by Task 4. +- New desktop client migration onto `apps/ota` is covered by Task 5. +- README and required verification commands are covered by Task 6. + +### Placeholder Scan + +- No `TODO`, `TBD`, or “similar to previous task” placeholders remain. +- Every task lists concrete files, commands, and example code to implement. + +### Type Consistency + +- `runtimeVersion` is the desktop OTA compatibility key everywhere in Tasks 2 through 5. +- `installedBinaryVersion` stays tied to `X-App-Version` and `/policy`. +- `direct`, `mas`, and `mss` are the only desktop distribution values used throughout the plan. +- Legacy `store` metadata is only referenced as an alias handled in Worker schema parsing, not as a new canonical type. diff --git a/packages/internal/shared/src/env.common.ts b/packages/internal/shared/src/env.common.ts index c58e25be3..d143b98b9 100644 --- a/packages/internal/shared/src/env.common.ts +++ b/packages/internal/shared/src/env.common.ts @@ -11,6 +11,7 @@ const FIREBASE_CONFIG_DEFAULT = JSON.stringify({ export const DEFAULT_VALUES = { PROD: { API_URL: "https://api.folo.is", + OTA_URL: "https://ota.folo.is", WEB_URL: "https://app.folo.is", INBOXES_EMAIL: "@follow.re", FIREBASE_CONFIG: FIREBASE_CONFIG_DEFAULT, @@ -21,11 +22,13 @@ export const DEFAULT_VALUES = { }, DEV: { API_URL: "https://api.dev.follow.is", + OTA_URL: "https://ota.folo.is", WEB_URL: "https://dev.follow.is", INBOXES_EMAIL: "__dev@follow.re", }, STAGING: { API_URL: "https://api.folo.is", + OTA_URL: "https://ota.folo.is", WEB_URL: "https://staging.follow.is", INBOXES_EMAIL: "@follow.re", POSTHOG_KEY: "phc_EZGEvBt830JgBHTiwpHqJAEbWnbv63m5UpreojwEWNL", @@ -33,6 +36,7 @@ export const DEFAULT_VALUES = { }, LOCAL: { API_URL: "http://localhost:3000", + OTA_URL: "http://localhost:8787", WEB_URL: "http://localhost:2233", INBOXES_EMAIL: "@follow.re", }, diff --git a/packages/internal/shared/src/env.desktop.ts b/packages/internal/shared/src/env.desktop.ts index 1e14beb10..7f357f2ce 100644 --- a/packages/internal/shared/src/env.desktop.ts +++ b/packages/internal/shared/src/env.desktop.ts @@ -11,6 +11,7 @@ export const env = createEnv({ client: { VITE_WEB_URL: z.string().url().default(DEFAULT_VALUES.PROD.WEB_URL), VITE_API_URL: z.string().default(DEFAULT_VALUES.PROD.API_URL), + VITE_OTA_URL: z.string().url().default(DEFAULT_VALUES.PROD.OTA_URL), VITE_DEV_PROXY: z.string().optional(), VITE_SENTRY_DSN: z.string().optional(), VITE_INBOXES_EMAIL: z.string().default(DEFAULT_VALUES.PROD.INBOXES_EMAIL),