Spaces:
Paused
Paused
| /** | |
| * Update checker β polls the Codex Sparkle appcast for new versions. | |
| * Automatically applies version updates to config file and runtime. | |
| */ | |
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; | |
| import { resolve } from "path"; | |
| import { fork } from "child_process"; | |
| import yaml from "js-yaml"; | |
| import { mutateClientConfig, reloadAllConfigs } from "./config.js"; | |
| import { jitterInt } from "./utils/jitter.js"; | |
| import { curlFetchGet } from "./tls/curl-fetch.js"; | |
| import { getConfigDir, getDataDir, isEmbedded } from "./paths.js"; | |
| function getConfigPath(): string { | |
| return resolve(getConfigDir(), "default.yaml"); | |
| } | |
| function getStatePath(): string { | |
| return resolve(getDataDir(), "update-state.json"); | |
| } | |
| const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; | |
| const POLL_INTERVAL_MS = 3 * 24 * 60 * 60 * 1000; // 3 days | |
| export interface UpdateState { | |
| last_check: string; | |
| latest_version: string | null; | |
| latest_build: string | null; | |
| download_url: string | null; | |
| update_available: boolean; | |
| current_version: string; | |
| current_build: string; | |
| } | |
| let _currentState: UpdateState | null = null; | |
| let _pollTimer: ReturnType<typeof setTimeout> | null = null; | |
| let _updateInProgress = false; | |
| function getVersionOverridePath(): string { | |
| return resolve(getDataDir(), "version-state.json"); | |
| } | |
| function loadCurrentConfig(): { app_version: string; build_number: string } { | |
| // Check runtime override first (written by applyVersionUpdate) | |
| try { | |
| const overridePath = getVersionOverridePath(); | |
| if (existsSync(overridePath)) { | |
| const override = JSON.parse(readFileSync(overridePath, "utf-8")) as { | |
| app_version: string; | |
| build_number: string; | |
| }; | |
| if (override.app_version && override.build_number) { | |
| return override; | |
| } | |
| } | |
| } catch { | |
| // Corrupt override β fall through to YAML baseline | |
| } | |
| const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record<string, unknown>; | |
| const client = raw.client as Record<string, string>; | |
| return { | |
| app_version: client.app_version, | |
| build_number: client.build_number, | |
| }; | |
| } | |
| function parseAppcast(xml: string): { | |
| version: string | null; | |
| build: string | null; | |
| downloadUrl: string | null; | |
| } { | |
| const itemMatch = xml.match(/<item>([\s\S]*?)<\/item>/i); | |
| if (!itemMatch) return { version: null, build: null, downloadUrl: null }; | |
| const item = itemMatch[1]; | |
| // Support both attribute syntax (sparkle:version="X") and element syntax (<sparkle:version>X</sparkle:version>) | |
| const versionMatch = | |
| item.match(/sparkle:shortVersionString="([^"]+)"/) ?? | |
| item.match(/<sparkle:shortVersionString>([^<]+)<\/sparkle:shortVersionString>/); | |
| const buildMatch = | |
| item.match(/sparkle:version="([^"]+)"/) ?? | |
| item.match(/<sparkle:version>([^<]+)<\/sparkle:version>/); | |
| const urlMatch = item.match(/url="([^"]+)"/); | |
| return { | |
| version: versionMatch?.[1] ?? null, | |
| build: buildMatch?.[1] ?? null, | |
| downloadUrl: urlMatch?.[1] ?? null, | |
| }; | |
| } | |
| function applyVersionUpdate(version: string, build: string): void { | |
| // Persist to data/ (gitignored) β config/default.yaml stays read-only | |
| try { | |
| const dataDir = getDataDir(); | |
| mkdirSync(dataDir, { recursive: true }); | |
| writeFileSync( | |
| getVersionOverridePath(), | |
| JSON.stringify({ app_version: version, build_number: build }, null, 2), | |
| ); | |
| } catch { | |
| // best-effort persistence | |
| } | |
| // Update runtime config in memory | |
| mutateClientConfig({ app_version: version, build_number: build }); | |
| } | |
| /** | |
| * Trigger the full-update pipeline in a background child process. | |
| * Downloads new Codex.app, extracts fingerprint, and applies config updates. | |
| * Protected by a lock to prevent concurrent runs. | |
| */ | |
| const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes | |
| function triggerFullUpdate(): void { | |
| if (_updateInProgress) { | |
| console.log("[UpdateChecker] Full update already in progress, skipping"); | |
| return; | |
| } | |
| // Skip fork-based update in embedded (Electron) mode β no tsx/scripts available | |
| if (isEmbedded()) { | |
| console.log("[UpdateChecker] Embedded mode β skipping full-update pipeline"); | |
| return; | |
| } | |
| _updateInProgress = true; | |
| console.log("[UpdateChecker] Triggering full-update pipeline..."); | |
| const child = fork( | |
| resolve(process.cwd(), "scripts/full-update.ts"), | |
| ["--force"], | |
| { | |
| execArgv: ["--import", "tsx"], | |
| stdio: "pipe", | |
| cwd: process.cwd(), | |
| }, | |
| ); | |
| // Kill the child if it hangs beyond the timeout | |
| const killTimer = setTimeout(() => { | |
| console.warn("[UpdateChecker] Full update timed out, killing child"); | |
| child.kill("SIGTERM"); | |
| }, UPDATE_TIMEOUT_MS); | |
| let output = ""; | |
| child.stdout?.on("data", (chunk: Buffer) => { | |
| output += chunk.toString(); | |
| }); | |
| child.stderr?.on("data", (chunk: Buffer) => { | |
| output += chunk.toString(); | |
| }); | |
| child.on("exit", (code) => { | |
| clearTimeout(killTimer); | |
| _updateInProgress = false; | |
| if (code === 0) { | |
| console.log("[UpdateChecker] Full update completed. Reloading config..."); | |
| try { | |
| reloadAllConfigs(); | |
| } catch (err) { | |
| console.error("[UpdateChecker] Failed to reload config after update:", err instanceof Error ? err.message : err); | |
| } | |
| } else { | |
| console.warn(`[UpdateChecker] Full update exited with code ${code}`); | |
| if (output) { | |
| // Log last few lines for debugging | |
| const lines = output.trim().split("\n").slice(-5); | |
| for (const line of lines) { | |
| console.warn(`[UpdateChecker] ${line}`); | |
| } | |
| } | |
| } | |
| }); | |
| child.on("error", (err) => { | |
| clearTimeout(killTimer); | |
| _updateInProgress = false; | |
| console.error("[UpdateChecker] Failed to spawn full-update:", err.message); | |
| }); | |
| } | |
| export async function checkForUpdate(): Promise<UpdateState> { | |
| const current = loadCurrentConfig(); | |
| const res = await curlFetchGet(APPCAST_URL); | |
| if (!res.ok) { | |
| throw new Error(`Failed to fetch appcast: ${res.status}`); | |
| } | |
| const xml = res.body; | |
| const { version, build, downloadUrl } = parseAppcast(xml); | |
| const updateAvailable = !!(version && build && | |
| (version !== current.app_version || build !== current.build_number)); | |
| const state: UpdateState = { | |
| last_check: new Date().toISOString(), | |
| latest_version: version, | |
| latest_build: build, | |
| download_url: downloadUrl, | |
| update_available: updateAvailable, | |
| current_version: current.app_version, | |
| current_build: current.build_number, | |
| }; | |
| _currentState = state; | |
| // Persist state | |
| try { | |
| mkdirSync(getDataDir(), { recursive: true }); | |
| writeFileSync(getStatePath(), JSON.stringify(state, null, 2)); | |
| } catch { | |
| // best-effort persistence | |
| } | |
| if (updateAvailable) { | |
| console.log( | |
| `[UpdateChecker] *** UPDATE AVAILABLE: v${version} (build ${build}) β current: v${current.app_version} (build ${current.build_number})`, | |
| ); | |
| applyVersionUpdate(version!, build!); | |
| state.current_version = version!; | |
| state.current_build = build!; | |
| state.update_available = false; | |
| console.log(`[UpdateChecker] Auto-applied: v${version} (build ${build})`); | |
| // Trigger full-update pipeline in background (download + fingerprint extraction) | |
| triggerFullUpdate(); | |
| } | |
| return state; | |
| } | |
| /** Get the most recent update check state. */ | |
| export function getUpdateState(): UpdateState | null { | |
| return _currentState; | |
| } | |
| /** Whether a full-update pipeline is currently running. */ | |
| export function isUpdateInProgress(): boolean { | |
| return _updateInProgress; | |
| } | |
| function scheduleNextPoll(): void { | |
| _pollTimer = setTimeout(() => { | |
| checkForUpdate().catch((err) => { | |
| console.warn(`[UpdateChecker] Poll failed: ${err instanceof Error ? err.message : err}`); | |
| }); | |
| scheduleNextPoll(); | |
| }, jitterInt(POLL_INTERVAL_MS, 0.1)); | |
| if (_pollTimer.unref) _pollTimer.unref(); | |
| } | |
| /** | |
| * Start periodic update checking. | |
| * Runs an initial check immediately (non-blocking), then polls with jittered intervals. | |
| */ | |
| export function startUpdateChecker(): void { | |
| // Initial check (non-blocking) | |
| checkForUpdate().catch((err) => { | |
| console.warn(`[UpdateChecker] Initial check failed: ${err instanceof Error ? err.message : err}`); | |
| }); | |
| // Periodic polling with jitter | |
| scheduleNextPoll(); | |
| } | |
| /** Stop the periodic update checker. */ | |
| export function stopUpdateChecker(): void { | |
| if (_pollTimer) { | |
| clearTimeout(_pollTimer); | |
| _pollTimer = null; | |
| } | |
| } | |