File size: 8,454 Bytes
85aec43
 
0d2f54c
85aec43
 
50720ce
85aec43
347f81b
85aec43
347f81b
0d2f54c
22a7de1
4a940a5
85aec43
4a940a5
 
 
 
 
 
85aec43
8728276
85aec43
 
 
 
 
 
 
 
 
 
 
 
0d2f54c
347f81b
85aec43
50720ce
 
 
 
85aec43
50720ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a940a5
85aec43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22a7de1
 
 
 
 
 
 
85aec43
 
 
 
 
 
 
 
0d2f54c
50720ce
 
 
 
 
 
 
 
 
 
 
 
0d2f54c
 
 
347f81b
 
 
 
 
8728276
 
347f81b
 
 
 
 
4a940a5
 
 
 
 
 
 
347f81b
 
 
 
 
 
 
 
 
 
 
 
 
8728276
 
 
 
 
 
347f81b
 
 
 
 
 
 
 
 
8728276
347f81b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8728276
347f81b
 
 
 
 
85aec43
 
22a7de1
85aec43
22a7de1
85aec43
22a7de1
85aec43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a940a5
 
85aec43
 
 
 
 
 
 
 
0d2f54c
 
 
 
 
347f81b
 
 
85aec43
 
 
 
 
 
 
 
 
 
06a5304
 
 
 
 
0d2f54c
 
 
 
 
 
 
 
 
 
85aec43
 
0d2f54c
85aec43
 
 
 
 
 
 
0d2f54c
 
85aec43
 
 
 
 
0d2f54c
85aec43
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
/**
 * 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;
  }
}