Files
2026-07-13 13:08:41 +08:00

236 lines
6.8 KiB
TypeScript

import { app, session } from "electron";
import fs from "fs";
import path from "path";
import { addMainBreadcrumb, captureMainException } from "../sentry/main";
import { safeError, safeLog, safeWarn } from "./safe-console";
export type ChromiumCacheRecoveryStatus = {
mode: "auto" | "force" | "off";
status: "pending-session-clear" | "completed" | "failed" | "skipped";
recoveryKey: string;
appVersion: string;
electronVersion: string;
quarantined: string[];
removedStaleDirectories: number;
errors: string[];
reason?: string;
};
const RECOVERY_KEY = "shared-dictionary-cache-v1";
const SENTINEL_FILE = "chromium-cache-recovery.json";
function getRecoveryMode(): ChromiumCacheRecoveryStatus["mode"] {
const raw = process.env.PRESENTON_CHROMIUM_CACHE_RECOVERY?.trim().toLowerCase();
if (["0", "false", "no", "off"].includes(raw ?? "")) {
return "off";
}
if (raw === "force") {
return "force";
}
return "auto";
}
function getSentinelPath(userDataDir: string): string {
return path.join(userDataDir, SENTINEL_FILE);
}
function readSentinel(userDataDir: string): Record<string, unknown> | undefined {
try {
const raw = fs.readFileSync(getSentinelPath(userDataDir), "utf8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return undefined;
}
}
function writeSentinel(userDataDir: string, status: ChromiumCacheRecoveryStatus): void {
const payload = {
recoveryKey: status.recoveryKey,
appVersion: status.appVersion,
electronVersion: status.electronVersion,
completedAt: new Date().toISOString(),
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
};
fs.writeFileSync(getSentinelPath(userDataDir), `${JSON.stringify(payload, null, 2)}\n`);
}
function shouldRunRecovery(
mode: ChromiumCacheRecoveryStatus["mode"],
userDataDir: string,
appVersion: string,
electronVersion: string,
): boolean {
if (mode === "force") {
return true;
}
if (mode === "off") {
return false;
}
const sentinel = readSentinel(userDataDir);
return !(
sentinel?.recoveryKey === RECOVERY_KEY &&
sentinel?.appVersion === appVersion &&
sentinel?.electronVersion === electronVersion
);
}
function uniqueQuarantinePath(quarantineRoot: string, label: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
let candidate = path.join(quarantineRoot, `${label}-${timestamp}`);
let suffix = 1;
while (fs.existsSync(candidate)) {
candidate = path.join(quarantineRoot, `${label}-${timestamp}-${suffix}`);
suffix += 1;
}
return candidate;
}
function quarantineIfPresent(
source: string,
quarantineRoot: string,
label: string,
status: ChromiumCacheRecoveryStatus,
): void {
if (!fs.existsSync(source)) {
return;
}
try {
fs.mkdirSync(quarantineRoot, { recursive: true });
fs.renameSync(source, uniqueQuarantinePath(quarantineRoot, label));
status.quarantined.push(label);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
status.errors.push(`${label}: ${message}`);
}
}
function removeOldDirectories(dir: string, status: ChromiumCacheRecoveryStatus): void {
if (!fs.existsSync(dir)) {
return;
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith("old_")) {
fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true });
status.removedStaleDirectories += 1;
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
status.errors.push(`old-cache-cleanup: ${message}`);
}
}
export function prepareChromiumCacheRecovery(
cacheDir: string,
userDataDir: string,
): ChromiumCacheRecoveryStatus {
const mode = getRecoveryMode();
const appVersion = app.getVersion();
const electronVersion = process.versions.electron ?? "unknown";
const status: ChromiumCacheRecoveryStatus = {
mode,
status: "pending-session-clear",
recoveryKey: RECOVERY_KEY,
appVersion,
electronVersion,
quarantined: [],
removedStaleDirectories: 0,
errors: [],
};
if (mode === "off") {
status.status = "skipped";
status.reason = "disabled-by-env";
return status;
}
if (!shouldRunRecovery(mode, userDataDir, appVersion, electronVersion)) {
status.status = "skipped";
status.reason = "already-completed";
return status;
}
removeOldDirectories(cacheDir, status);
removeOldDirectories(path.join(userDataDir, "GPUCache"), status);
const quarantineRoot = path.join(userDataDir, "Recovered Chromium Cache");
quarantineIfPresent(
path.join(userDataDir, "Shared Dictionary"),
quarantineRoot,
"Shared Dictionary",
status,
);
if (status.errors.length > 0) {
status.status = "failed";
safeWarn("[Presenton] Chromium cache recovery finished with errors:", status);
return status;
}
safeLog("[Presenton] Chromium cache recovery prepared:", {
mode: status.mode,
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
});
return status;
}
export async function finishChromiumCacheRecovery(
userDataDir: string,
status: ChromiumCacheRecoveryStatus,
): Promise<ChromiumCacheRecoveryStatus> {
if (status.status === "skipped") {
addMainBreadcrumb("cache-recovery", "electron.chromium_cache_recovery.skipped", {
reason: status.reason,
mode: status.mode,
});
return status;
}
try {
const defaultSession = session.defaultSession;
const skippedSessionClears: string[] = [];
if (typeof defaultSession.clearSharedDictionaryCache === "function") {
await defaultSession.clearSharedDictionaryCache();
} else {
skippedSessionClears.push("shared-dictionary-cache");
}
if (typeof defaultSession.clearCodeCaches === "function") {
await defaultSession.clearCodeCaches({ urls: [] });
} else {
skippedSessionClears.push("code-cache");
}
await defaultSession.clearCache();
writeSentinel(userDataDir, status);
status.status = "completed";
addMainBreadcrumb("cache-recovery", "electron.chromium_cache_recovery.completed", {
mode: status.mode,
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
skippedSessionClears,
});
} catch (error) {
status.status = "failed";
const message = error instanceof Error ? error.message : String(error);
status.errors.push(`session-clear: ${message}`);
safeError("[Presenton] Chromium cache recovery failed:", error);
captureMainException(error, {
area: "chromium-cache-recovery",
mode: status.mode,
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
});
}
return status;
}