Files

389 lines
13 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as core from "./core.js";
const PLUGIN_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
function pluginVersion() {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, "package.json"), "utf8"));
return String(pkg.version || "");
} catch {
return "";
}
}
function installedRevPath() {
return path.join(core.findDshHome(), "dsh-purge", "installed-rev");
}
function installedRev() {
try {
return fs.readFileSync(installedRevPath(), "utf8").trim();
} catch {
return "";
}
}
function appliedStampPath() {
return path.join(core.findDshHome(), "dsh-purge", "applied.json");
}
function settleGuardPath() {
return path.join(core.findDshHome(), "dsh-purge", "settle-restart.guard");
}
export function readAppliedStamp() {
try {
const raw = JSON.parse(fs.readFileSync(appliedStampPath(), "utf8"));
return {
version: String(raw.version || ""),
sha: String(raw.sha || ""),
};
} catch {
return null;
}
}
function sameRev(a, b) {
if (!a || !b) return false;
return a === b || a.startsWith(b) || b.startsWith(a);
}
function isApplyPlaceholder(sha) {
const text = String(sha || "");
return text.startsWith("apply:") || text === "applied";
}
function applyPlaceholderVersion(sha) {
const m = String(sha || "").trim().match(/^apply:(.+)$/);
return m ? m[1] : "";
}
function refreshStaleApplyPlaceholder(sha) {
const ver = pluginVersion();
const oldVer = applyPlaceholderVersion(sha);
if (oldVer && ver && oldVer !== ver) return `apply:${ver}`;
return String(sha || "").trim();
}
/** 手动应用未必写过 installed-rev;空 sha 会让 settle 永远判未对齐并无限重启。 */
export function resolveApplySha(explicit) {
const wanted = String(explicit == null ? "" : explicit).trim();
if (wanted) return refreshStaleApplyPlaceholder(wanted) || wanted;
const rev = installedRev();
if (rev) return refreshStaleApplyPlaceholder(rev) || rev;
const ver = pluginVersion();
return ver ? `apply:${ver}` : "applied";
}
function writeInstalledRev(sha) {
const value = String(sha || "").trim();
if (!value) return;
const fp = installedRevPath();
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, `${value}\n`, "utf8");
}
function writeAppliedStamp(sha) {
const fp = appliedStampPath();
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, `${JSON.stringify({
version: pluginVersion(),
sha: String(sha || "").trim(),
at: new Date().toISOString(),
}, null, 2)}\n`, "utf8");
}
/** 当前插件版本和已写入补丁的版本一致时,启动不必再还原重打。 */
export function stampMatches(stamp, version, rev) {
if (!stamp || !stamp.version || stamp.version !== version) return false;
// sha 为空表示 stamp 不完整(曾误写空串),不能当作已对齐(#56)。
if (!stamp.sha) return false;
const current = String(rev || "").trim();
if (!current) return true;
// 手动应用占位戳与后续 git rev 并存时,只认 version,避免再空转重启。
if (isApplyPlaceholder(stamp.sha) || isApplyPlaceholder(current)) return true;
if (!sameRev(current, stamp.sha)) return false;
return true;
}
/**
* stamp 对上了,但宿主被官方更新打回 sealed / 清洗标记没了时仍算未对齐(#57)。
* 找不到插件根(web 启动早期、纯 CLI)不等于补丁丢失,否则会无限重打+重启(#59)。
*/
export function hostStillHasAppliedPatches() {
try {
if (core.desktopAsarStillSealed()) return false;
} catch {
/* 非桌面面 */
}
try {
const aiBase = core.findAiBase();
if (!aiBase) return true;
return core.hostCleanMarkersPresent(aiBase);
} catch {
return true;
}
}
/** 空 sha 只有在「不是密封、也不是明确缺标记」时才允许就地补戳。 */
export function canHealEmptyStamp({ sealed = false, hasAiBase = false, markersPresent = false } = {}) {
if (sealed) return false;
if (hasAiBase && !markersPresent) return false;
return true;
}
export function patchesMatchInstall() {
if (!stampMatches(readAppliedStamp(), pluginVersion(), installedRev())) return false;
return hostStillHasAppliedPatches();
}
/**
* 逐补丁实检:patchStatus 里出现 "pending" 才算真的有活要干。
* hostCleanMarkersPresent 只看任一清洗 marker 存在,无法发现 DSH 自升级后
* agent-loop 这类文件被整体替换、#74/#87 marker 全不见的漂移。
*/
export async function hostPatchesStillPending() {
try {
const aiBase = core.findAiBase();
if (!aiBase) return false;
const status = await core.patchStatus(aiBase);
return Object.values(status).some((row) => row === "pending");
} catch {
return false;
}
}
/** fast path 过了也要再做一次逐补丁 pending 实检,避免 DSH 升级 marker 漂移时永不重打。 */
export async function patchesMatchInstallStrict() {
if (!patchesMatchInstall()) return false;
if (await hostPatchesStillPending()) return false;
return true;
}
/**
* 宿主上的清洗还在,只是版本戳旧了。启动时不能因此 revertAll,
* 否则用户点应用并重启后,页面会回到「要重新清洗」。
*/
export async function hostCleanEnoughToKeep() {
let aiBase = "";
try {
aiBase = core.findAiBase() || "";
} catch {
return false;
}
if (!aiBase || !core.hostCleanMarkersPresent(aiBase)) return false;
const status = await core.patchStatus(aiBase);
return !Object.values(status).some((row) => row === "pending");
}
/** 有 applied 戳但宿主已回到密封 asar / 清洗标记没了:面板提示「补丁已丢失」(#57)。找不到根不算丢失。 */
export function patchesLostAfterHostReset() {
const stamp = readAppliedStamp();
if (!stamp?.version || !stamp?.sha) return false;
try {
if (core.desktopAsarStillSealed()) return true;
} catch {
/* 非桌面面 */
}
try {
const aiBase = core.findAiBase();
if (aiBase && !core.hostCleanMarkersPresent(aiBase)) return true;
} catch {
/* ignore */
}
return false;
}
/** 手动「应用」成功后也要打戳,否则重启后 settle 会以为补丁未对齐再要求重启。 */
export function markPatchesApplied(sha) {
const resolved = resolveApplySha(sha);
const current = installedRev();
// 已有更新通道写入的 git rev 时沿用;否则用 resolve 结果(含 apply:version 占位)。
const finalSha = current && !isApplyPlaceholder(current) ? current : resolved;
writeInstalledRev(finalSha);
writeAppliedStamp(finalSha);
return finalSha;
}
/**
* #59/#60:空 sha 或缺失 installed-rev 会让 settle 每次启动都 reapply+restart。
* 版本已对齐时先补非空戳 / 种子 rev,打断自重启;真正缺补丁再由后续逻辑重打。
*/
export function healEmptyAppliedStamp() {
const stamp = readAppliedStamp();
const version = pluginVersion();
if (!stamp || stamp.version !== version) return false;
if (stamp.sha) {
if (!installedRev()) writeInstalledRev(stamp.sha);
return false;
}
let sealed = false;
try { sealed = core.desktopAsarStillSealed(); } catch { /* ignore */ }
let hasAiBase = false;
let markersPresent = false;
try {
const aiBase = core.findAiBase();
hasAiBase = Boolean(aiBase);
markersPresent = Boolean(aiBase && core.hostCleanMarkersPresent(aiBase));
} catch {
/* 根还没定位 */
}
// 密封或明确没打上清洗标记:不能补戳装成已对齐,否则 #57 不再自愈。
if (!canHealEmptyStamp({ sealed, hasAiBase, markersPresent })) return false;
markPatchesApplied();
return true;
}
/** 安装后还没有 installed-rev 时先落盘占位(tarball / 手动应用常见)。 */
export function ensureInstalledRevSeed() {
const current = installedRev();
if (current) {
const next = refreshStaleApplyPlaceholder(current);
if (next && next !== current) {
writeInstalledRev(next);
return next;
}
return current;
}
const stamp = readAppliedStamp();
if (stamp?.sha) {
writeInstalledRev(stamp.sha);
return stamp.sha;
}
const sha = resolveApplySha();
writeInstalledRev(sha);
return sha;
}
/** settle 刚重启过则冷却,防止「重打→重启→再重打」打成死循环。 */
export function shouldSkipSettleRestart(windowMs = 180000) {
try {
const raw = JSON.parse(fs.readFileSync(settleGuardPath(), "utf8"));
if (String(raw.version || "") !== pluginVersion()) return false;
const at = Date.parse(String(raw.at || "")) || 0;
return at > 0 && Date.now() - at < windowMs;
} catch {
return false;
}
}
export function markSettleRestart() {
const fp = settleGuardPath();
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, `${JSON.stringify({
version: pluginVersion(),
at: new Date().toISOString(),
}, null, 2)}\n`, "utf8");
}
/**
* 先把宿主文件从备份还原,再按当前这份插件的补丁重写。
* 低版本带着自己的补丁列表,高版本带着自己的;回退到哪一版就打哪一版。
*/
export async function reapplyInstalled(options = {}) {
core.applyRuntimeEnv();
let aiBase = null;
let handoff = null;
let freshExtract = null;
try {
const state = await core.gatherState();
aiBase = state.ai_base;
} catch (e) {
return { ok: false, error: String(e && e.message ? e.message : e) };
}
const sealed = core.sealedAsarHandoff();
if (!aiBase || sealed) {
try {
const opened = await core.openSealedDesktopHost({
upgrade: options.upgrade === true,
forApply: true,
});
if (opened.aiBase) aiBase = opened.aiBase;
if (opened.swapAsar) handoff = opened;
if (opened.extracted) freshExtract = opened;
} catch (e) {
return { ok: false, error: String(e && e.message ? e.message : e) };
}
} else {
core.rememberUnpackedArchive();
}
if (!aiBase) return { ok: false, error: core.missingAiBaseMessage() };
// 刚解开的是官方原件。已经在用的目录仍先还原再按这一版重打。
const preserveClientBundles = options.preserveClientBundles !== false;
// DSH 自升级会把宿主文件整个换成新版,但旁边老 bak 不变。此时直接 revertAll 会把当前新版
// 写回成旧版 → 新旧混码直接崩。先把"原文无 dsh-purge marker 却和 bak 字节不同"的 bak
// 刷到 current,再 revertAll 就不会毁掉新宿主,下面 applyPatches 把 marker 补回来。
if (!freshExtract) {
try {
const refreshed = core.refreshStaleBaksForUpgrade(aiBase);
if (refreshed.length) {
console.warn(`[dsh-purge] refreshed ${refreshed.length} stale bak(s) before revert:`, refreshed);
}
} catch (e) {
console.warn("[dsh-purge] refresh stale bak failed:", String(e && e.message ? e.message : e));
}
}
const reverted = freshExtract ? { reverted: [] } : await core.revertAll(aiBase, { preserveClientBundles });
if (!freshExtract) await core.revertAllShims();
const multi = await core.applyPatchesAllHosts(core.ALL_PATCHES, { preserveClientBundles, forApply: true });
await core.patchAllShims();
const flash = core.silenceCmdFlash(multi.hosts[0]?.aiBase || aiBase);
const summary = core.summarizeApplyReport(multi.report);
const clean =
summary.failed.length === 0 &&
(core.allPatchHostsWritten(multi.hosts) || core.hostCleanMarkersPresent(aiBase));
if (clean && flash.ok !== true) {
console.warn("[dsh-purge] cmd flash patch did not apply cleanly; clean flow continues, UI shows cmdFlashOk=false");
}
if (!clean && freshExtract) {
const restored = core.restoreSealedExtract(freshExtract.appDir, freshExtract.prev);
return {
ok: false,
error: restored ? "Clean did not complete; the previous unpacked directory was restored" : "Clean did not complete",
restored,
version: pluginVersion(),
reverted: 0,
applied: summary.applied || 0,
failed: summary.failed.length,
renamed: false,
needsFullQuit: false,
cmdFlashOk: flash.ok === true,
};
}
if (clean) {
markPatchesApplied(installedRev() || undefined);
if (freshExtract?.archive) core.writeUnpackedAsarStamp(freshExtract.archive);
try {
const seed = core.seedPromptInjectAtBoot(core.findDshHome());
if (seed && seed.action === "error") {
console.warn("[dsh-purge] seed prompt-inject failed:", seed.reason);
}
} catch (e) {
console.warn("[dsh-purge] seed prompt-inject exception:", String(e && e.message ? e.message : e));
}
}
let renamed = false;
if (clean && handoff?.asar && options.scheduleSwap !== false) {
const swapped = core.scheduleSealedDesktopRestart(handoff);
renamed = Boolean(swapped?.renamed);
}
const pending = Boolean(handoff?.asar && !renamed && options.scheduleSwap !== false);
// 只有真的挪开了 app.asar,当前进程才还在旧包上,需要再开一次。
// 官方端已经在 resources/app 里跑时,不能仅因 ELECTRON_RUN_AS_NODE 再弹「需要重启」,
// 否则每次打开面板都会再要一次重启。
return {
ok: clean,
error: clean ? "" : "Clean did not complete",
version: pluginVersion(),
reverted: reverted.reverted?.length || 0,
applied: summary.applied || 0,
failed: summary.failed.length,
renamed,
needsFullQuit: renamed || pending,
cmdFlashOk: flash.ok === true,
};
}