Skip to content
Merged
43 changes: 42 additions & 1 deletion packages/desktop-electron/scripts/mirror-release-to-r2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { readFileSync } from "node:fs"
import { join } from "node:path"

import { releaseAssetNames } from "./verify-release.ts"
import { buildManifest, uploadPlan } from "./mirror-release-to-r2.ts"
import {
buildManifest,
missingPointerReferences,
pointerReferencedAssets,
uploadPlan,
} from "./mirror-release-to-r2.ts"

describe("buildManifest", () => {
test("locks the manifest shape and per-platform installer URLs", () => {
Expand Down Expand Up @@ -56,6 +61,42 @@ describe("uploadPlan", () => {
})
})

describe("pointer reference alignment", () => {
const latestMacYml = [
"version: 2026.5.29",
"files:",
" - url: pawwork-mac-arm64-2026.5.29.zip",
" sha512: abc",
" size: 123",
" - url: pawwork-mac-x64-2026.5.29.zip",
" sha512: def",
" size: 456",
"path: pawwork-mac-arm64-2026.5.29.zip",
"sha512: abc",
"",
].join("\n")

test("collects the deduped asset names a pointer references", () => {
expect(pointerReferencedAssets(latestMacYml)).toEqual([
"pawwork-mac-arm64-2026.5.29.zip",
"pawwork-mac-x64-2026.5.29.zip",
])
})

test("reduces a full download URL to its asset name", () => {
const yml = "files:\n - url: https://example.com/d/pawwork-win-x64-2026.5.29.exe\npath: pawwork-win-x64-2026.5.29.exe\n"
expect(pointerReferencedAssets(yml)).toEqual(["pawwork-win-x64-2026.5.29.exe"])
})

test("flags references the mirror would not upload", () => {
const mirrored = new Set(releaseAssetNames("2026.5.29"))
expect(missingPointerReferences(pointerReferencedAssets(latestMacYml), mirrored)).toEqual([])
expect(missingPointerReferences(["pawwork-mac-arm64-9.9.9.zip"], mirrored)).toEqual([
"pawwork-mac-arm64-9.9.9.zip",
])
})
})

describe("mirror workflow shell-injection guard", () => {
const workflow = readFileSync(
join(import.meta.dir, "..", "..", "..", ".github", "workflows", "mirror-release-to-r2.yml"),
Expand Down
26 changes: 25 additions & 1 deletion packages/desktop-electron/scripts/mirror-release-to-r2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
import { mkdtemp, readdir, rm, stat } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { releaseAssetNames } from "./verify-release.ts"
import { parseUpdaterFileUrls, releaseAssetNames } from "./verify-release.ts"

const POINTER_YMLS = ["latest.yml", "latest-mac.yml"] as const

const MUTABLE_POINTERS = new Set(["latest.yml", "latest-mac.yml"])
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"
Expand Down Expand Up @@ -55,6 +57,17 @@ export function uploadPlan(assets: string[]): UploadStep[] {
return [...versioned, ...pointers, { name: MANIFEST_NAME, cacheControl: POINTER_CACHE, manifest: true }]
}

// The bare asset names a latest*.yml points electron-updater at. The generic R2
// feed resolves these relative to dl.pawwork.ai, so each must be an object we
// actually mirror — otherwise the in-app updater (#219) downloads a 404.
export function pointerReferencedAssets(pointerYml: string): string[] {
return [...new Set(parseUpdaterFileUrls(pointerYml).map((url) => url.split("/").at(-1) ?? url))]
}

export function missingPointerReferences(referenced: string[], mirrored: Set<string>): string[] {
return referenced.filter((name) => !mirrored.has(name))
}

const CONTENT_TYPES: Record<string, string> = {
dmg: "application/x-apple-diskimage",
exe: "application/octet-stream",
Expand Down Expand Up @@ -132,6 +145,17 @@ async function mirror({ assets, tag, repo, dir, bucket, endpoint, publicBase, ve
const missing = assets.filter((name) => !present.has(name))
if (missing.length) throw new Error(`Assets missing after download: ${missing.join(", ")}`)

// Fail before mirroring if a pointer references an asset we will not upload:
// the generic R2 feed must be able to resolve every file the yml lists.
const mirrored = new Set(assets)
for (const pointer of POINTER_YMLS) {
const referenced = pointerReferencedAssets(await Bun.file(join(dir, pointer)).text())
const missingRefs = missingPointerReferences(referenced, mirrored)
if (missingRefs.length) {
throw new Error(`${pointer} references assets not mirrored to R2: ${missingRefs.join(", ")}`)
}
}

const upload = async (name: string, cacheControl: string) => {
const local = join(dir, name)
await run([
Expand Down
14 changes: 14 additions & 0 deletions packages/desktop-electron/src/main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,17 @@ export const SETTINGS_STORE = PAWWORK_RUNTIME.settingsStore
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_ENABLED_KEY = "wslEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

// Opt-in dev switch to exercise the real updater feed (R2 + GitHub fallback)
// against dl.pawwork.ai under `bun run dev:desktop`. Off unless explicitly set,
// so normal dev runs never hit the network or forceDevUpdateConfig.
export const DEV_UPDATER = !app.isPackaged && process.env.PAWWORK_DEV_UPDATER === "1"
export const UPDATER_ACTIVE = UPDATER_ENABLED || DEV_UPDATER

// In-app update feed (#219). Prod releases are mirrored to Cloudflare R2 for
// mainland China reach; GitHub is the global fallback. Beta has no R2 mirror.
export const UPDATE_CHANNEL = "latest"
export const UPDATE_GITHUB_OWNER = "Astro-Han"
export const UPDATE_GITHUB_REPO = CHANNEL === "beta" ? "pawwork-beta" : "pawwork"
export const DOWNLOAD_PUBLIC_BASE = "https://dl.pawwork.ai"
export const UPDATE_R2_ENABLED = CHANNEL === "prod" || DEV_UPDATER
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("main updater source contracts", () => {
})

test("registers progress listeners only after the updater-disabled early return", () => {
const earlyReturnIndex = source.search(/if\s*\(\s*!UPDATER_ENABLED\s*\)\s*return/)
const earlyReturnIndex = source.search(/if\s*\(\s*!UPDATER_ACTIVE\s*\)\s*return/)
const listenerIndex = source.search(/autoUpdater\.on\("download-progress"/)
expect(earlyReturnIndex).toBeGreaterThan(0)
expect(listenerIndex).toBeGreaterThan(earlyReturnIndex)
Expand Down
73 changes: 65 additions & 8 deletions packages/desktop-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ const APP_IDS: Record<string, string> = {
const CI_SMOKE_HOME = process.env.PAWWORK_CI_SMOKE_HOME
const CI_SMOKE_ENABLED = process.env.PAWWORK_CI_SMOKE === "true"
const FEEDBACK_SESSION_EXPORT_TIMEOUT_MS = 3_000
// How long to wait on one update feed's reachability probe before falling back
// to the next. The probe is aborted (not abandoned) when it elapses.
const UPDATE_FEED_TIMEOUT_MS = 10_000
// electron-updater channel metadata file for this platform; the feed probe HEADs
// it to decide reachability.
const UPDATE_CHANNEL_FILE =
process.platform === "win32" ? "latest.yml" : process.platform === "darwin" ? "latest-mac.yml" : "latest-linux.yml"
const userDataRoot = CI_SMOKE_HOME ?? app.getPath("appData")

app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "PawWork Dev")
Expand All @@ -53,7 +60,17 @@ const { autoUpdater } = pkg
import type { DesktopContext, InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { ciSmokeCdpSwitches } from "./ci-smoke-cdp"
import { CHANNEL, FEEDBACK_FORM_URL, UPDATER_ENABLED } from "./constants"
import {
CHANNEL,
DEV_UPDATER,
DOWNLOAD_PUBLIC_BASE,
FEEDBACK_FORM_URL,
UPDATE_CHANNEL,
UPDATE_GITHUB_OWNER,
UPDATE_GITHUB_REPO,
UPDATE_R2_ENABLED,
UPDATER_ACTIVE,
} from "./constants"
import { normalizeDesktopContextPayload, syncWindowTitleForDesktopContext } from "./desktop-context-window"
import { createDesktopContextStore } from "./desktop-context-store"
import { createFeedbackHandler, feedbackDialogLabels } from "./feedback"
Expand All @@ -73,7 +90,7 @@ import {
} from "./renderer-diagnostics"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { PAWWORK_RUNTIME } from "./runtime-namespace"
import { createUpdaterController } from "./updater"
import { createUpdaterController, createUpdateFeed, githubFeed, r2Feed, type FeedTarget } from "./updater"
import { pendingUpdateCacheDir } from "./updater-cache"
import { updaterDialogLabels } from "./updater-dialog-labels"
import {
Expand Down Expand Up @@ -140,11 +157,29 @@ const rendererDiagnostics = createRendererDiagnosticsRecorder({
root: rendererDiagnosticsRoot(app.getPath("userData")),
appLaunchID: randomUUID(),
})
const updater = createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: () => app.getVersion(),
// Ordered update feeds: R2 first (fast/reachable in mainland China), GitHub as
// global fallback. electron-updater binds the download source at check time, so
// feed selection happens here and the same active feed serves the download.
function buildUpdateFeeds(): FeedTarget[] {
const r2 = UPDATE_R2_ENABLED ? [r2Feed(DOWNLOAD_PUBLIC_BASE, UPDATE_CHANNEL, UPDATE_CHANNEL_FILE)] : []
return [...r2, githubFeed(UPDATE_GITHUB_OWNER, UPDATE_GITHUB_REPO, UPDATE_CHANNEL, UPDATE_CHANNEL_FILE)]
}

const updateFeed = createUpdateFeed({
feeds: buildUpdateFeeds(),
setFeedURL: (options) => autoUpdater.setFeedURL(options),
checkForUpdates: () => autoUpdater.checkForUpdates(),
downloadUpdate: () => autoUpdater.downloadUpdate(),
timeoutMs: UPDATE_FEED_TIMEOUT_MS,
log: (message, data) => logger.log(message, data),
error: (message, error) => logger.error(message, error),
})

const updater = createUpdaterController({
enabled: UPDATER_ACTIVE,
currentVersion: () => app.getVersion(),
checkForUpdates: () => updateFeed.check(),
downloadUpdate: () => updateFeed.download(),
clearPendingUpdate: clearPendingUpdate,
quitAndInstall: () => {
killSidecar()
Expand All @@ -159,7 +194,7 @@ function diagnostics(context = currentDesktopContext()) {
appVersion: app.getVersion(),
channel: CHANNEL,
packaged: app.isPackaged,
updaterEnabled: UPDATER_ENABLED,
updaterEnabled: UPDATER_ACTIVE,
platform: process.platform,
osVersion: `${os.type()} ${os.release()}`,
arch: process.arch,
Expand Down Expand Up @@ -575,7 +610,7 @@ registerIpcHandlers({
getWslConfig: () => Promise.resolve(getWslConfig()),
setWslConfig: (config: WslConfig) => setWslConfig(config),
getWindowConfig: () => ({
updaterEnabled: UPDATER_ENABLED,
updaterEnabled: UPDATER_ACTIVE,
wslEnabled: getWslConfig().enabled,
}),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
Expand Down Expand Up @@ -678,18 +713,40 @@ async function clearPendingUpdate() {
}

function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
if (!UPDATER_ACTIVE) return
autoUpdater.logger = logger
// Dev-only: run the real updater under dev:desktop so the R2 feed and GitHub
// fallback can be exercised without a signed packaged build (#219).
if (DEV_UPDATER) autoUpdater.forceDevUpdateConfig = true
autoUpdater.channel = "latest"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
autoUpdater.allowPrerelease = false
autoUpdater.allowDowngrade = false
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = process.platform !== "darwin"
// Set an initial feed so any pre-check path has a sane provider and so a
// broken feed config surfaces in logs at startup. Every check() re-selects
// the feed (R2 first, GitHub fallback) regardless.
const feeds = buildUpdateFeeds()
try {
autoUpdater.setFeedURL(feeds[0].options)
} catch (error) {
logger.error("initial update feed config failed", error)
const github = feeds.find((feed) => feed.label === "github")
if (github) {
try {
autoUpdater.setFeedURL(github.options)
} catch (fallbackError) {
logger.error("github update feed config failed", fallbackError)
}
}
}
logger.log("auto updater configured", {
channel: autoUpdater.channel,
allowPrerelease: autoUpdater.allowPrerelease,
allowDowngrade: autoUpdater.allowDowngrade,
autoInstallOnAppQuit: autoUpdater.autoInstallOnAppQuit,
devUpdater: DEV_UPDATER,
feeds: feeds.map((feed) => feed.label),
currentVersion: app.getVersion(),
})
autoUpdater.on("download-progress", (info) => {
Expand Down
Loading