|
| 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