Skip to content

Commit c0e2ef0

Browse files
authored
ADE-119 Surface disk-space failures and recovery in desktop auto-update UX -> Primary (#776)
* Refs ADE-119: Surface auto-update disk space failures and recovery * Refs ADE-119: Preserve cached update recovery without download preflight * Refs ADE-119: Keep slow update refreshes outside install watchdog * Refs ADE-119: Bound native updater handoff without losing recovery state * Refs ADE-119: Preserve verified updates across retry feed failures
1 parent 9b977ee commit c0e2ef0

11 files changed

Lines changed: 1528 additions & 106 deletions

File tree

apps/desktop/src/main/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,6 +2009,7 @@ app.whenReady().then(async () => {
20092009
currentVersion: app.getVersion(),
20102010
globalStatePath,
20112011
updaterCacheDir: app.isPackaged ? resolveAutoUpdaterCacheDir() : undefined,
2012+
installTargetPath: process.execPath,
20122013
autoCheckEnabled: app.isPackaged,
20132014
beforeQuitAndInstall: prepareAutoUpdateInstall,
20142015
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import type { UpdateInfo } from "electron-updater";
4+
import type { AutoUpdateErrorKind, AutoUpdatePhase } from "../../../shared/types";
5+
6+
const MIB = 1024 * 1024;
7+
const DEFAULT_UPDATE_DOWNLOAD_BYTES = 512 * MIB;
8+
const UPDATE_SPACE_HEADROOM_BYTES = 512 * MIB;
9+
10+
export type DiskSpaceInfo = {
11+
availableBytes: number;
12+
volumePath: string;
13+
};
14+
15+
function nearestExistingPath(targetPath: string): string {
16+
let candidate = path.resolve(targetPath);
17+
while (!fs.existsSync(candidate)) {
18+
const parent = path.dirname(candidate);
19+
if (parent === candidate) return candidate;
20+
candidate = parent;
21+
}
22+
return candidate;
23+
}
24+
25+
export function readDiskSpace(targetPath: string): DiskSpaceInfo {
26+
const volumePath = nearestExistingPath(targetPath);
27+
const stats = fs.statfsSync(volumePath, { bigint: true });
28+
return {
29+
availableBytes: Number(stats.bavail * stats.bsize),
30+
volumePath,
31+
};
32+
}
33+
34+
export function finitePositive(value: unknown): number | null {
35+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
36+
}
37+
38+
export function updateDownloadBytes(info: UpdateInfo | null | undefined): number | null {
39+
if (!info || !Array.isArray(info.files)) return null;
40+
let largest = 0;
41+
for (const file of info.files) {
42+
const size = finitePositive(file.size);
43+
if (size && size > largest) largest = size;
44+
}
45+
return largest || null;
46+
}
47+
48+
// electron-updater keeps the pending archive and a second update.zip used for
49+
// future differential downloads. Squirrel.Mac then fetches that archive again
50+
// and expands/replaces the app. These are deliberately conservative estimates:
51+
// two compressed copies for download; one staged copy plus a 4x expansion
52+
// allowance for install; and 512 MiB of filesystem/rollback headroom.
53+
export function estimateUpdateRequiredBytes(
54+
phase: "download" | "install",
55+
compressedBytes: number | null,
56+
): number {
57+
const archiveBytes = compressedBytes ?? DEFAULT_UPDATE_DOWNLOAD_BYTES;
58+
return phase === "download"
59+
? (archiveBytes * 2) + UPDATE_SPACE_HEADROOM_BYTES
60+
: (archiveBytes * 5) + UPDATE_SPACE_HEADROOM_BYTES;
61+
}
62+
63+
function errorCode(error: unknown): string {
64+
if (!error || typeof error !== "object") return "";
65+
const code = (error as { code?: unknown }).code;
66+
return typeof code === "string" ? code.toUpperCase() : "";
67+
}
68+
69+
function errorMessage(error: unknown): string {
70+
return error instanceof Error ? error.message : String(error ?? "unknown");
71+
}
72+
73+
export function classifyUpdateError(
74+
error: unknown,
75+
fallbackPhase: AutoUpdatePhase,
76+
): { kind: AutoUpdateErrorKind; phase: AutoUpdatePhase } {
77+
const code = errorCode(error);
78+
const message = errorMessage(error).toLowerCase();
79+
const phase = /extract|staging|shipit|unpack/.test(message) ? "staging" : fallbackPhase;
80+
if (code === "ENOSPC" || /no space left|disk(?: is)? full/.test(message)) {
81+
return { kind: "disk_full", phase };
82+
}
83+
if (code === "EDQUOT" || /quota exceeded|disk quota/.test(message)) {
84+
return { kind: "quota", phase };
85+
}
86+
if (/not enough (?:free )?(?:disk )?space|insufficient (?:disk )?(?:space|capacity)/.test(message)) {
87+
return { kind: "insufficient_space", phase };
88+
}
89+
if (/signature|code requirement|notariz/.test(message)) {
90+
return { kind: "signature", phase: "verification" };
91+
}
92+
if (/sha(?:512)?|checksum|verify|verification|corrupt/.test(message)) {
93+
return { kind: "verification", phase: "verification" };
94+
}
95+
if (
96+
code === "EACCES"
97+
|| code === "EPERM"
98+
|| code === "EROFS"
99+
|| /permission denied|not permitted|read-only file system/.test(message)
100+
) {
101+
return { kind: "permission", phase };
102+
}
103+
if (
104+
/\b(?:enotfound|econnreset|econnrefused|etimedout|network|offline|http 4\d\d|http 5\d\d)\b/.test(`${code.toLowerCase()} ${message}`)
105+
) {
106+
return { kind: "network", phase };
107+
}
108+
if (phase === "install" || phase === "staging") {
109+
return { kind: "installer", phase };
110+
}
111+
return { kind: "unknown", phase };
112+
}

0 commit comments

Comments
 (0)