@remodex/rmx 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1620 +0,0 @@
1
- /**
2
- * Secure updater for the Remodex desktop shell.
3
- *
4
- * This module is intentionally separate from the npm self-updater in `job.ts`.
5
- * Desktop releases are native installers published in the Remodex release
6
- * repository; treating them as npm packages would either install the wrong
7
- * payload or replace a running runtime in place. The tray and dashboard share
8
- * the small, owner-only state record written here.
9
- */
10
- import { createHash } from "node:crypto";
11
- import {
12
- chmodSync,
13
- closeSync,
14
- createReadStream,
15
- existsSync,
16
- lstatSync,
17
- mkdirSync,
18
- openSync,
19
- readFileSync,
20
- renameSync,
21
- statSync,
22
- truncateSync,
23
- unlinkSync,
24
- writeSync,
25
- } from "node:fs";
26
- import { spawn } from "node:child_process";
27
- import { basename, join } from "node:path";
28
- import { arch as hostArch, platform as hostPlatform } from "node:process";
29
- import { atomicWriteFile, getConfigDir } from "../config";
30
- import { isProcessAlive } from "../lib/process-control";
31
-
32
- export const DESKTOP_RELEASE_REPOSITORY = "ESCANOR-001/remodex-releases";
33
- export const DESKTOP_MANIFEST_URL =
34
- "https://raw.githubusercontent.com/ESCANOR-001/remodex-releases/main/latest.json";
35
- export const DESKTOP_MANIFEST_FALLBACK_URL =
36
- "https://github.com/ESCANOR-001/remodex-releases/releases/latest/download/latest.json";
37
- export const DESKTOP_PREVIEW_MANIFEST_URL =
38
- "https://raw.githubusercontent.com/ESCANOR-001/remodex-releases/main/preview.json";
39
-
40
- const DESKTOP_UPDATE_STATE_FILENAME = "desktop-update.json";
41
- const DESKTOP_UPDATE_DIRNAME = "desktop-updates";
42
- const DESKTOP_AUTO_UPDATE_SCHEDULE_FILENAME = "desktop-update-schedule.json";
43
- const MANIFEST_MAX_BYTES = 256 * 1024;
44
- const RELEASE_NOTES_MAX_BYTES = 32 * 1024;
45
- const MAX_ARTIFACT_BYTES = 4 * 1024 * 1024 * 1024;
46
- const FETCH_TIMEOUT_MS = 20_000;
47
- const DOWNLOAD_TIMEOUT_MS = 30 * 60_000;
48
- const INSTALL_TIMEOUT_MS = 15 * 60_000;
49
- const MAX_REDIRECTS = 4;
50
- const ACTIVE_JOB_STALE_MS = 10 * 60_000;
51
- export const DESKTOP_AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60_000;
52
-
53
- export type DesktopReleaseChannel = "latest" | "preview";
54
- export type DesktopPlatform = "linux" | "windows" | "darwin";
55
- export type DesktopArtifactFormat = "deb" | "rpm" | "exe" | "msi" | "dmg" | "pkg";
56
- export type DesktopUpdatePhase =
57
- | "idle"
58
- | "checking"
59
- | "available"
60
- | "downloading"
61
- | "verifying"
62
- | "ready"
63
- | "installing"
64
- | "up-to-date"
65
- | "succeeded"
66
- | "failed";
67
-
68
- export type DesktopUpdateReason =
69
- | "already_latest"
70
- | "latest_unavailable"
71
- | "invalid_manifest"
72
- | "unsupported_platform"
73
- | "desktop_required"
74
- | "signature_unsupported"
75
- | "download_failed"
76
- | "checksum_mismatch"
77
- | "install_failed";
78
-
79
- export interface DesktopReleaseArtifact {
80
- format: DesktopArtifactFormat;
81
- url: string;
82
- size: number;
83
- sha256: string;
84
- name?: string;
85
- signature?: {
86
- algorithm: "ed25519";
87
- value: string;
88
- keyId: string;
89
- required?: boolean;
90
- };
91
- }
92
-
93
- export interface DesktopReleaseManifest {
94
- schemaVersion: 1;
95
- version: string;
96
- channel: DesktopReleaseChannel;
97
- releaseNotes: string;
98
- releaseNotesUrl?: string;
99
- artifacts: Record<string, DesktopReleaseArtifact>;
100
- }
101
-
102
- export interface DesktopTarget {
103
- platform: DesktopPlatform | null;
104
- arch: string;
105
- key: string | null;
106
- }
107
-
108
- export interface DesktopUpdateCheckResult {
109
- currentVersion: string;
110
- latestVersion: string | null;
111
- channel: DesktopReleaseChannel;
112
- platform: DesktopPlatform | null;
113
- arch: string;
114
- targetKey: string | null;
115
- artifact: {
116
- format: DesktopArtifactFormat;
117
- name: string;
118
- size: number;
119
- sha256: string;
120
- signatureRequired: boolean;
121
- } | null;
122
- updateAvailable: boolean;
123
- canDownload: boolean;
124
- canInstall: boolean;
125
- desktopShell: boolean;
126
- releaseNotes: string;
127
- releaseNotesUrl: string | null;
128
- manifestUrl: string | null;
129
- checkedAt: string;
130
- reason?: DesktopUpdateReason;
131
- }
132
-
133
- export interface DesktopUpdateState {
134
- schemaVersion: 1;
135
- id: string;
136
- phase: DesktopUpdatePhase;
137
- currentVersion: string;
138
- latestVersion: string | null;
139
- channel: DesktopReleaseChannel;
140
- platform: DesktopPlatform | null;
141
- arch: string;
142
- targetKey: string | null;
143
- artifactFormat: DesktopArtifactFormat | null;
144
- artifactName: string | null;
145
- artifactSha256: string | null;
146
- totalBytes: number | null;
147
- downloadedBytes: number;
148
- progress: number | null;
149
- verification: "pending" | "sha256" | "signature" | "failed";
150
- releaseNotes: string;
151
- releaseNotesUrl: string | null;
152
- manifestUrl: string | null;
153
- startedAt: string;
154
- updatedAt: string;
155
- checkedAt: string | null;
156
- pid?: number;
157
- errorCode?: DesktopUpdateReason;
158
- restartRequired?: boolean;
159
- }
160
-
161
- interface DesktopAutoUpdateSchedule {
162
- schemaVersion: 1;
163
- lastAttemptAt: string;
164
- lastSuccessfulCheckAt?: string;
165
- lastNotifiedVersion?: string;
166
- }
167
-
168
- export interface AutomaticDesktopUpdateCheckResult {
169
- checked: boolean;
170
- updateAvailable: boolean;
171
- latestVersion: string | null;
172
- notify: boolean;
173
- outcome: "checked" | "not_due" | "busy" | "state_changed" | "failed";
174
- reason?: DesktopUpdateReason;
175
- }
176
-
177
- export interface DesktopReleaseIo {
178
- fetchFn?: typeof fetch;
179
- now?: () => number;
180
- platform?: NodeJS.Platform;
181
- arch?: string;
182
- currentVersion?: () => string;
183
- desktopShell?: () => boolean;
184
- installer?: (format: DesktopArtifactFormat, path: string) => Promise<InstallResult>;
185
- isAlive?: (pid: number) => boolean;
186
- spawnWorker?: (id: string, install: boolean) => { pid?: number; unref(): void };
187
- }
188
-
189
- export interface InstallResult {
190
- ok: boolean;
191
- restartRequired?: boolean;
192
- code?: DesktopUpdateReason;
193
- }
194
-
195
- export interface StartDesktopUpdateOptions {
196
- /** Check and download when false; launch the verified installer when true. */
197
- install?: boolean;
198
- /**
199
- * Bind a dashboard install click to the exact ready record it displayed.
200
- * The tray intentionally omits this because its action is derived from the
201
- * latest shared state at click time.
202
- */
203
- expectedReady?: {
204
- id: string;
205
- channel: DesktopReleaseChannel;
206
- };
207
- }
208
-
209
- export class DesktopUpdateError extends Error {
210
- constructor(
211
- message: string,
212
- readonly status = 400,
213
- readonly code: DesktopUpdateReason = "download_failed",
214
- ) {
215
- super(message);
216
- }
217
- }
218
-
219
- const FORMAT_EXTENSIONS: Record<DesktopArtifactFormat, string> = {
220
- deb: ".deb",
221
- rpm: ".rpm",
222
- exe: ".exe",
223
- msi: ".msi",
224
- dmg: ".dmg",
225
- pkg: ".pkg",
226
- };
227
-
228
- const ALLOWED_FORMATS = new Set<DesktopArtifactFormat>(Object.keys(FORMAT_EXTENSIONS) as DesktopArtifactFormat[]);
229
-
230
- function updateStatePath(): string {
231
- return join(getConfigDir(), DESKTOP_UPDATE_STATE_FILENAME);
232
- }
233
-
234
- export function desktopUpdateStatePathForTests(): string {
235
- return updateStatePath();
236
- }
237
-
238
- function autoUpdateSchedulePath(): string {
239
- return join(getConfigDir(), DESKTOP_AUTO_UPDATE_SCHEDULE_FILENAME);
240
- }
241
-
242
- export function desktopAutoUpdateSchedulePathForTests(): string {
243
- return autoUpdateSchedulePath();
244
- }
245
-
246
- export function desktopUpdateDirectory(): string {
247
- return join(getConfigDir(), DESKTOP_UPDATE_DIRNAME);
248
- }
249
-
250
- function ensureUpdateDirectory(): void {
251
- const dir = desktopUpdateDirectory();
252
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
253
- const stat = lstatSync(dir);
254
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
255
- throw new DesktopUpdateError("The desktop update directory is unavailable.", 500, "download_failed");
256
- }
257
- try { chmodSync(dir, 0o700); } catch { /* platform may ignore chmod */ }
258
- }
259
-
260
- function isVersion(value: unknown): value is string {
261
- return typeof value === "string"
262
- && value.length <= 64
263
- && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(value);
264
- }
265
-
266
- function isSafeReleaseUrl(value: unknown): value is string {
267
- if (typeof value !== "string" || value.length > 2048) return false;
268
- try {
269
- const url = new URL(value);
270
- if (url.protocol !== "https:") return false;
271
- const host = url.hostname.toLowerCase();
272
- return host === "github.com"
273
- || host === "raw.githubusercontent.com"
274
- || host === "objects.githubusercontent.com"
275
- || host === "githubusercontent.com"
276
- || host.endsWith(".githubusercontent.com");
277
- } catch {
278
- return false;
279
- }
280
- }
281
-
282
- const RELEASE_REPOSITORY_PATH = "/escanor-001/remodex-releases";
283
-
284
- /**
285
- * Manifest fields are expected to point at this repository, while a completed
286
- * GitHub download commonly redirects to an `*.githubusercontent.com` object
287
- * host whose URL no longer carries the repository path. Keep those two cases
288
- * explicit instead of treating every GitHub repository as a Remodex release.
289
- */
290
- function isTrustedReleaseRepositoryUrl(value: unknown): value is string {
291
- if (!isSafeReleaseUrl(value)) return false;
292
- const url = new URL(value);
293
- const host = url.hostname.toLowerCase();
294
- if (host === "github.com" || host === "raw.githubusercontent.com") {
295
- const path = url.pathname.replace(/\/+$/, "").toLowerCase();
296
- return path === RELEASE_REPOSITORY_PATH || path.startsWith(`${RELEASE_REPOSITORY_PATH}/`);
297
- }
298
- return host === "objects.githubusercontent.com"
299
- || host === "githubusercontent.com"
300
- || host.endsWith(".githubusercontent.com");
301
- }
302
-
303
- function safeArtifactName(value: unknown, url: string, format: DesktopArtifactFormat): string {
304
- const candidate = typeof value === "string" && value.trim() ? value.trim() : basename(new URL(url).pathname);
305
- if (
306
- candidate.length === 0
307
- || candidate.length > 180
308
- || candidate === "."
309
- || candidate === ".."
310
- || candidate.includes("/")
311
- || candidate.includes("\\")
312
- || candidate.includes("\0")
313
- ) {
314
- return `Remodex${FORMAT_EXTENSIONS[format]}`;
315
- }
316
- return candidate;
317
- }
318
-
319
- function parseArtifact(value: unknown): DesktopReleaseArtifact | null {
320
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
321
- const raw = value as Record<string, unknown>;
322
- if (!isTrustedReleaseRepositoryUrl(raw.url)) return null;
323
- if (typeof raw.format !== "string" || !ALLOWED_FORMATS.has(raw.format as DesktopArtifactFormat)) return null;
324
- const format = raw.format as DesktopArtifactFormat;
325
- if (
326
- typeof raw.size !== "number"
327
- || !Number.isSafeInteger(raw.size)
328
- || raw.size <= 0
329
- || raw.size > MAX_ARTIFACT_BYTES
330
- || typeof raw.sha256 !== "string"
331
- || !/^[a-fA-F0-9]{64}$/.test(raw.sha256)
332
- ) return null;
333
- let signature: DesktopReleaseArtifact["signature"];
334
- if (raw.signature !== undefined) {
335
- if (!raw.signature || typeof raw.signature !== "object" || Array.isArray(raw.signature)) return null;
336
- const candidate = raw.signature as Record<string, unknown>;
337
- if (
338
- candidate.algorithm !== "ed25519"
339
- || typeof candidate.value !== "string"
340
- || candidate.value.length < 16
341
- || candidate.value.length > 512
342
- || typeof candidate.keyId !== "string"
343
- || candidate.keyId.length < 1
344
- || candidate.keyId.length > 128
345
- || (candidate.required !== undefined && typeof candidate.required !== "boolean")
346
- ) return null;
347
- signature = {
348
- algorithm: "ed25519",
349
- value: candidate.value,
350
- keyId: candidate.keyId,
351
- ...(candidate.required === true ? { required: true } : {}),
352
- };
353
- }
354
- return {
355
- format,
356
- url: raw.url,
357
- size: raw.size,
358
- sha256: raw.sha256.toLowerCase(),
359
- ...(typeof raw.name === "string" ? { name: safeArtifactName(raw.name, raw.url, format) } : {}),
360
- ...(signature ? { signature } : {}),
361
- };
362
- }
363
-
364
- function artifactFormatMatchesTarget(key: string, format: DesktopArtifactFormat): boolean {
365
- if (key.startsWith("linux-")) return format === "deb" || format === "rpm";
366
- if (key.startsWith("windows-")) return format === "exe" || format === "msi";
367
- if (key.startsWith("darwin-")) return format === "dmg" || format === "pkg";
368
- return false;
369
- }
370
-
371
- export function validateDesktopReleaseManifest(value: unknown): DesktopReleaseManifest {
372
- if (!value || typeof value !== "object" || Array.isArray(value)) {
373
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
374
- }
375
- const raw = value as Record<string, unknown>;
376
- if (raw.schemaVersion !== 1 || !isVersion(raw.version)) {
377
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
378
- }
379
- if (raw.channel !== "latest" && raw.channel !== "preview") {
380
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
381
- }
382
- if (typeof raw.releaseNotes !== "string" || Buffer.byteLength(raw.releaseNotes, "utf8") > RELEASE_NOTES_MAX_BYTES) {
383
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
384
- }
385
- if (raw.releaseNotesUrl !== undefined && !isTrustedReleaseRepositoryUrl(raw.releaseNotesUrl)) {
386
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
387
- }
388
- if (!raw.artifacts || typeof raw.artifacts !== "object" || Array.isArray(raw.artifacts)) {
389
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
390
- }
391
- const artifacts: Record<string, DesktopReleaseArtifact> = {};
392
- for (const [key, candidate] of Object.entries(raw.artifacts)) {
393
- if (!/^(?:linux|windows|darwin)-(?:x64|arm64|ia32)$/.test(key)) continue;
394
- const parsed = parseArtifact(candidate);
395
- if (!parsed || !artifactFormatMatchesTarget(key, parsed.format)) {
396
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
397
- }
398
- artifacts[key] = parsed;
399
- }
400
- if (Object.keys(artifacts).length === 0) {
401
- throw new DesktopUpdateError("The release manifest has no supported artifacts.", 502, "invalid_manifest");
402
- }
403
- return {
404
- schemaVersion: 1,
405
- version: raw.version,
406
- channel: raw.channel,
407
- releaseNotes: raw.releaseNotes,
408
- ...(typeof raw.releaseNotesUrl === "string" ? { releaseNotesUrl: raw.releaseNotesUrl } : {}),
409
- artifacts,
410
- };
411
- }
412
-
413
- function normalizePlatform(value: NodeJS.Platform): DesktopPlatform | null {
414
- if (value === "linux") return "linux";
415
- if (value === "win32") return "windows";
416
- if (value === "darwin") return "darwin";
417
- return null;
418
- }
419
-
420
- function normalizeArch(value: string): string {
421
- if (value === "x64" || value === "amd64") return "x64";
422
- if (value === "arm64" || value === "aarch64") return "arm64";
423
- if (value === "ia32" || value === "x86") return "ia32";
424
- return value;
425
- }
426
-
427
- export function desktopTarget(
428
- platform: NodeJS.Platform = hostPlatform,
429
- arch: string = hostArch,
430
- ): DesktopTarget {
431
- const normalizedPlatform = normalizePlatform(platform);
432
- const normalizedArch = normalizeArch(arch);
433
- return {
434
- platform: normalizedPlatform,
435
- arch: normalizedArch,
436
- key: normalizedPlatform ? `${normalizedPlatform}-${normalizedArch}` : null,
437
- };
438
- }
439
-
440
- type ParsedVersion = {
441
- major: number;
442
- minor: number;
443
- patch: number;
444
- pre: string[];
445
- };
446
-
447
- function parseVersion(value: string): ParsedVersion | null {
448
- if (!isVersion(value)) return null;
449
- const withoutBuild = value.split("+", 1)[0]!;
450
- const separator = withoutBuild.indexOf("-");
451
- const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
452
- const pre = separator === -1 ? "" : withoutBuild.slice(separator + 1);
453
- const [major, minor, patch] = core.split(".").map(Number);
454
- return { major, minor, patch, pre: pre ? pre.split(".") : [] };
455
- }
456
-
457
- function compareVersions(a: string, b: string): number {
458
- const left = parseVersion(a);
459
- const right = parseVersion(b);
460
- if (!left || !right) return 0;
461
- for (const key of ["major", "minor", "patch"] as const) {
462
- if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
463
- }
464
- if (left.pre.length === 0 && right.pre.length > 0) return 1;
465
- if (left.pre.length > 0 && right.pre.length === 0) return -1;
466
- for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
467
- const l = left.pre[index];
468
- const r = right.pre[index];
469
- if (l === undefined) return -1;
470
- if (r === undefined) return 1;
471
- if (l === r) continue;
472
- const lNum = /^\d+$/.test(l) ? Number(l) : null;
473
- const rNum = /^\d+$/.test(r) ? Number(r) : null;
474
- if (lNum !== null && rNum !== null) return lNum > rNum ? 1 : -1;
475
- if (lNum !== null) return -1;
476
- if (rNum !== null) return 1;
477
- return l > r ? 1 : -1;
478
- }
479
- return 0;
480
- }
481
-
482
- export function isNewerDesktopVersion(latest: string, current: string, channel: DesktopReleaseChannel): boolean {
483
- if (channel === "latest" && latest.includes("-")) return false;
484
- return compareVersions(latest, current) > 0;
485
- }
486
-
487
- function defaultCurrentVersion(): string {
488
- try {
489
- const value = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown };
490
- return typeof value.version === "string" && isVersion(value.version) ? value.version : "0.0.0";
491
- } catch {
492
- return "0.0.0";
493
- }
494
- }
495
-
496
- function defaultDesktopShell(): boolean {
497
- // `OCX_DESKTOP_UPDATE` identifies the short-lived worker, not the desktop
498
- // shell itself. A service/browser request must not gain installer
499
- // privileges merely because it spawned that worker.
500
- return process.env.OCX_DESKTOP === "1";
501
- }
502
-
503
- function manifestUrls(channel: DesktopReleaseChannel): readonly string[] {
504
- return channel === "preview"
505
- ? [DESKTOP_PREVIEW_MANIFEST_URL]
506
- : [DESKTOP_MANIFEST_URL, DESKTOP_MANIFEST_FALLBACK_URL];
507
- }
508
-
509
- function allowedManifestUrl(value: string): boolean {
510
- return value === DESKTOP_MANIFEST_URL
511
- || value === DESKTOP_MANIFEST_FALLBACK_URL
512
- || value === DESKTOP_PREVIEW_MANIFEST_URL;
513
- }
514
-
515
- async function fetchBoundedText(
516
- url: string,
517
- fetchFn: typeof fetch,
518
- maxBytes: number,
519
- ): Promise<{ text: string; finalUrl: string }> {
520
- let current = url;
521
- for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
522
- if (!allowedManifestUrl(current)) {
523
- // Redirects are permitted only to the trusted GitHub content hosts and
524
- // never to an arbitrary host.
525
- if (!isTrustedReleaseRepositoryUrl(current)) throw new DesktopUpdateError("The release URL is not trusted.", 502, "invalid_manifest");
526
- }
527
- const response = await fetchFn(current, {
528
- redirect: "manual",
529
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
530
- headers: { accept: "application/json" },
531
- });
532
- if (response.status >= 300 && response.status < 400) {
533
- const location = response.headers.get("location");
534
- if (!location || redirect === MAX_REDIRECTS) {
535
- throw new DesktopUpdateError("The release manifest could not be fetched.", 502, "latest_unavailable");
536
- }
537
- const next = new URL(location, current).toString();
538
- if (!isTrustedReleaseRepositoryUrl(next)) {
539
- throw new DesktopUpdateError("The release URL is not trusted.", 502, "invalid_manifest");
540
- }
541
- current = next;
542
- continue;
543
- }
544
- if (!response.ok) {
545
- throw new DesktopUpdateError("The release manifest could not be fetched.", response.status === 404 ? 404 : 502, "latest_unavailable");
546
- }
547
- const length = Number(response.headers.get("content-length") ?? "0");
548
- if (Number.isFinite(length) && length > maxBytes) {
549
- throw new DesktopUpdateError("The release manifest is too large.", 502, "invalid_manifest");
550
- }
551
- // Do not call `arrayBuffer()` for an untrusted response before enforcing the
552
- // bound: a server can omit Content-Length and otherwise make a malformed
553
- // manifest consume an arbitrary amount of memory. Read at most maxBytes
554
- // from the stream and cancel as soon as the limit is crossed.
555
- const bytes = await readResponseBytesBounded(response, maxBytes);
556
- return {
557
- text: new TextDecoder().decode(bytes),
558
- finalUrl: response.url && isTrustedReleaseRepositoryUrl(response.url) ? response.url : current,
559
- };
560
- }
561
- throw new DesktopUpdateError("The release manifest could not be fetched.", 502, "latest_unavailable");
562
- }
563
-
564
- async function readResponseBytesBounded(response: Response, maxBytes: number): Promise<Uint8Array> {
565
- if (!response.body) {
566
- const bytes = new Uint8Array(await response.arrayBuffer());
567
- if (bytes.byteLength > maxBytes) {
568
- throw new DesktopUpdateError("The release manifest is too large.", 502, "invalid_manifest");
569
- }
570
- return bytes;
571
- }
572
-
573
- const reader = response.body.getReader();
574
- const chunks: Uint8Array[] = [];
575
- let total = 0;
576
- let complete = false;
577
- try {
578
- for (;;) {
579
- const next = await reader.read();
580
- if (next.done) {
581
- complete = true;
582
- break;
583
- }
584
- total += next.value.byteLength;
585
- if (total > maxBytes) {
586
- throw new DesktopUpdateError("The release manifest is too large.", 502, "invalid_manifest");
587
- }
588
- chunks.push(next.value);
589
- }
590
- } finally {
591
- if (!complete) {
592
- try { await reader.cancel(); } catch { /* response is already closed */ }
593
- }
594
- }
595
-
596
- const bytes = new Uint8Array(total);
597
- let offset = 0;
598
- for (const chunk of chunks) {
599
- bytes.set(chunk, offset);
600
- offset += chunk.byteLength;
601
- }
602
- return bytes;
603
- }
604
-
605
- async function fetchManifest(
606
- channel: DesktopReleaseChannel,
607
- fetchFn: typeof fetch,
608
- ): Promise<{ manifest: DesktopReleaseManifest; url: string }> {
609
- let lastError: DesktopUpdateError | null = null;
610
- for (const url of manifestUrls(channel)) {
611
- try {
612
- const fetched = await fetchBoundedText(url, fetchFn, MANIFEST_MAX_BYTES);
613
- let parsed: unknown;
614
- try {
615
- parsed = JSON.parse(fetched.text);
616
- } catch {
617
- throw new DesktopUpdateError("The release manifest is invalid.", 502, "invalid_manifest");
618
- }
619
- const manifest = validateDesktopReleaseManifest(parsed);
620
- return { manifest, url: fetched.finalUrl };
621
- } catch (error) {
622
- lastError = error instanceof DesktopUpdateError
623
- ? error
624
- : new DesktopUpdateError("The release manifest could not be fetched.", 502, "latest_unavailable");
625
- if (lastError.code === "invalid_manifest") throw lastError;
626
- }
627
- }
628
- throw lastError ?? new DesktopUpdateError("The release manifest could not be fetched.", 502, "latest_unavailable");
629
- }
630
-
631
- function artifactName(artifact: DesktopReleaseArtifact): string {
632
- return safeArtifactName(artifact.name, artifact.url, artifact.format);
633
- }
634
-
635
- export async function checkDesktopReleaseUpdate(
636
- requestedChannel: DesktopReleaseChannel = "latest",
637
- io: DesktopReleaseIo = {},
638
- ): Promise<DesktopUpdateCheckResult> {
639
- const now = io.now ?? Date.now;
640
- const currentVersion = io.currentVersion?.() ?? defaultCurrentVersion();
641
- const target = desktopTarget(io.platform ?? hostPlatform, io.arch ?? hostArch);
642
- const desktopShell = io.desktopShell?.() ?? defaultDesktopShell();
643
- const base = {
644
- currentVersion,
645
- latestVersion: null,
646
- channel: requestedChannel,
647
- platform: target.platform,
648
- arch: target.arch,
649
- targetKey: target.key,
650
- artifact: null,
651
- updateAvailable: false,
652
- canDownload: false,
653
- canInstall: false,
654
- desktopShell,
655
- releaseNotes: "",
656
- releaseNotesUrl: null,
657
- manifestUrl: null,
658
- checkedAt: new Date(now()).toISOString(),
659
- } satisfies DesktopUpdateCheckResult;
660
-
661
- let fetched: { manifest: DesktopReleaseManifest; url: string };
662
- try {
663
- fetched = await fetchManifest(requestedChannel, io.fetchFn ?? fetch);
664
- } catch (error) {
665
- const code = error instanceof DesktopUpdateError ? error.code : "latest_unavailable";
666
- return { ...base, reason: code };
667
- }
668
- return desktopReleaseCheckFromManifest(requestedChannel, fetched.manifest, fetched.url, {
669
- ...io,
670
- currentVersion: () => currentVersion,
671
- desktopShell: () => desktopShell,
672
- platform: io.platform ?? hostPlatform,
673
- arch: io.arch ?? hostArch,
674
- now,
675
- }, base);
676
- }
677
-
678
- function desktopReleaseCheckFromManifest(
679
- requestedChannel: DesktopReleaseChannel,
680
- manifest: DesktopReleaseManifest,
681
- url: string,
682
- io: DesktopReleaseIo,
683
- baseOverride?: DesktopUpdateCheckResult,
684
- ): DesktopUpdateCheckResult {
685
- const now = io.now ?? Date.now;
686
- const currentVersion = io.currentVersion?.() ?? defaultCurrentVersion();
687
- const target = desktopTarget(io.platform ?? hostPlatform, io.arch ?? hostArch);
688
- const desktopShell = io.desktopShell?.() ?? defaultDesktopShell();
689
- const base = baseOverride ?? {
690
- currentVersion,
691
- latestVersion: null,
692
- channel: requestedChannel,
693
- platform: target.platform,
694
- arch: target.arch,
695
- targetKey: target.key,
696
- artifact: null,
697
- updateAvailable: false,
698
- canDownload: false,
699
- canInstall: false,
700
- desktopShell,
701
- releaseNotes: "",
702
- releaseNotesUrl: null,
703
- manifestUrl: null,
704
- checkedAt: new Date(now()).toISOString(),
705
- };
706
- if (manifest.channel !== requestedChannel) {
707
- return { ...base, latestVersion: manifest.version, manifestUrl: url, releaseNotes: manifest.releaseNotes, releaseNotesUrl: manifest.releaseNotesUrl ?? null, reason: "latest_unavailable" };
708
- }
709
- const selected = target.key ? manifest.artifacts[target.key] : undefined;
710
- const updateAvailable = isNewerDesktopVersion(manifest.version, currentVersion, requestedChannel);
711
- // A missing target artifact matters only when there is a newer release to
712
- // install. If the installed version already matches the manifest, report
713
- // "up to date" rather than making a supported machine look unsupported.
714
- if (!updateAvailable) {
715
- return {
716
- ...base,
717
- latestVersion: manifest.version,
718
- updateAvailable: false,
719
- manifestUrl: url,
720
- releaseNotes: manifest.releaseNotes,
721
- releaseNotesUrl: manifest.releaseNotesUrl ?? null,
722
- reason: "already_latest",
723
- };
724
- }
725
- if (!selected) {
726
- return {
727
- ...base,
728
- latestVersion: manifest.version,
729
- updateAvailable,
730
- manifestUrl: url,
731
- releaseNotes: manifest.releaseNotes,
732
- releaseNotesUrl: manifest.releaseNotesUrl ?? null,
733
- reason: "unsupported_platform",
734
- };
735
- }
736
- const artifact = {
737
- format: selected.format,
738
- name: artifactName(selected),
739
- size: selected.size,
740
- sha256: selected.sha256,
741
- signatureRequired: selected.signature?.required === true,
742
- };
743
- return {
744
- ...base,
745
- latestVersion: manifest.version,
746
- updateAvailable,
747
- canDownload: updateAvailable,
748
- canInstall: updateAvailable && desktopShell,
749
- artifact,
750
- manifestUrl: url,
751
- releaseNotes: manifest.releaseNotes,
752
- releaseNotesUrl: manifest.releaseNotesUrl ?? null,
753
- ...(updateAvailable ? {} : { reason: "already_latest" }),
754
- };
755
- }
756
-
757
- function safeStateString(value: unknown, max = 2048): string {
758
- return typeof value === "string" && value.length <= max && !/[\0\r\n]/.test(value) ? value : "";
759
- }
760
-
761
- function safeReleaseNotes(value: unknown): string {
762
- return typeof value === "string"
763
- && !value.includes("\0")
764
- && Buffer.byteLength(value, "utf8") <= RELEASE_NOTES_MAX_BYTES
765
- ? value
766
- : "";
767
- }
768
-
769
- function sanitizeState(state: DesktopUpdateState): DesktopUpdateState {
770
- const phase = [
771
- "idle",
772
- "checking",
773
- "available",
774
- "downloading",
775
- "verifying",
776
- "ready",
777
- "installing",
778
- "up-to-date",
779
- "succeeded",
780
- "failed",
781
- ].includes(state.phase) ? state.phase : "failed";
782
- const platform = state.platform === "linux" || state.platform === "windows" || state.platform === "darwin"
783
- ? state.platform
784
- : null;
785
- const artifactFormat = state.artifactFormat && ALLOWED_FORMATS.has(state.artifactFormat)
786
- ? state.artifactFormat
787
- : null;
788
- const verification = ["pending", "sha256", "signature", "failed"].includes(state.verification)
789
- ? state.verification
790
- : "failed";
791
- const errorCode = state.errorCode && [
792
- "already_latest",
793
- "latest_unavailable",
794
- "invalid_manifest",
795
- "unsupported_platform",
796
- "desktop_required",
797
- "signature_unsupported",
798
- "download_failed",
799
- "checksum_mismatch",
800
- "install_failed",
801
- ].includes(state.errorCode) ? state.errorCode : undefined;
802
- return {
803
- schemaVersion: 1,
804
- id: safeStateString(state.id, 96),
805
- phase,
806
- currentVersion: isVersion(state.currentVersion) ? state.currentVersion : "0.0.0",
807
- latestVersion: state.latestVersion && isVersion(state.latestVersion) ? state.latestVersion : null,
808
- channel: state.channel === "preview" ? "preview" : "latest",
809
- platform,
810
- arch: safeStateString(state.arch, 32),
811
- targetKey: state.targetKey ? safeStateString(state.targetKey, 64) : null,
812
- artifactFormat,
813
- artifactName: state.artifactName ? safeStateString(state.artifactName, 180) : null,
814
- artifactSha256: typeof state.artifactSha256 === "string" && /^[a-f0-9]{64}$/i.test(state.artifactSha256)
815
- ? state.artifactSha256.toLowerCase()
816
- : null,
817
- totalBytes: typeof state.totalBytes === "number" && Number.isSafeInteger(state.totalBytes) && state.totalBytes > 0 ? state.totalBytes : null,
818
- downloadedBytes: Number.isSafeInteger(state.downloadedBytes) && state.downloadedBytes >= 0 ? state.downloadedBytes : 0,
819
- progress: typeof state.progress === "number" && Number.isFinite(state.progress) ? Math.max(0, Math.min(100, state.progress)) : null,
820
- verification,
821
- releaseNotes: safeReleaseNotes(state.releaseNotes),
822
- releaseNotesUrl: state.releaseNotesUrl && isSafeReleaseUrl(state.releaseNotesUrl) ? state.releaseNotesUrl : null,
823
- manifestUrl: state.manifestUrl && isSafeReleaseUrl(state.manifestUrl) ? state.manifestUrl : null,
824
- startedAt: safeStateString(state.startedAt, 64),
825
- updatedAt: safeStateString(state.updatedAt, 64),
826
- checkedAt: state.checkedAt ? safeStateString(state.checkedAt, 64) : null,
827
- ...(typeof state.pid === "number" && Number.isSafeInteger(state.pid) && state.pid > 0 ? { pid: state.pid } : {}),
828
- ...(errorCode ? { errorCode } : {}),
829
- ...(state.restartRequired ? { restartRequired: true } : {}),
830
- };
831
- }
832
-
833
- function writeState(state: DesktopUpdateState): DesktopUpdateState {
834
- const path = updateStatePath();
835
- if (existsSync(path)) {
836
- const stat = lstatSync(path);
837
- if (!stat.isFile() || stat.isSymbolicLink()) {
838
- throw new DesktopUpdateError("The desktop update state file is unavailable.", 500, "download_failed");
839
- }
840
- }
841
- const sanitized = sanitizeState(state);
842
- atomicWriteFile(path, `${JSON.stringify(sanitized, null, 2)}\n`);
843
- return sanitized;
844
- }
845
-
846
- function recoverStaleDesktopUpdateState(
847
- state: DesktopUpdateState,
848
- now = Date.now(),
849
- ): DesktopUpdateState {
850
- if (!["checking", "downloading", "verifying", "installing"].includes(state.phase)) return state;
851
-
852
- const updatedAt = Date.parse(state.updatedAt);
853
- const stale = typeof state.pid === "number"
854
- ? !isProcessAlive(state.pid)
855
- : !Number.isFinite(updatedAt) || now < updatedAt || now - updatedAt >= ACTIVE_JOB_STALE_MS;
856
- if (!stale) return state;
857
-
858
- const recovered: DesktopUpdateState = {
859
- ...state,
860
- phase: "failed",
861
- verification: "failed",
862
- errorCode: state.phase === "installing" ? "install_failed" : "download_failed",
863
- pid: undefined,
864
- updatedAt: new Date(now).toISOString(),
865
- };
866
- try {
867
- return writeState(recovered);
868
- } catch {
869
- // Keep the read useful even if a transient config/state write error prevents
870
- // persisting recovery. The next write-capable operation can retry it.
871
- return sanitizeState(recovered);
872
- }
873
- }
874
-
875
- export function readDesktopUpdateState(now = Date.now()): DesktopUpdateState | null {
876
- try {
877
- const path = updateStatePath();
878
- const stat = lstatSync(path);
879
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MANIFEST_MAX_BYTES) return null;
880
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<DesktopUpdateState>;
881
- if (!parsed || parsed.schemaVersion !== 1 || typeof parsed.id !== "string" || typeof parsed.phase !== "string") return null;
882
- if (!/^desktop-\d+-[a-z0-9]{1,32}$/.test(parsed.id)) return null;
883
- if (!["idle", "checking", "available", "downloading", "verifying", "ready", "installing", "up-to-date", "succeeded", "failed"].includes(parsed.phase)) return null;
884
- if (parsed.channel !== "latest" && parsed.channel !== "preview") return null;
885
- return recoverStaleDesktopUpdateState(sanitizeState(parsed as DesktopUpdateState), now);
886
- } catch {
887
- return null;
888
- }
889
- }
890
-
891
- function patchState(id: string, patch: Partial<DesktopUpdateState>, now = Date.now()): DesktopUpdateState | null {
892
- const current = readDesktopUpdateState(now);
893
- if (!current || current.id !== id) return null;
894
- return writeState({ ...current, ...patch, updatedAt: new Date(now).toISOString() });
895
- }
896
-
897
- function newJobId(): string {
898
- return `desktop-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
899
- }
900
-
901
- function artifactPath(id: string, format: DesktopArtifactFormat): string {
902
- ensureUpdateDirectory();
903
- return join(desktopUpdateDirectory(), `${id}${FORMAT_EXTENSIONS[format]}`);
904
- }
905
-
906
- function artifactTempPath(id: string, format: DesktopArtifactFormat): string {
907
- ensureUpdateDirectory();
908
- return join(desktopUpdateDirectory(), `${id}${FORMAT_EXTENSIONS[format]}.part`);
909
- }
910
-
911
- function removeFileSafely(path: string): void {
912
- try {
913
- const stat = lstatSync(path);
914
- if (!stat.isFile() || stat.isSymbolicLink()) return;
915
- truncateSync(path, 0);
916
- unlinkSync(path);
917
- } catch {
918
- /* best effort cleanup */
919
- }
920
- }
921
-
922
- function removeDesktopUpdateArtifacts(state: DesktopUpdateState | null): void {
923
- if (!state?.artifactFormat) return;
924
- removeFileSafely(artifactPath(state.id, state.artifactFormat));
925
- removeFileSafely(artifactTempPath(state.id, state.artifactFormat));
926
- }
927
-
928
- function writeChunk(fd: number, chunk: Uint8Array): void {
929
- let offset = 0;
930
- while (offset < chunk.byteLength) {
931
- const written = writeSync(fd, chunk, offset, chunk.byteLength - offset);
932
- if (written <= 0) throw new Error("download write failed");
933
- offset += written;
934
- }
935
- }
936
-
937
- async function fetchArtifactResponse(url: string, fetchFn: typeof fetch): Promise<Response> {
938
- let current = url;
939
- for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
940
- if (!isTrustedReleaseRepositoryUrl(current)) throw new DesktopUpdateError("The artifact URL is not trusted.", 502, "invalid_manifest");
941
- const response = await fetchFn(current, {
942
- redirect: "manual",
943
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
944
- });
945
- if (response.status >= 300 && response.status < 400) {
946
- const location = response.headers.get("location");
947
- if (!location || redirect === MAX_REDIRECTS) throw new DesktopUpdateError("The artifact could not be downloaded.", 502, "download_failed");
948
- const next = new URL(location, current).toString();
949
- if (!isTrustedReleaseRepositoryUrl(next)) throw new DesktopUpdateError("The artifact URL is not trusted.", 502, "invalid_manifest");
950
- current = next;
951
- continue;
952
- }
953
- if (!response.ok) throw new DesktopUpdateError("The artifact could not be downloaded.", 502, "download_failed");
954
- if (response.url && !isTrustedReleaseRepositoryUrl(response.url)) throw new DesktopUpdateError("The artifact URL is not trusted.", 502, "invalid_manifest");
955
- return response;
956
- }
957
- throw new DesktopUpdateError("The artifact could not be downloaded.", 502, "download_failed");
958
- }
959
-
960
- async function downloadArtifact(
961
- id: string,
962
- artifact: DesktopReleaseArtifact,
963
- onProgress: (downloaded: number) => void,
964
- onVerifying: () => void,
965
- fetchFn: typeof fetch,
966
- ): Promise<string> {
967
- const destination = artifactPath(id, artifact.format);
968
- const temporary = artifactTempPath(id, artifact.format);
969
- removeFileSafely(temporary);
970
- removeFileSafely(destination);
971
- const response = await fetchArtifactResponse(artifact.url, fetchFn);
972
- const contentLength = Number(response.headers.get("content-length") ?? "0");
973
- if (Number.isSafeInteger(contentLength) && contentLength > 0 && contentLength > artifact.size) {
974
- throw new DesktopUpdateError("The downloaded artifact is larger than expected.", 502, "download_failed");
975
- }
976
- if (!response.body) throw new DesktopUpdateError("The artifact response was empty.", 502, "download_failed");
977
- const reader = response.body.getReader();
978
- const hash = createHash("sha256");
979
- let fd: number | null = null;
980
- let downloaded = 0;
981
- let streamError: unknown;
982
- try {
983
- fd = openSync(temporary, "wx", 0o600);
984
- for (;;) {
985
- const next = await reader.read();
986
- if (next.done) break;
987
- const chunk = next.value;
988
- downloaded += chunk.byteLength;
989
- if (downloaded > artifact.size || downloaded > MAX_ARTIFACT_BYTES) {
990
- throw new DesktopUpdateError("The downloaded artifact is larger than expected.", 502, "download_failed");
991
- }
992
- hash.update(chunk);
993
- writeChunk(fd, chunk);
994
- onProgress(downloaded);
995
- }
996
- } catch (error) {
997
- streamError = error;
998
- } finally {
999
- try { await reader.cancel(); } catch { /* stream already closed */ }
1000
- if (fd !== null) closeSync(fd);
1001
- }
1002
- if (streamError !== undefined) {
1003
- removeFileSafely(temporary);
1004
- throw streamError;
1005
- }
1006
- if (downloaded !== artifact.size) {
1007
- removeFileSafely(temporary);
1008
- throw new DesktopUpdateError("The downloaded artifact size did not match the manifest.", 502, "download_failed");
1009
- }
1010
- onVerifying();
1011
- if (hash.digest("hex").toLowerCase() !== artifact.sha256) {
1012
- removeFileSafely(temporary);
1013
- throw new DesktopUpdateError("The downloaded artifact checksum did not match.", 502, "checksum_mismatch");
1014
- }
1015
- try {
1016
- renameSync(temporary, destination);
1017
- } catch (error) {
1018
- removeFileSafely(temporary);
1019
- throw error;
1020
- }
1021
- const finalStat = lstatSync(destination);
1022
- if (!finalStat.isFile() || finalStat.isSymbolicLink() || finalStat.size !== artifact.size) {
1023
- removeFileSafely(destination);
1024
- throw new DesktopUpdateError("The downloaded artifact could not be verified.", 502, "checksum_mismatch");
1025
- }
1026
- return destination;
1027
- }
1028
-
1029
- async function verifyDownloadedArtifact(
1030
- path: string,
1031
- expectedSize: number,
1032
- expectedSha256: string,
1033
- ): Promise<boolean> {
1034
- let before;
1035
- try {
1036
- before = lstatSync(path);
1037
- } catch {
1038
- return false;
1039
- }
1040
- if (!before.isFile() || before.isSymbolicLink() || before.size !== expectedSize) return false;
1041
-
1042
- const hash = createHash("sha256");
1043
- let bytes = 0;
1044
- try {
1045
- for await (const chunk of createReadStream(path)) {
1046
- const buffer = chunk as Buffer;
1047
- bytes += buffer.byteLength;
1048
- if (bytes > expectedSize) return false;
1049
- hash.update(buffer);
1050
- }
1051
- } catch {
1052
- return false;
1053
- }
1054
- if (bytes !== expectedSize || hash.digest("hex").toLowerCase() !== expectedSha256.toLowerCase()) {
1055
- return false;
1056
- }
1057
- try {
1058
- const after = lstatSync(path);
1059
- return after.isFile()
1060
- && !after.isSymbolicLink()
1061
- && after.size === before.size
1062
- && after.mtimeMs === before.mtimeMs;
1063
- } catch {
1064
- return false;
1065
- }
1066
- }
1067
-
1068
- function trustedExecutable(candidates: string[]): string | null {
1069
- return candidates.find(candidate => existsSync(candidate) && (() => {
1070
- try {
1071
- return statSync(candidate).isFile();
1072
- } catch {
1073
- return false;
1074
- }
1075
- })()) ?? null;
1076
- }
1077
-
1078
- async function spawnAndWait(command: string, args: string[]): Promise<boolean> {
1079
- return await new Promise(resolve => {
1080
- let settled = false;
1081
- const child = spawn(command, args, { stdio: "ignore", windowsHide: true });
1082
- const finish = (ok: boolean) => {
1083
- if (settled) return;
1084
- settled = true;
1085
- clearTimeout(timer);
1086
- resolve(ok);
1087
- };
1088
- const timer = setTimeout(() => {
1089
- try { child.kill(); } catch { /* process already exited */ }
1090
- finish(false);
1091
- }, INSTALL_TIMEOUT_MS);
1092
- timer.unref?.();
1093
- child.once("error", () => finish(false));
1094
- child.once("exit", code => finish(code === 0));
1095
- });
1096
- }
1097
-
1098
- async function defaultInstaller(format: DesktopArtifactFormat, path: string): Promise<InstallResult> {
1099
- // The generated path is inside the owner-only update directory and the
1100
- // command/arguments are fixed by the validated artifact format.
1101
- if (process.platform === "linux") {
1102
- if (format === "deb") {
1103
- const pkexec = trustedExecutable(["/usr/bin/pkexec", "/bin/pkexec"]);
1104
- const dpkg = trustedExecutable(["/usr/bin/dpkg", "/bin/dpkg"]);
1105
- if (!pkexec || !dpkg) return { ok: false, code: "install_failed" };
1106
- const ok = await spawnAndWait(pkexec, [dpkg, "--install", path]);
1107
- return ok ? { ok: true, restartRequired: true } : { ok: false, code: "install_failed" };
1108
- }
1109
- if (format === "rpm") {
1110
- const pkexec = trustedExecutable(["/usr/bin/pkexec", "/bin/pkexec"]);
1111
- const rpm = trustedExecutable(["/usr/bin/rpm", "/bin/rpm"]);
1112
- if (!pkexec || !rpm) return { ok: false, code: "install_failed" };
1113
- const ok = await spawnAndWait(pkexec, [rpm, "--upgrade", path]);
1114
- return ok ? { ok: true, restartRequired: true } : { ok: false, code: "install_failed" };
1115
- }
1116
- return { ok: false, code: "desktop_required" };
1117
- }
1118
- if (process.platform === "win32") {
1119
- if (format === "msi") {
1120
- const msiexec = trustedExecutable([
1121
- `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\msiexec.exe`,
1122
- ]);
1123
- if (!msiexec) return { ok: false, code: "desktop_required" };
1124
- const ok = await spawnAndWait(msiexec, ["/i", path]);
1125
- return ok ? { ok: true, restartRequired: true } : { ok: false, code: "install_failed" };
1126
- }
1127
- if (format === "exe") {
1128
- const child = spawn(path, [], { detached: true, stdio: "ignore", windowsHide: false });
1129
- child.unref();
1130
- return { ok: true, restartRequired: true };
1131
- }
1132
- return { ok: false, code: "desktop_required" };
1133
- }
1134
- if (process.platform === "darwin") {
1135
- const open = trustedExecutable(["/usr/bin/open"]);
1136
- if (!open) return { ok: false, code: "desktop_required" };
1137
- if (format === "pkg") {
1138
- const ok = await spawnAndWait(open, [path]);
1139
- return ok ? { ok: true, restartRequired: true } : { ok: false, code: "install_failed" };
1140
- }
1141
- if (format === "dmg") {
1142
- const child = spawn(open, [path], { detached: true, stdio: "ignore" });
1143
- child.unref();
1144
- return { ok: true, restartRequired: true };
1145
- }
1146
- }
1147
- return { ok: false, code: "desktop_required" };
1148
- }
1149
-
1150
- function initialState(id: string, channel: DesktopReleaseChannel, now: number, io: DesktopReleaseIo): DesktopUpdateState {
1151
- const target = desktopTarget(io.platform ?? hostPlatform, io.arch ?? hostArch);
1152
- return {
1153
- schemaVersion: 1,
1154
- id,
1155
- phase: "checking",
1156
- currentVersion: io.currentVersion?.() ?? defaultCurrentVersion(),
1157
- latestVersion: null,
1158
- channel,
1159
- platform: target.platform,
1160
- arch: target.arch,
1161
- targetKey: target.key,
1162
- artifactFormat: null,
1163
- artifactName: null,
1164
- artifactSha256: null,
1165
- totalBytes: null,
1166
- downloadedBytes: 0,
1167
- progress: null,
1168
- verification: "pending",
1169
- releaseNotes: "",
1170
- releaseNotesUrl: null,
1171
- manifestUrl: null,
1172
- startedAt: new Date(now).toISOString(),
1173
- updatedAt: new Date(now).toISOString(),
1174
- checkedAt: null,
1175
- };
1176
- }
1177
-
1178
- function safeIsoTimestamp(value: unknown): string | null {
1179
- if (typeof value !== "string" || value.length > 64 || /[\0\r\n]/.test(value)) return null;
1180
- const parsed = Date.parse(value);
1181
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
1182
- }
1183
-
1184
- function readAutoUpdateSchedule(): DesktopAutoUpdateSchedule | null {
1185
- try {
1186
- const path = autoUpdateSchedulePath();
1187
- const stat = lstatSync(path);
1188
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MANIFEST_MAX_BYTES) return null;
1189
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<DesktopAutoUpdateSchedule>;
1190
- const lastAttemptAt = safeIsoTimestamp(parsed.lastAttemptAt);
1191
- if (parsed.schemaVersion !== 1 || !lastAttemptAt) return null;
1192
- const lastSuccessfulCheckAt = safeIsoTimestamp(parsed.lastSuccessfulCheckAt);
1193
- const lastNotifiedVersion = isVersion(parsed.lastNotifiedVersion)
1194
- ? parsed.lastNotifiedVersion
1195
- : undefined;
1196
- return {
1197
- schemaVersion: 1,
1198
- lastAttemptAt,
1199
- ...(lastSuccessfulCheckAt ? { lastSuccessfulCheckAt } : {}),
1200
- ...(lastNotifiedVersion ? { lastNotifiedVersion } : {}),
1201
- };
1202
- } catch {
1203
- return null;
1204
- }
1205
- }
1206
-
1207
- function writeAutoUpdateSchedule(schedule: DesktopAutoUpdateSchedule): DesktopAutoUpdateSchedule {
1208
- const sanitized: DesktopAutoUpdateSchedule = {
1209
- schemaVersion: 1,
1210
- lastAttemptAt: safeIsoTimestamp(schedule.lastAttemptAt) ?? new Date().toISOString(),
1211
- ...(safeIsoTimestamp(schedule.lastSuccessfulCheckAt)
1212
- ? { lastSuccessfulCheckAt: safeIsoTimestamp(schedule.lastSuccessfulCheckAt)! }
1213
- : {}),
1214
- ...(isVersion(schedule.lastNotifiedVersion)
1215
- ? { lastNotifiedVersion: schedule.lastNotifiedVersion }
1216
- : {}),
1217
- };
1218
- atomicWriteFile(autoUpdateSchedulePath(), `${JSON.stringify(sanitized, null, 2)}\n`);
1219
- return sanitized;
1220
- }
1221
-
1222
- function mostRecentAutomaticCheckAt(
1223
- schedule: DesktopAutoUpdateSchedule | null,
1224
- state: DesktopUpdateState | null,
1225
- ): number | null {
1226
- const candidates = [
1227
- schedule?.lastAttemptAt,
1228
- state?.checkedAt,
1229
- ]
1230
- .map(value => typeof value === "string" ? Date.parse(value) : Number.NaN)
1231
- .filter(value => Number.isFinite(value));
1232
- return candidates.length > 0 ? Math.max(...candidates) : null;
1233
- }
1234
-
1235
- function automaticDesktopUpdateDue(
1236
- schedule: DesktopAutoUpdateSchedule | null,
1237
- state: DesktopUpdateState | null,
1238
- now: number,
1239
- ): boolean {
1240
- const last = mostRecentAutomaticCheckAt(schedule, state);
1241
- if (last === null) return true;
1242
- if (now <= last) return false;
1243
- return now - last >= DESKTOP_AUTO_UPDATE_INTERVAL_MS;
1244
- }
1245
-
1246
- function desktopUpdateStateFingerprint(state: DesktopUpdateState | null): string {
1247
- return state ? `${state.id}\0${state.phase}\0${state.updatedAt}` : "";
1248
- }
1249
-
1250
- function automaticStateFromCheck(
1251
- check: DesktopUpdateCheckResult,
1252
- now: number,
1253
- ): DesktopUpdateState {
1254
- const available = check.updateAvailable && check.canDownload && check.artifact !== null;
1255
- return {
1256
- schemaVersion: 1,
1257
- id: newJobId(),
1258
- phase: available ? "available" : "up-to-date",
1259
- currentVersion: check.currentVersion,
1260
- latestVersion: check.latestVersion,
1261
- channel: check.channel,
1262
- platform: check.platform,
1263
- arch: check.arch,
1264
- targetKey: check.targetKey,
1265
- artifactFormat: available ? check.artifact?.format ?? null : null,
1266
- artifactName: available ? check.artifact?.name ?? null : null,
1267
- artifactSha256: available ? check.artifact?.sha256 ?? null : null,
1268
- totalBytes: available ? check.artifact?.size ?? null : null,
1269
- downloadedBytes: 0,
1270
- progress: null,
1271
- verification: "pending",
1272
- releaseNotes: check.releaseNotes,
1273
- releaseNotesUrl: check.releaseNotesUrl,
1274
- manifestUrl: check.manifestUrl,
1275
- startedAt: check.checkedAt,
1276
- updatedAt: new Date(now).toISOString(),
1277
- checkedAt: check.checkedAt,
1278
- };
1279
- }
1280
-
1281
- /**
1282
- * Background desktop check used by the native shell.
1283
- *
1284
- * It never downloads or installs an artifact. A successful supported update is
1285
- * persisted as `available`, allowing the existing tray/dashboard action to
1286
- * download it only after the user clicks. Attempts are persisted before the
1287
- * network request so restarting the application cannot turn startup checks
1288
- * into an accidental polling loop.
1289
- */
1290
- export async function runAutomaticDesktopUpdateCheck(
1291
- channel: DesktopReleaseChannel = "latest",
1292
- io: DesktopReleaseIo = {},
1293
- ): Promise<AutomaticDesktopUpdateCheckResult> {
1294
- const now = io.now ?? Date.now;
1295
- const startedAt = now();
1296
- const previous = readDesktopUpdateState(startedAt);
1297
- if (activeState(previous, io) || previous?.phase === "ready") {
1298
- return {
1299
- checked: false,
1300
- updateAvailable: previous?.latestVersion !== null && previous?.phase === "ready",
1301
- latestVersion: previous?.latestVersion ?? null,
1302
- notify: false,
1303
- outcome: "busy",
1304
- };
1305
- }
1306
-
1307
- const schedule = readAutoUpdateSchedule();
1308
- if (!automaticDesktopUpdateDue(schedule, previous, startedAt)) {
1309
- return {
1310
- checked: false,
1311
- updateAvailable: previous?.phase === "available",
1312
- latestVersion: previous?.latestVersion ?? null,
1313
- notify: false,
1314
- outcome: "not_due",
1315
- };
1316
- }
1317
-
1318
- const attemptAt = new Date(startedAt).toISOString();
1319
- writeAutoUpdateSchedule({
1320
- schemaVersion: 1,
1321
- lastAttemptAt: attemptAt,
1322
- ...(schedule?.lastSuccessfulCheckAt
1323
- ? { lastSuccessfulCheckAt: schedule.lastSuccessfulCheckAt }
1324
- : {}),
1325
- ...(schedule?.lastNotifiedVersion
1326
- ? { lastNotifiedVersion: schedule.lastNotifiedVersion }
1327
- : {}),
1328
- });
1329
-
1330
- const baseline = desktopUpdateStateFingerprint(previous);
1331
- const check = await checkDesktopReleaseUpdate(channel, {
1332
- ...io,
1333
- desktopShell: () => false,
1334
- });
1335
- const supportedUpdate = check.updateAvailable && check.canDownload && check.artifact !== null;
1336
- const successful = check.reason === "already_latest" || supportedUpdate;
1337
- if (!successful) {
1338
- return {
1339
- checked: true,
1340
- updateAvailable: false,
1341
- latestVersion: check.latestVersion,
1342
- notify: false,
1343
- outcome: "failed",
1344
- ...(check.reason ? { reason: check.reason } : {}),
1345
- };
1346
- }
1347
-
1348
- const current = readDesktopUpdateState(now());
1349
- if (desktopUpdateStateFingerprint(current) !== baseline) {
1350
- writeAutoUpdateSchedule({
1351
- schemaVersion: 1,
1352
- lastAttemptAt: attemptAt,
1353
- lastSuccessfulCheckAt: check.checkedAt,
1354
- ...(schedule?.lastNotifiedVersion
1355
- ? { lastNotifiedVersion: schedule.lastNotifiedVersion }
1356
- : {}),
1357
- });
1358
- return {
1359
- checked: true,
1360
- updateAvailable: supportedUpdate,
1361
- latestVersion: check.latestVersion,
1362
- notify: false,
1363
- outcome: "state_changed",
1364
- };
1365
- }
1366
-
1367
- removeDesktopUpdateArtifacts(previous);
1368
- writeState(automaticStateFromCheck(check, now()));
1369
- const notify = supportedUpdate
1370
- && check.latestVersion !== null
1371
- && schedule?.lastNotifiedVersion !== check.latestVersion;
1372
- writeAutoUpdateSchedule({
1373
- schemaVersion: 1,
1374
- lastAttemptAt: attemptAt,
1375
- lastSuccessfulCheckAt: check.checkedAt,
1376
- ...(notify && check.latestVersion
1377
- ? { lastNotifiedVersion: check.latestVersion }
1378
- : schedule?.lastNotifiedVersion
1379
- ? { lastNotifiedVersion: schedule.lastNotifiedVersion }
1380
- : {}),
1381
- });
1382
- return {
1383
- checked: true,
1384
- updateAvailable: supportedUpdate,
1385
- latestVersion: check.latestVersion,
1386
- notify,
1387
- outcome: "checked",
1388
- };
1389
- }
1390
-
1391
- function activeState(state: DesktopUpdateState | null, io: DesktopReleaseIo): boolean {
1392
- if (!state || !["checking", "downloading", "verifying", "installing"].includes(state.phase)) return false;
1393
- if (typeof state.pid !== "number") {
1394
- const updatedAt = Date.parse(state.updatedAt);
1395
- const now = (io.now ?? Date.now)();
1396
- return Number.isFinite(updatedAt)
1397
- && now >= updatedAt
1398
- && now - updatedAt < ACTIVE_JOB_STALE_MS;
1399
- }
1400
- return (io.isAlive ?? isProcessAlive)(state.pid);
1401
- }
1402
-
1403
- export function spawnDesktopUpdateWorker(id: string, install: boolean): { pid?: number; unref(): void } {
1404
- const entry = process.argv[1];
1405
- if (!entry) throw new DesktopUpdateError("The desktop update worker could not start.", 500, "download_failed");
1406
- const child = spawn(process.execPath, [entry, "__desktop-update-worker", id, install ? "install" : "download"], {
1407
- detached: true,
1408
- stdio: "ignore",
1409
- windowsHide: true,
1410
- env: { ...process.env, OCX_DESKTOP_UPDATE: "1" },
1411
- });
1412
- return child;
1413
- }
1414
-
1415
- export function startDesktopUpdateJob(
1416
- channel: DesktopReleaseChannel = "latest",
1417
- options: StartDesktopUpdateOptions = {},
1418
- io: DesktopReleaseIo = {},
1419
- ): DesktopUpdateState {
1420
- const install = options.install === true;
1421
- const now = io.now ?? Date.now;
1422
- const desktopShell = io.desktopShell?.() ?? defaultDesktopShell();
1423
- if (install && !desktopShell) {
1424
- throw new DesktopUpdateError("The desktop application is required to install this release.", 409, "desktop_required");
1425
- }
1426
- const previous = readDesktopUpdateState(now());
1427
- if (activeState(previous, io)) {
1428
- throw new DesktopUpdateError("A desktop update is already in progress.", 409, "download_failed");
1429
- }
1430
- if (install && options.expectedReady) {
1431
- const expected = options.expectedReady;
1432
- if (
1433
- channel !== expected.channel
1434
- || !/^desktop-\d+-[a-z0-9]{1,32}$/.test(expected.id)
1435
- || previous?.phase !== "ready"
1436
- || previous.id !== expected.id
1437
- || previous.channel !== expected.channel
1438
- ) {
1439
- throw new DesktopUpdateError(
1440
- "The selected desktop update is no longer ready. Check for updates again.",
1441
- 409,
1442
- "download_failed",
1443
- );
1444
- }
1445
- }
1446
- if (previous?.phase === "ready") {
1447
- // A download-only request follows a fresh manifest check in the dashboard
1448
- // and management API. Never reuse a ready artifact for that path: the
1449
- // manifest may have advanced while the persisted artifact is still ready.
1450
- // The tray's second click uses `install: true`, so it continues to install
1451
- // the exact verified record instead of downloading it again.
1452
- if (
1453
- install
1454
- && (
1455
- !previous.artifactFormat
1456
- || !previous.artifactSha256
1457
- || !previous.totalBytes
1458
- )
1459
- ) {
1460
- throw new DesktopUpdateError("The verified desktop update is incomplete. Download it again.", 409, "download_failed");
1461
- }
1462
- if (install) {
1463
- const installing = patchState(previous.id, {
1464
- phase: "installing",
1465
- progress: 100,
1466
- verification: "sha256",
1467
- errorCode: undefined,
1468
- pid: process.pid,
1469
- }, now()) ?? previous;
1470
- try {
1471
- const worker = (io.spawnWorker ?? spawnDesktopUpdateWorker)(previous.id, true);
1472
- if (typeof worker.pid === "number" && worker.pid > 0) patchState(previous.id, { pid: worker.pid }, now());
1473
- worker.unref();
1474
- } catch {
1475
- patchState(previous.id, { phase: "failed", errorCode: "install_failed" }, now());
1476
- throw new DesktopUpdateError("The desktop update worker could not start.", 500, "install_failed");
1477
- }
1478
- return readDesktopUpdateState(now()) ?? installing;
1479
- }
1480
- }
1481
- if (install) {
1482
- throw new DesktopUpdateError("Download and verify the desktop update before installing it.", 409, "download_failed");
1483
- }
1484
- const startedAt = now();
1485
- const id = newJobId();
1486
- const job = writeState(initialState(id, channel, startedAt, io));
1487
- removeDesktopUpdateArtifacts(previous);
1488
- try {
1489
- const worker = (io.spawnWorker ?? spawnDesktopUpdateWorker)(id, false);
1490
- if (typeof worker.pid === "number" && worker.pid > 0) patchState(id, { pid: worker.pid }, now());
1491
- worker.unref();
1492
- } catch {
1493
- patchState(id, { phase: "failed", errorCode: "download_failed" }, now());
1494
- throw new DesktopUpdateError("The desktop update worker could not start.", 500, "download_failed");
1495
- }
1496
- return readDesktopUpdateState(now()) ?? job;
1497
- }
1498
-
1499
- export async function runDesktopUpdateWorker(
1500
- id: string,
1501
- install: boolean,
1502
- io: DesktopReleaseIo = {},
1503
- ): Promise<void> {
1504
- let state = readDesktopUpdateState();
1505
- if (!state || state.id !== id) return;
1506
- try {
1507
- if (install) {
1508
- if (
1509
- state.phase !== "installing"
1510
- || !state.artifactFormat
1511
- || !state.artifactSha256
1512
- || !state.totalBytes
1513
- ) {
1514
- patchState(id, { phase: "failed", errorCode: "install_failed" });
1515
- return;
1516
- }
1517
- const destination = artifactPath(id, state.artifactFormat);
1518
- if (!await verifyDownloadedArtifact(destination, state.totalBytes, state.artifactSha256)) {
1519
- removeFileSafely(destination);
1520
- patchState(id, {
1521
- phase: "failed",
1522
- verification: "failed",
1523
- errorCode: "checksum_mismatch",
1524
- });
1525
- return;
1526
- }
1527
- const result = await (io.installer ?? defaultInstaller)(state.artifactFormat, destination);
1528
- if (!result.ok) {
1529
- patchState(id, {
1530
- phase: "failed",
1531
- verification: "failed",
1532
- errorCode: result.code ?? "install_failed",
1533
- });
1534
- return;
1535
- }
1536
- patchState(id, {
1537
- phase: "succeeded",
1538
- verification: "sha256",
1539
- restartRequired: result.restartRequired !== false,
1540
- errorCode: undefined,
1541
- });
1542
- return;
1543
- }
1544
-
1545
- const fetched = await fetchManifest(state.channel, io.fetchFn ?? fetch);
1546
- const check = desktopReleaseCheckFromManifest(state.channel, fetched.manifest, fetched.url, io);
1547
- state = patchState(id, {
1548
- currentVersion: check.currentVersion,
1549
- latestVersion: check.latestVersion,
1550
- platform: check.platform,
1551
- arch: check.arch,
1552
- targetKey: check.targetKey,
1553
- artifactFormat: check.artifact?.format ?? null,
1554
- artifactName: check.artifact?.name ?? null,
1555
- artifactSha256: check.artifact?.sha256 ?? null,
1556
- totalBytes: check.artifact?.size ?? null,
1557
- releaseNotes: check.releaseNotes,
1558
- releaseNotesUrl: check.releaseNotesUrl,
1559
- manifestUrl: check.manifestUrl,
1560
- checkedAt: check.checkedAt,
1561
- phase: check.reason === "already_latest" ? "up-to-date" : check.updateAvailable ? "downloading" : "failed",
1562
- errorCode: check.reason && check.reason !== "already_latest" ? check.reason : undefined,
1563
- }) ?? state;
1564
- if (!check.updateAvailable || !check.artifact || !check.canDownload) {
1565
- if (check.reason === "already_latest") patchState(id, { phase: "up-to-date", errorCode: undefined });
1566
- else patchState(id, { phase: "failed", errorCode: check.reason ?? "latest_unavailable" });
1567
- return;
1568
- }
1569
- const manifestArtifact = fetched.manifest.artifacts[check.targetKey ?? ""];
1570
- if (!manifestArtifact) {
1571
- patchState(id, { phase: "failed", errorCode: "unsupported_platform" });
1572
- return;
1573
- }
1574
- const destination = await downloadArtifact(
1575
- id,
1576
- manifestArtifact,
1577
- downloaded => {
1578
- const total = manifestArtifact.size;
1579
- patchState(id, {
1580
- phase: "downloading",
1581
- downloadedBytes: downloaded,
1582
- progress: total > 0 ? Math.min(100, (downloaded / total) * 100) : null,
1583
- });
1584
- },
1585
- () => {
1586
- patchState(id, {
1587
- phase: "verifying",
1588
- downloadedBytes: manifestArtifact.size,
1589
- progress: 100,
1590
- verification: "pending",
1591
- });
1592
- },
1593
- io.fetchFn ?? fetch,
1594
- );
1595
- if (manifestArtifact.signature?.required === true) {
1596
- removeFileSafely(destination);
1597
- patchState(id, { phase: "failed", verification: "failed", errorCode: "signature_unsupported" });
1598
- return;
1599
- }
1600
- if (!install) {
1601
- patchState(id, { phase: "ready", verification: "sha256", errorCode: undefined });
1602
- return;
1603
- }
1604
- } catch (error) {
1605
- const code = error instanceof DesktopUpdateError ? error.code : "download_failed";
1606
- patchState(id, {
1607
- phase: "failed",
1608
- verification: code === "checksum_mismatch" || code === "signature_unsupported" ? "failed" : state.verification,
1609
- errorCode: code,
1610
- });
1611
- }
1612
- }
1613
-
1614
- /**
1615
- * Decide what a tray click should do. The first click checks/downloads; a
1616
- * second click after verification hands the artifact to the platform installer.
1617
- */
1618
- export function desktopUpdateInstallAction(state: DesktopUpdateState | null): boolean {
1619
- return state?.phase === "ready";
1620
- }