2008 lines
81 KiB
JavaScript
2008 lines
81 KiB
JavaScript
import os from "node:os";
|
||
import path from "node:path";
|
||
import { execFileSync, spawn } from "node:child_process";
|
||
import { fileURLToPath } from "node:url";
|
||
import { randomBytes } from "node:crypto";
|
||
import { isWshAscii, wshLiteral } from "./official-update.js";
|
||
import { asarArchiveIsFile, diskFs, hostFs } from "./extract-asar.js";
|
||
import { hostPromptKeepsInject, HOST_CLEAN_MARKERS } from "./host-clean.js";
|
||
import { atomicCopyFileSync, atomicWriteSync } from "./core.js";
|
||
|
||
function restartUniqSuffix() {
|
||
return `${process.pid}-${randomBytes(4).toString("hex")}`;
|
||
}
|
||
|
||
// 无归档时用 node:fs。app.asar 还在时不能把映射出来的 resources/app 当成已经解开。
|
||
const fs = hostFs;
|
||
|
||
// [dsh-purge-fix 2026-10-09] console.warn 写 host stderr → main.js:3729 host.exitPromise
|
||
// fail 回调把最后一行 stderr 当 Error reason。helper restart 期间任何 warn 都可能
|
||
// 被误判成 crash log。改走内部文件,不污染 stderr。
|
||
const INTERNAL_LOG_PATH = path.join(os.tmpdir(), "dsh-purge-desktop.log");
|
||
function logInternal(msg) {
|
||
try {
|
||
const line = `${new Date().toISOString()} [dsh-purge] ${String(msg)}\n`;
|
||
fs.appendFileSync(INTERNAL_LOG_PATH, line, "utf8");
|
||
} catch {
|
||
/* 日志写不进去就吞,绝不走 stderr */
|
||
}
|
||
}
|
||
|
||
/** 启动一次:清掉旧版本留下的无 pid 后缀的 restart 脚本/日志(超过 24h)。 */
|
||
export function cleanupLegacyRestartScripts() {
|
||
const tmp = os.tmpdir();
|
||
let entries = [];
|
||
try { entries = fs.readdirSync(tmp); } catch { return; }
|
||
const pat = /^dsh-purge-official-restart(?:-[0-9a-f-]+)?\.(?:js|cmd|log)$/i;
|
||
for (const name of entries) {
|
||
if (!pat.test(name)) continue;
|
||
const fp = path.join(tmp, name);
|
||
try {
|
||
const st = fs.statSync(fp);
|
||
if (Date.now() - st.mtimeMs > 24 * 60 * 60 * 1000) {
|
||
fs.unlinkSync(fp);
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
/** 路径落在 `app.asar/...` 里面。`app.asar` 文件本身和 `app.asar.unpacked` 不算。 */
|
||
export function isAsarSealedPath(p) {
|
||
const parts = String(p || "").replace(/\\/g, "/").split("/").filter(Boolean);
|
||
if (parts.length < 2) return false;
|
||
return parts.slice(0, -1).some((seg) => {
|
||
const s = seg.toLowerCase();
|
||
return s.endsWith(".asar") && !s.endsWith(".asar.unpacked");
|
||
});
|
||
}
|
||
|
||
function isFile(fp) {
|
||
try {
|
||
if (isAsarSealedPath(fp)) return false;
|
||
return Boolean(fp) && fs.existsSync(fp) && fs.statSync(fp).isFile();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isDir(fp) {
|
||
try {
|
||
if (isAsarSealedPath(fp)) return false;
|
||
return Boolean(fp) && fs.existsSync(fp) && fs.statSync(fp).isDirectory();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isAiBase(p) {
|
||
return Boolean(p) && isDir(path.join(p, "dsh-agent-instructions", "lib"));
|
||
}
|
||
|
||
function uniquePaths(list) {
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const item of list) {
|
||
if (!item) continue;
|
||
const n = path.normalize(item);
|
||
const key = n.replace(/\\/g, "/").toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
out.push(n);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function slashNorm(p) {
|
||
return path.normalize(String(p || "")).replace(/\\/g, "/").toLowerCase();
|
||
}
|
||
|
||
function isSubPath(child, root) {
|
||
const c = slashNorm(child).replace(/\/+$/, "");
|
||
const r = slashNorm(root).replace(/\/+$/, "");
|
||
if (!c || !r || r === "/") return false;
|
||
if (/^[a-z]:$/.test(r)) return false;
|
||
return c === r || c.startsWith(`${r}/`);
|
||
}
|
||
|
||
/**
|
||
* 社区桌面、官方 exe、.app 包才算安装树。
|
||
* 只是上级文件夹叫 DeepSeek Harness 不算:npm 前缀和配置目录经常用这个名字,
|
||
* 按名字整棵排除的话,Web 的身份句和 complete 注入补丁会被跳过。
|
||
*/
|
||
export function looksLikeDesktopName(p) {
|
||
const n = slashNorm(p);
|
||
if (!n) return false;
|
||
return (
|
||
n.includes("/dsh desktop/") ||
|
||
n.includes("/dsh desktop.app/") ||
|
||
n.endsWith("/dsh desktop") ||
|
||
n.endsWith("/dsh desktop.app") ||
|
||
n.includes("dsh-desktop") ||
|
||
n.includes("/dsh next/") ||
|
||
n.includes("/dsh next.app/") ||
|
||
n.endsWith("/dsh next") ||
|
||
n.endsWith("/dsh next.app") ||
|
||
n.includes("dsh-next") ||
|
||
n.includes("dsh-desktop-next") ||
|
||
n.includes("/deepseek harness/") ||
|
||
n.includes("/deepseek-harness/") ||
|
||
n.includes("/deepseekharness") ||
|
||
/\/deepseek[\s._-]*harness(\/|$)/.test(n) ||
|
||
n.includes("/deepseek harness.app/") ||
|
||
n.endsWith("/deepseek harness.app") ||
|
||
n.includes("/deepseek bot/") ||
|
||
n.includes("/deepseek-bot/") ||
|
||
n.includes("/ds-bot/") ||
|
||
n.endsWith("/deepseek bot") ||
|
||
n.endsWith("/deepseek bot.app") ||
|
||
/\/dsh desktop\.exe$/i.test(n) ||
|
||
/\/dsh next\.exe$/i.test(n) ||
|
||
/\/deepseek[\s._-]*harness\.exe$/i.test(n) ||
|
||
/\/deepseek[\s._-]*bot\.exe$/i.test(n)
|
||
);
|
||
}
|
||
|
||
const OFFICIAL_HARNESS_BASES = new Set([
|
||
"deepseek harness.exe",
|
||
"deepseek-harness.exe",
|
||
"deepseek harness",
|
||
"deepseek-harness",
|
||
"dsh next.exe",
|
||
"dsh next",
|
||
"dsh-next.exe",
|
||
"dsh-next",
|
||
]);
|
||
|
||
/**
|
||
* 只把「路径自己那一段」当桌面名字,不看祖先目录。
|
||
*
|
||
* 上面那段注释说要避免的事,原实现其实在做:`n.includes("/deepseek harness/")`
|
||
* 会命中任意祖先目录。而 npm 前缀(`D:\DeepSeek Harness\npm-global\…`)和
|
||
* DSH_HOME 的父目录(`D:\DeepSeek Harness\.dsh` 的上级)恰好都叫这个名字 ——
|
||
* 于是 Web 安装根被判成桌面,findAiBaseWeb 里每一处
|
||
* `!isInsideDesktopInstall(cand)` 守卫都会把正确答案踢掉,Web 面定位不到宿主。
|
||
*
|
||
* 真正的桌面安装靠结构标记判定:路径里含 `/resources/app/`、
|
||
* `/resources/app.asar.unpacked/` 或 `/contents/resources/`(见 inferDesktopInstallDir)。
|
||
* 名字只作为「这一级目录本身就是安装根」的兜底。
|
||
*/
|
||
export function looksLikeDesktopNameTail(p) {
|
||
const n = slashNorm(p).toLowerCase().replace(/\/+$/, "");
|
||
if (!n) return false;
|
||
const cut = n.lastIndexOf("/");
|
||
const tail = cut >= 0 ? n.slice(cut + 1) : n;
|
||
if (!tail) return false;
|
||
return (
|
||
tail === "dsh desktop" ||
|
||
tail === "dsh desktop.app" ||
|
||
tail === "dsh next" ||
|
||
tail === "dsh next.app" ||
|
||
tail === "deepseek harness" ||
|
||
tail === "deepseek harness.app" ||
|
||
tail === "deepseek-harness" ||
|
||
tail === "deepseekharness" ||
|
||
/^dsh-desktop/.test(tail) ||
|
||
/^dsh-next/.test(tail) ||
|
||
/^dsh[\s._-]*next(\.exe)?$/.test(tail) ||
|
||
/^deepseek[\s._-]*harness(\.exe)?$/.test(tail) ||
|
||
tail === "deepseek bot" ||
|
||
tail === "deepseek bot.app" ||
|
||
tail === "deepseek-bot" ||
|
||
tail === "ds-bot" ||
|
||
/^deepseek[\s._-]*bot(\.exe)?$/.test(tail)
|
||
);
|
||
}
|
||
|
||
function sameBase(name, expected) {
|
||
return String(name || "").toLowerCase() === expected;
|
||
}
|
||
|
||
export function isDesktopExecutable(fp) {
|
||
const base = path.basename(String(fp || "")).toLowerCase();
|
||
if (base === "dsh desktop.exe" || base === "dsh desktop") return true;
|
||
if (base === "dsh next.exe" || base === "dsh next") return true;
|
||
if (OFFICIAL_HARNESS_BASES.has(base)) return true;
|
||
if (/^deepseek[\s._-]*bot(\.exe)?$/.test(base) || /^ds[\s._-]*bot(\.exe)?$/.test(base)) return true;
|
||
if (/^dsh[\s._-]*next(\.exe)?$/.test(base)) return true;
|
||
return /^deepseek[\s._-]*harness(\.exe)?$/.test(base);
|
||
}
|
||
|
||
/** 官方 DeepSeek Harness 和带 asar 的 DSH NEXT 会解开 app.asar。第三方 DSH Desktop 不走这条。 */
|
||
export function isOfficialHarnessExecutable(fp) {
|
||
const base = path.basename(String(fp || "")).toLowerCase();
|
||
if (OFFICIAL_HARNESS_BASES.has(base)) return true;
|
||
if (/^dsh[\s._-]*next(\.exe)?$/.test(base)) return true;
|
||
return /^deepseek[\s._-]*harness(\.exe)?$/.test(base);
|
||
}
|
||
|
||
/** 某个安装目录下可能的 resources 文件夹(mac 大小写敏感盘要 Resources 和 resources 都试)。 */
|
||
export function resourceDirCandidates(installDir) {
|
||
if (!installDir) return [];
|
||
const name = path.basename(installDir);
|
||
if (sameBase(name, "macos")) {
|
||
const contents = path.dirname(installDir);
|
||
return [path.join(contents, "Resources"), path.join(contents, "resources")];
|
||
}
|
||
if (sameBase(name, "contents")) {
|
||
return [path.join(installDir, "Resources"), path.join(installDir, "resources")];
|
||
}
|
||
if (name.toLowerCase().endsWith(".app")) {
|
||
return [
|
||
path.join(installDir, "Contents", "Resources"),
|
||
path.join(installDir, "Contents", "resources"),
|
||
];
|
||
}
|
||
if (sameBase(name, "resources")) return [installDir];
|
||
return [path.join(installDir, "resources"), path.join(installDir, "Resources")];
|
||
}
|
||
|
||
/** 官方宿主的 resources 目录。已存在的 app.asar 优先,否则用该平台的常规目录名。 */
|
||
export function resourcesDirForExecutable(exe) {
|
||
const exeDir = path.dirname(path.normalize(String(exe || "")));
|
||
if (!exeDir) return "";
|
||
const cands = resourceDirCandidates(exeDir);
|
||
const withAsar = cands.find((dir) => isFile(path.join(dir, "app.asar")));
|
||
if (withAsar) return withAsar;
|
||
const existing = cands.find((dir) => isDir(dir));
|
||
if (existing) return existing;
|
||
return cands[0] || "";
|
||
}
|
||
|
||
export function appAsarPath(exe) {
|
||
const dir = resourcesDirForExecutable(exe);
|
||
return dir ? path.join(dir, "app.asar") : "";
|
||
}
|
||
|
||
export function appDirForExecutable(exe) {
|
||
const dir = resourcesDirForExecutable(exe);
|
||
return dir ? path.join(dir, "app") : "";
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- office-skills 自愈
|
||
* resources/runtime/office-skills/office/SKILL.md 缺失时,dsh-skill-office 初始化
|
||
* 同步读七个文件,桌面端直接 DesktopHostFatalError 退出。这里在“应用能起来”的
|
||
* 前提下把缺的文件从 app.asar / npm 安装树补回磁盘,避免下次再崩。
|
||
* 注意:救不了崩溃当次(崩溃发生在 Fiber._reload,比插件早)。
|
||
*/
|
||
const OFFICE_SKILL_MARKER = ["office", "SKILL.md"];
|
||
const OFFICE_COPY_MAX_DEPTH = 12;
|
||
|
||
function copySkillTree(src, dest, reader, writer, depth = 0) {
|
||
if (depth > OFFICE_COPY_MAX_DEPTH) return;
|
||
try { writer.mkdirSync(dest, { recursive: true }); } catch { return; }
|
||
let entries;
|
||
try { entries = reader.readdirSync(src, { withFileTypes: true }); } catch { return; }
|
||
for (const entry of entries) {
|
||
if (!entry || !entry.name) continue;
|
||
// 只跟真目录;符号链接可能指回上层,递归会绕圈。
|
||
if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
|
||
const from = path.join(src, entry.name);
|
||
const to = path.join(dest, entry.name);
|
||
if (entry.isDirectory()) {
|
||
copySkillTree(from, to, reader, writer, depth + 1);
|
||
} else if (entry.isFile()) {
|
||
try {
|
||
if (writer.existsSync(to)) continue;
|
||
writer.mkdirSync(path.dirname(to), { recursive: true });
|
||
writer.writeFileSync(to, reader.readFileSync(from));
|
||
} catch { /* 单个文件失败不挡整棵树 */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
export function healOfficeSkillsAt(resourcesDir, appDir = "", extraSources = []) {
|
||
if (!resourcesDir) return { ok: false, reason: "no-resources" };
|
||
const dest = path.join(resourcesDir, "runtime", "office-skills");
|
||
const marker = path.join(dest, ...OFFICE_SKILL_MARKER);
|
||
const out = diskFs();
|
||
try { if (out.existsSync(marker)) return { ok: true, healed: false, dest }; } catch { /* 下面按缺失处理 */ }
|
||
const sources = [
|
||
appDir ? path.join(appDir, "runtime", "office-skills") : "",
|
||
appDir ? path.join(appDir, "dsh", "node_modules", "@deepseek-ai", "dsh-skill-office", "assets") : "",
|
||
path.join(resourcesDir, "app", "runtime", "office-skills"),
|
||
path.join(resourcesDir, "app", "dsh", "node_modules", "@deepseek-ai", "dsh-skill-office", "assets"),
|
||
// 打包安装的资源在 asar 里,hostFs 认 asar 路径。
|
||
path.join(resourcesDir, "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-skill-office", "assets"),
|
||
path.join(resourcesDir, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-skill-office", "assets"),
|
||
...(Array.isArray(extraSources) ? extraSources : []),
|
||
].filter(Boolean);
|
||
const src = sources.find((dir) => {
|
||
try { return hostFs.existsSync(path.join(dir, ...OFFICE_SKILL_MARKER)); } catch { return false; }
|
||
});
|
||
if (!src) return { ok: false, reason: "no-source", dest };
|
||
try {
|
||
copySkillTree(src, dest, hostFs, out);
|
||
return { ok: out.existsSync(marker), healed: true, src, dest };
|
||
} catch (error) {
|
||
return { ok: false, reason: String(error?.message || error), src, dest };
|
||
}
|
||
}
|
||
|
||
export function healOfficeSkillsRuntimeOnDisk(extraSources = []) {
|
||
const exe = findDesktopAppExecutable();
|
||
if (!exe || !isOfficialHarnessExecutable(exe)) return { ok: false, reason: "no-exe" };
|
||
return healOfficeSkillsAt(resourcesDirForExecutable(exe), appDirForExecutable(exe), extraSources);
|
||
}
|
||
|
||
function exeCandidatesBeside(dir) {
|
||
if (!dir) return [];
|
||
return [
|
||
path.join(dir, "DeepSeek Harness.exe"),
|
||
path.join(dir, "deepseek-harness.exe"),
|
||
path.join(dir, "DeepSeek Harness"),
|
||
path.join(dir, "deepseek-harness"),
|
||
path.join(dir, "DSH Desktop.exe"),
|
||
path.join(dir, "DSH Desktop"),
|
||
path.join(dir, "DSH NEXT.exe"),
|
||
path.join(dir, "DSH NEXT"),
|
||
path.join(dir, "DeepSeek Bot.exe"),
|
||
path.join(dir, "DeepSeek-Bot.exe"),
|
||
path.join(dir, "DeepSeek Bot"),
|
||
path.join(dir, "MacOS", "DeepSeek Harness"),
|
||
path.join(dir, "MacOS", "deepseek-harness"),
|
||
path.join(dir, "MacOS", "DSH Desktop"),
|
||
path.join(dir, "MacOS", "DSH NEXT"),
|
||
path.join(dir, "MacOS", "DeepSeek Bot"),
|
||
path.join(dir, "Contents", "MacOS", "DeepSeek Harness"),
|
||
path.join(dir, "Contents", "MacOS", "deepseek-harness"),
|
||
path.join(dir, "Contents", "MacOS", "DSH Desktop"),
|
||
path.join(dir, "Contents", "MacOS", "DSH NEXT"),
|
||
path.join(dir, "Contents", "MacOS", "DeepSeek Bot"),
|
||
];
|
||
}
|
||
|
||
function firstExistingExe(dirs) {
|
||
for (const dir of dirs) {
|
||
if (!dir) continue;
|
||
for (const cand of exeCandidatesBeside(dir)) {
|
||
if (isFile(cand)) return path.normalize(cand);
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
const APP_SCRIPT_RE = /(desktop-cli|host-process-entry)\.js$/i;
|
||
|
||
function installDirsFromAppScript(scriptPath) {
|
||
if (!scriptPath) return [];
|
||
const abs = path.resolve(scriptPath);
|
||
const lib = path.dirname(abs);
|
||
const app = path.dirname(lib);
|
||
const resources = path.dirname(app);
|
||
const install = path.dirname(resources);
|
||
return uniquePaths([lib, app, resources, install]);
|
||
}
|
||
|
||
/** 从任意 `.../resources/app/...` 树反推安装目录(不要求文件夹叫 DSH Desktop)。 */
|
||
export function inferDesktopInstallDir(p) {
|
||
const raw = path.normalize(String(p || "")).replace(/\\/g, "/");
|
||
const n = raw.toLowerCase();
|
||
if (!n) return "";
|
||
const markers = [
|
||
"/resources/app.asar.unpacked/",
|
||
"/resources/app/",
|
||
"/contents/resources/",
|
||
];
|
||
for (const marker of markers) {
|
||
const i = n.indexOf(marker);
|
||
if (i <= 0) continue;
|
||
return path.normalize(raw.slice(0, i));
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function extraInstallsFromEnv(env = process.env) {
|
||
return String(env.DSH_DESKTOP_INSTALL || "")
|
||
.split(path.delimiter)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function trustedInstallDir(dir) {
|
||
if (!dir) return false;
|
||
if (looksLikeDesktopName(dir)) return true;
|
||
if (firstExistingExe([dir])) return true;
|
||
return (
|
||
isAiBase(path.join(dir, "resources", "app", "dsh", "node_modules", "@deepseek-ai")) ||
|
||
isAiBase(path.join(dir, "resources", "app", "node_modules", "@deepseek-ai")) ||
|
||
isAiBase(path.join(dir, "resources", "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai")) ||
|
||
isAiBase(path.join(dir, "resources", "app.asar.unpacked", "node_modules", "@deepseek-ai"))
|
||
);
|
||
}
|
||
|
||
const REGISTRY_INSTALL_SCRIPT = `
|
||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||
$names = @('DeepSeek Harness','DSH Desktop','DSH NEXT','DeepSeek Bot','DS Bot')
|
||
$roots = @(
|
||
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||
'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||
'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
|
||
)
|
||
$rows = foreach ($root in $roots) {
|
||
if (-not (Test-Path $root)) { continue }
|
||
Get-ChildItem $root -ErrorAction SilentlyContinue | ForEach-Object {
|
||
$p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
|
||
if (-not $p.DisplayName) { return }
|
||
$hit = $false
|
||
foreach ($n in $names) { if ($p.DisplayName -like ('*' + $n + '*')) { $hit = $true } }
|
||
if (-not $hit) { return }
|
||
[pscustomobject]@{
|
||
InstallLocation = [string]$p.InstallLocation
|
||
DisplayIcon = [string]$p.DisplayIcon
|
||
UninstallString = [string]$p.UninstallString
|
||
}
|
||
}
|
||
}
|
||
if ($rows) { @($rows) | ConvertTo-Json -Compress }
|
||
`.trim();
|
||
|
||
function exeDirFromRegistryValue(value) {
|
||
let val = String(value || "").trim();
|
||
const quoted = val.match(/^"([^"]+)"/);
|
||
if (quoted) val = quoted[1];
|
||
val = val.replace(/,0$/, "").trim();
|
||
if (!val) return "";
|
||
return path.normalize(path.dirname(val));
|
||
}
|
||
|
||
let cachedRegistryDirs;
|
||
/** 从卸载登记读安装目录。盘符跟着用户安装位置,不写死。 */
|
||
export function registryInstallDirs() {
|
||
if (cachedRegistryDirs !== undefined) return cachedRegistryDirs;
|
||
if (process.platform !== "win32") {
|
||
cachedRegistryDirs = [];
|
||
return cachedRegistryDirs;
|
||
}
|
||
let text = "";
|
||
try {
|
||
text = execFileSync("powershell.exe", ["-NoProfile", "-Command", REGISTRY_INSTALL_SCRIPT], {
|
||
encoding: "utf8",
|
||
windowsHide: true,
|
||
timeout: 15000,
|
||
maxBuffer: 8 << 20,
|
||
});
|
||
} catch {
|
||
return [];
|
||
}
|
||
let rows = [];
|
||
try {
|
||
const parsed = JSON.parse(String(text || "").replace(/^\uFEFF/, "").trim() || "[]");
|
||
rows = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
const found = [];
|
||
for (const row of rows) {
|
||
const location = String(row.InstallLocation || "").trim();
|
||
if (location) found.push(path.normalize(location));
|
||
found.push(exeDirFromRegistryValue(row.DisplayIcon));
|
||
found.push(exeDirFromRegistryValue(row.UninstallString));
|
||
}
|
||
const result = uniquePaths(found).filter((dir) => firstExistingExe([dir]));
|
||
if (result.length > 0) cachedRegistryDirs = result;
|
||
return result;
|
||
}
|
||
|
||
const PORTABLE_INSTALL_NAMES = [
|
||
"DeepSeek Harness",
|
||
"DeepseekHarnessDesktop",
|
||
"DeepSeekHarnessDesktop",
|
||
"DSH Desktop",
|
||
"DSH NEXT",
|
||
"deepseek-harness",
|
||
];
|
||
|
||
function homeSiblingInstalls(env = process.env) {
|
||
const homes = [env.DSH_HOME, env.DSH_BASE].filter(Boolean);
|
||
const dirs = [];
|
||
for (const raw of homes) {
|
||
const start = path.normalize(String(raw));
|
||
dirs.push(start, path.dirname(start), path.dirname(path.dirname(start)));
|
||
}
|
||
return uniquePaths(dirs).filter((dir) => firstExistingExe([dir]));
|
||
}
|
||
|
||
/** 用户目录和各盘根下一层常见文件夹名。不扫整盘。 */
|
||
function portableInstallGuesses(env = process.env) {
|
||
const home = os.homedir();
|
||
const cands = [];
|
||
for (const folder of ["Desktop", "Documents", "Downloads"]) {
|
||
for (const name of PORTABLE_INSTALL_NAMES) {
|
||
cands.push(path.join(home, folder, name));
|
||
}
|
||
}
|
||
for (const name of PORTABLE_INSTALL_NAMES) {
|
||
cands.push(path.join(home, name));
|
||
}
|
||
if (process.platform === "win32") {
|
||
for (const letter of "CDEFGHI") {
|
||
for (const name of PORTABLE_INSTALL_NAMES) {
|
||
cands.push(`${letter}:\\${name}`);
|
||
}
|
||
}
|
||
}
|
||
return uniquePaths(cands).filter((dir) => firstExistingExe([dir]));
|
||
}
|
||
|
||
function walkParentsForOfficialExe(start, maxUp = 8) {
|
||
if (!start) return "";
|
||
let dir = "";
|
||
try {
|
||
const abs = path.resolve(String(start));
|
||
dir = isDir(abs) ? abs : path.dirname(abs);
|
||
} catch {
|
||
return "";
|
||
}
|
||
for (let i = 0; i < maxUp && dir; i += 1) {
|
||
const hit = firstExistingExe([dir]);
|
||
if (hit) return hit;
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
const PROCESS_EXE_SCRIPT = `
|
||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||
$re = 'DeepSeek Harness\\.exe$|deepseek-harness\\.exe$|DSH Desktop\\.exe$|DSH NEXT\\.exe$'
|
||
$paths = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.ExecutablePath -and $_.ExecutablePath -match $re } |
|
||
Select-Object -ExpandProperty ExecutablePath)
|
||
if ($paths) { @($paths) | ConvertTo-Json -Compress }
|
||
`.trim();
|
||
|
||
let cachedRunningExes;
|
||
function runningOfficialExecutables() {
|
||
if (cachedRunningExes !== undefined) return cachedRunningExes;
|
||
if (process.platform !== "win32") {
|
||
cachedRunningExes = [];
|
||
return cachedRunningExes;
|
||
}
|
||
let text = "";
|
||
try {
|
||
text = execFileSync("powershell.exe", ["-NoProfile", "-Command", PROCESS_EXE_SCRIPT], {
|
||
encoding: "utf8",
|
||
windowsHide: true,
|
||
// 15s 会把启动期的 UI 线程卡 15s;Win32_Process 枚举 1-3s 够用,超时直接退不缓存。
|
||
timeout: 6000,
|
||
maxBuffer: 8 << 20,
|
||
});
|
||
} catch {
|
||
// 探测失败不缓存空结果,否则整次进程都找不到正在跑的客户端。
|
||
return [];
|
||
}
|
||
let rows = [];
|
||
try {
|
||
const parsed = JSON.parse(String(text || "").replace(/^\uFEFF/, "").trim() || "[]");
|
||
rows = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
// 结果为空也不缓存:客户端可能稍后才启动,否则本进程后续永远找不到它。
|
||
const result = uniquePaths(rows.map((item) => String(item || "").trim()).filter((fp) => isDesktopExecutable(fp) && isFile(fp)));
|
||
if (result.length > 0) cachedRunningExes = result;
|
||
return result;
|
||
}
|
||
|
||
const SHORTCUT_EXE_SCRIPT = `
|
||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||
$shell = New-Object -ComObject WScript.Shell
|
||
$roots = @(
|
||
[Environment]::GetFolderPath('StartMenu'),
|
||
[Environment]::GetFolderPath('CommonStartMenu'),
|
||
[Environment]::GetFolderPath('Desktop'),
|
||
[Environment]::GetFolderPath('CommonDesktopDirectory')
|
||
) | Where-Object { $_ }
|
||
$pats = @('*DeepSeek*Harness*.lnk','*DSH Desktop*.lnk','*DSH NEXT*.lnk','*deepseek-harness*.lnk','*dsh-next*.lnk')
|
||
$out = New-Object System.Collections.Generic.List[string]
|
||
foreach ($root in $roots) {
|
||
if (-not (Test-Path -LiteralPath $root)) { continue }
|
||
foreach ($pat in $pats) {
|
||
Get-ChildItem -LiteralPath $root -Filter $pat -Recurse -ErrorAction SilentlyContinue |
|
||
Select-Object -First 16 | ForEach-Object {
|
||
try {
|
||
$t = $shell.CreateShortcut($_.FullName).TargetPath
|
||
if ($t) { $out.Add([string]$t) }
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
if ($out.Count) { @($out) | ConvertTo-Json -Compress }
|
||
`.trim();
|
||
|
||
let cachedShortcutExes;
|
||
function shortcutOfficialExecutables() {
|
||
if (cachedShortcutExes !== undefined) return cachedShortcutExes;
|
||
if (process.platform !== "win32") {
|
||
cachedShortcutExes = [];
|
||
return cachedShortcutExes;
|
||
}
|
||
let text = "";
|
||
try {
|
||
text = execFileSync("powershell.exe", ["-NoProfile", "-Command", SHORTCUT_EXE_SCRIPT], {
|
||
encoding: "utf8",
|
||
windowsHide: true,
|
||
timeout: 15000,
|
||
maxBuffer: 8 << 20,
|
||
});
|
||
} catch {
|
||
return [];
|
||
}
|
||
let rows = [];
|
||
try {
|
||
const parsed = JSON.parse(String(text || "").replace(/^\uFEFF/, "").trim() || "[]");
|
||
rows = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
const result = uniquePaths(rows.map((item) => String(item || "").trim()).filter((fp) => isDesktopExecutable(fp) && isFile(fp)));
|
||
if (result.length > 0) cachedShortcutExes = result;
|
||
return result;
|
||
}
|
||
|
||
/** 点「应用」前清掉失败的空缓存,重新找本机 exe。 */
|
||
export function clearDesktopLocateCache() {
|
||
cachedRunningExes = undefined;
|
||
cachedShortcutExes = undefined;
|
||
cachedRegistryDirs = undefined;
|
||
}
|
||
|
||
function extraDiscoveredInstallDirs(env = process.env, input = {}) {
|
||
const cheap = [
|
||
...homeSiblingInstalls(env),
|
||
...portableInstallGuesses(env),
|
||
];
|
||
if (input.discover !== true) return uniquePaths(cheap);
|
||
return uniquePaths([
|
||
...cheap,
|
||
...runningOfficialExecutables().map((exe) => path.dirname(exe)),
|
||
...shortcutOfficialExecutables().map((exe) => path.dirname(exe)),
|
||
]);
|
||
}
|
||
|
||
/** NSIS / 商店默认位置;不是全盘扫描。discover 才查注册表 / 进程 / 快捷方式。 */
|
||
export function commonDesktopInstallDirs(env = process.env, input = {}) {
|
||
const discover = input.discover === true;
|
||
const home = os.homedir();
|
||
const local = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
||
const pf = env.ProgramFiles || "";
|
||
const pf86 = env["ProgramFiles(x86)"] || "";
|
||
return uniquePaths([
|
||
...extraInstallsFromEnv(env).filter(trustedInstallDir),
|
||
...(discover ? registryInstallDirs() : []),
|
||
...extraDiscoveredInstallDirs(env, { discover }),
|
||
path.join(local, "Programs", "DeepSeek Harness"),
|
||
path.join(local, "Programs", "DSH Desktop"),
|
||
path.join(local, "Programs", "DSH NEXT"),
|
||
path.join(local, "Programs", "DeepSeek Bot"),
|
||
path.join(local, "DeepSeek Harness"),
|
||
path.join(local, "DSH NEXT"),
|
||
path.join(local, "DeepSeek Bot"),
|
||
...(pf
|
||
? [path.join(pf, "DeepSeek Harness"), path.join(pf, "DSH Desktop"), path.join(pf, "DSH NEXT"), path.join(pf, "DeepSeek Bot")]
|
||
: []),
|
||
...(pf86
|
||
? [path.join(pf86, "DeepSeek Harness"), path.join(pf86, "DSH Desktop"), path.join(pf86, "DSH NEXT"), path.join(pf86, "DeepSeek Bot")]
|
||
: []),
|
||
"/Applications/DeepSeek Harness.app",
|
||
"/Applications/DSH Desktop.app",
|
||
"/Applications/DSH NEXT.app",
|
||
"/Applications/DeepSeek Bot.app",
|
||
path.join(home, "Applications", "DeepSeek Harness.app"),
|
||
path.join(home, "Applications", "DSH Desktop.app"),
|
||
path.join(home, "Applications", "DSH NEXT.app"),
|
||
path.join(home, "Applications", "DeepSeek Bot.app"),
|
||
"/opt/DeepSeek Harness",
|
||
"/opt/deepseek-harness",
|
||
"/opt/DSH NEXT",
|
||
"/usr/lib/DeepSeek Harness",
|
||
"/usr/lib/deepseek-harness",
|
||
"/usr/share/deepseek-harness",
|
||
path.join(home, ".local", "share", "DeepSeek Harness"),
|
||
path.join(home, ".local", "share", "deepseek-harness"),
|
||
"/opt/DSH Desktop",
|
||
"/usr/lib/dsh-desktop",
|
||
]);
|
||
}
|
||
|
||
export function listDesktopInstallRoots(input = {}) {
|
||
const execPath = input.execPath ?? process.execPath;
|
||
const resourcesPath = input.resourcesPath ?? process.resourcesPath;
|
||
const argv = input.argv ?? process.argv;
|
||
const env = input.env ?? process.env;
|
||
const roots = [];
|
||
|
||
if (isDesktopExecutable(execPath)) {
|
||
const dir = path.dirname(path.normalize(execPath));
|
||
roots.push(dir);
|
||
if (sameBase(path.basename(dir), "macos")) {
|
||
const contents = path.dirname(dir);
|
||
roots.push(
|
||
contents,
|
||
path.join(contents, "Resources"),
|
||
path.join(contents, "resources"),
|
||
path.dirname(contents),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (resourcesPath) {
|
||
const res = path.normalize(resourcesPath);
|
||
roots.push(res, path.dirname(res), path.join(res, "app"), path.join(res, "app.asar.unpacked"));
|
||
}
|
||
|
||
for (const arg of argv) {
|
||
if (typeof arg === "string" && APP_SCRIPT_RE.test(arg)) {
|
||
roots.push(...installDirsFromAppScript(arg));
|
||
}
|
||
}
|
||
|
||
for (const extra of extraInstallsFromEnv(env)) {
|
||
if (trustedInstallDir(extra)) roots.push(extra);
|
||
}
|
||
return uniquePaths(roots);
|
||
}
|
||
|
||
/**
|
||
* 是否落在社区 Desktop 安装树。
|
||
* 不依赖盘符、也不要求文件夹叫 “DSH Desktop”:
|
||
* 当前进程的 exe / resources / host-process-entry、旁边真有桌面 exe,或社区安装目录名。
|
||
*/
|
||
export function isInsideDesktopInstall(p, input = {}) {
|
||
if (!p) return false;
|
||
// 名字兜底只看路径尾部那一段,不看祖先目录名 —— 见 looksLikeDesktopNameTail。
|
||
if (looksLikeDesktopNameTail(p)) return true;
|
||
const roots = listDesktopInstallRoots(input);
|
||
if (roots.some((root) => isSubPath(p, root))) return true;
|
||
const inferred = inferDesktopInstallDir(p);
|
||
if (!inferred) return false;
|
||
if (roots.some((root) => isSubPath(inferred, root) || isSubPath(root, inferred))) return true;
|
||
return Boolean(firstExistingExe([inferred]));
|
||
}
|
||
|
||
export function findDesktopAppExecutable(input = {}) {
|
||
const execPath = input.execPath ?? process.execPath;
|
||
const resourcesPath = input.resourcesPath ?? process.resourcesPath;
|
||
const argv = input.argv ?? process.argv;
|
||
const env = input.env ?? process.env;
|
||
const discover = input.discover === true;
|
||
|
||
if (isDesktopExecutable(execPath) && isFile(execPath)) return path.normalize(execPath);
|
||
|
||
const electron = (input.versions ?? process.versions)?.electron;
|
||
if (
|
||
electron
|
||
&& isFile(execPath)
|
||
&& !/^node(\.exe)?$/i.test(path.basename(execPath))
|
||
) {
|
||
return path.normalize(execPath);
|
||
}
|
||
|
||
if (resourcesPath) {
|
||
const hit = firstExistingExe([path.dirname(path.normalize(resourcesPath))]);
|
||
if (hit) return hit;
|
||
}
|
||
|
||
for (const arg of argv) {
|
||
if (typeof arg !== "string" || !APP_SCRIPT_RE.test(arg)) continue;
|
||
const hit = firstExistingExe(installDirsFromAppScript(arg));
|
||
if (hit) return hit;
|
||
}
|
||
|
||
const walkStarts = [execPath, resourcesPath, env.DSH_HOME, env.DSH_BASE];
|
||
try { walkStarts.push(process.cwd()); } catch { /* ignore */ }
|
||
for (const arg of argv) {
|
||
if (typeof arg === "string") walkStarts.push(arg);
|
||
}
|
||
for (const start of walkStarts) {
|
||
const hit = walkParentsForOfficialExe(start);
|
||
if (hit) return hit;
|
||
}
|
||
|
||
if (discover) {
|
||
const running = runningOfficialExecutables();
|
||
if (running[0]) return running[0];
|
||
const shortcuts = shortcutOfficialExecutables();
|
||
if (shortcuts[0]) return shortcuts[0];
|
||
}
|
||
|
||
const guessed = firstExistingExe([
|
||
...extraInstallsFromEnv(env),
|
||
...commonDesktopInstallDirs(env, { discover }),
|
||
]);
|
||
return guessed || "";
|
||
}
|
||
|
||
function packageRootsFromInstall(installDir) {
|
||
if (!installDir) return [];
|
||
const out = [];
|
||
for (const resources of resourceDirCandidates(installDir)) {
|
||
out.push(
|
||
path.join(resources, "app", "dsh", "node_modules", "@deepseek-ai"),
|
||
path.join(resources, "app", "node_modules", "@deepseek-ai"),
|
||
path.join(resources, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai"),
|
||
path.join(resources, "app.asar.unpacked", "node_modules", "@deepseek-ai"),
|
||
);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function listDesktopPackageRoots(input = {}) {
|
||
const execPath = input.execPath ?? process.execPath;
|
||
const resourcesPath = input.resourcesPath ?? process.resourcesPath;
|
||
const argv = input.argv ?? process.argv;
|
||
const env = input.env ?? process.env;
|
||
const discover = input.discover === true;
|
||
const cands = [];
|
||
|
||
if (resourcesPath) {
|
||
cands.push(
|
||
path.join(resourcesPath, "app", "dsh", "node_modules", "@deepseek-ai"),
|
||
path.join(resourcesPath, "app", "node_modules", "@deepseek-ai"),
|
||
path.join(resourcesPath, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai"),
|
||
path.join(resourcesPath, "app.asar.unpacked", "node_modules", "@deepseek-ai"),
|
||
);
|
||
}
|
||
|
||
if (isDesktopExecutable(execPath)) {
|
||
cands.push(...packageRootsFromInstall(path.dirname(execPath)));
|
||
}
|
||
|
||
const exe = findDesktopAppExecutable(input);
|
||
if (exe) cands.push(...packageRootsFromInstall(path.dirname(exe)));
|
||
|
||
for (const arg of argv) {
|
||
if (typeof arg !== "string" || !APP_SCRIPT_RE.test(arg)) continue;
|
||
const appDir = path.dirname(path.dirname(path.resolve(arg)));
|
||
cands.push(path.join(appDir, "node_modules", "@deepseek-ai"));
|
||
cands.push(path.join(appDir, "dsh", "node_modules", "@deepseek-ai"));
|
||
cands.push(...packageRootsFromInstall(path.dirname(path.dirname(appDir))));
|
||
}
|
||
|
||
const localHits = uniquePaths(cands).filter(isAiBase);
|
||
// 已经在某个桌面 exe 里时,只改这一份。旁边装了另一种客户端也不能被带上。
|
||
if (isDesktopExecutable(execPath)) return localHits;
|
||
// 启动自愈:exe / 环境 / 常见目录已经够用。注册表和快捷方式留给点「应用」。
|
||
if (!discover && localHits.length) return localHits;
|
||
|
||
for (const install of commonDesktopInstallDirs(env, { discover })) {
|
||
cands.push(...packageRootsFromInstall(install));
|
||
}
|
||
|
||
return uniquePaths(cands).filter(isAiBase);
|
||
}
|
||
|
||
export function resolveDesktopAiBase(input = {}) {
|
||
return listDesktopPackageRoots(input)[0] || null;
|
||
}
|
||
|
||
export function isSealedRuntimeDir(dir) {
|
||
if (!dir) return false;
|
||
const n = path.normalize(dir).replace(/\\/g, "/").toLowerCase();
|
||
if (!/(^|\/)bin\/?$/.test(n)) return false;
|
||
return (
|
||
n.includes("/host-commands/") ||
|
||
n.includes("/runtime-commands/") ||
|
||
n.includes("/dsh desktop/")
|
||
);
|
||
}
|
||
|
||
export function sealedRuntimeRoots(env = process.env) {
|
||
const home = os.homedir();
|
||
const appdata = env.APPDATA || path.join(home, "AppData", "Roaming");
|
||
const localapp = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
||
return uniquePaths([
|
||
path.join(appdata, "DSH Desktop", "host-commands"),
|
||
path.join(appdata, "DSH Desktop", "runtime-commands"),
|
||
path.join(localapp, "DSH Desktop", "host-commands"),
|
||
path.join(localapp, "DSH Desktop", "runtime-commands"),
|
||
path.join(home, "Library", "Application Support", "DSH Desktop", "host-commands"),
|
||
]);
|
||
}
|
||
|
||
function walkBinDirs(dir, out, depth) {
|
||
if (depth < 0 || !isDir(dir)) return;
|
||
const n = dir.replace(/\\/g, "/").toLowerCase();
|
||
if (/(^|\/)bin$/.test(n)) {
|
||
out.push(path.normalize(dir));
|
||
return;
|
||
}
|
||
let ents = [];
|
||
try {
|
||
ents = fs.readdirSync(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const ent of ents) {
|
||
if (ent.isDirectory()) walkBinDirs(path.join(dir, ent.name), out, depth - 1);
|
||
}
|
||
}
|
||
|
||
export function listSealedRuntimeBins(env = process.env) {
|
||
const out = [];
|
||
for (const root of sealedRuntimeRoots(env)) walkBinDirs(root, out, 5);
|
||
return uniquePaths(out).filter(isSealedRuntimeDir);
|
||
}
|
||
|
||
export function restartNote() {
|
||
return "正在重启桌面应用…";
|
||
}
|
||
|
||
export function restartPlan(input = {}) {
|
||
const exe = findDesktopAppExecutable(input);
|
||
if (!exe) {
|
||
return { kind: "desktop", execPath: "", argv: [], cwd: "", error: "未找到 DSH Desktop.exe,无法重启桌面应用" };
|
||
}
|
||
return {
|
||
kind: "desktop",
|
||
execPath: exe,
|
||
argv: [],
|
||
cwd: path.dirname(exe),
|
||
};
|
||
}
|
||
|
||
function helperEnv(payload, runner) {
|
||
const env = { ...process.env };
|
||
delete env.DSH_PERMISSION_MODE;
|
||
if (runner && path.basename(runner).toLowerCase().includes("node")) {
|
||
delete env.ELECTRON_RUN_AS_NODE;
|
||
} else {
|
||
env.ELECTRON_RUN_AS_NODE = "1";
|
||
}
|
||
env.DSH_PURGE_RESTART = JSON.stringify(payload);
|
||
return env;
|
||
}
|
||
|
||
// #85 reverted: host 自己同步杀 Electron 会导致 host 作为 Electron 子进程被 Job
|
||
// Object 连带杀,spawnHelper 来不及调用 → relaunch 彻底失败 (YG 实测现象:点"重启"
|
||
// 没反应,DSH 全死没 relaunch)。改回让 helper 全权处理 kill+relaunch,crash dialog
|
||
// 问题靠 patch #83+#84 (新 main.js 加载后 close 回调 silent) 解决第二次之后,
|
||
// 第一次 restart 的 crash dialog 是 Electron 内存版本滞后的必然代价,无法避免。
|
||
|
||
/** 换包助手不能再用 Electron 本体跑:Stop-HarnessInstall / taskkill 按 exe 名会先把自己杀掉,app.asar 永远挪不开(#133)。 */
|
||
function helperNodePath() {
|
||
if (!process.versions.electron) return process.execPath;
|
||
const hinted = String(process.env.DSH_NODE || "").trim();
|
||
if (hinted && isFile(hinted)) return hinted;
|
||
try {
|
||
const out = execFileSync(process.platform === "win32" ? "where.exe" : "which", ["node"], {
|
||
encoding: "utf8",
|
||
windowsHide: true,
|
||
timeout: 3000,
|
||
});
|
||
const first = String(out || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
||
if (first && isFile(first)) return first;
|
||
} catch {
|
||
/* 没有系统 node 时再退回 Electron + RUN_AS_NODE */
|
||
}
|
||
return process.execPath;
|
||
}
|
||
|
||
function spawnHelper(helper, payload, opts = {}) {
|
||
const plan = restartPlan();
|
||
const hinted = payload && payload.execPath ? payload.execPath : "";
|
||
const execPath = hinted || plan.execPath;
|
||
if (!execPath) throw new Error(plan.error || "未找到 DSH Desktop.exe,无法重启桌面应用");
|
||
const runner = helperNodePath();
|
||
const child = spawn(runner, [helper], {
|
||
detached: true,
|
||
stdio: "ignore",
|
||
windowsHide: true,
|
||
shell: false,
|
||
env: helperEnv({
|
||
kind: "desktop",
|
||
execPath,
|
||
argv: plan.argv,
|
||
cwd: plan.cwd || path.dirname(execPath),
|
||
pid: process.pid,
|
||
helperPidKeep: true,
|
||
...payload,
|
||
}, runner),
|
||
});
|
||
child.unref();
|
||
if (opts.exit !== false) setTimeout(() => process.exit(0), opts.exitAfter ?? 250);
|
||
}
|
||
|
||
function desktopRuntimeOf(ctx) {
|
||
if (!ctx || typeof ctx.get !== "function") return null;
|
||
const runtime = ctx.get("desktopRuntime") || ctx.get("desktopActions");
|
||
return runtime && typeof runtime.requestRestart === "function" ? runtime : null;
|
||
}
|
||
|
||
export const ASAR_SWAP_PS1 = [
|
||
"param(",
|
||
" [Parameter(Mandatory = $true)][int]$WaitPid,",
|
||
" [Parameter(Mandatory = $true)][string]$AsarPath,",
|
||
" [Parameter(Mandatory = $true)][string]$ExePath",
|
||
")",
|
||
"$ErrorActionPreference = 'Continue'",
|
||
"$log = Join-Path $env:TEMP 'dsh-purge-desktop.log'",
|
||
"function Write-SwapLog([string]$message) {",
|
||
" try { Add-Content -LiteralPath $log -Value ((Get-Date).ToUniversalTime().ToString('o') + ' [asar-swap] ' + $message) -ErrorAction SilentlyContinue } catch {}",
|
||
"}",
|
||
"$installRoot = (Split-Path -Parent $ExePath).TrimEnd('\\')",
|
||
"$exeNorm = $ExePath.ToLower()",
|
||
"function Stop-HarnessInstall {",
|
||
" # 仅按本次安装根路径杀本安装的进程;不再用 /IM 全名杀,避免误伤同名第三方。",
|
||
" Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
|
||
" $ep = $_.ExecutablePath",
|
||
" if (-not $ep) { return $false }",
|
||
" $low = $ep.ToLower()",
|
||
" return ($low -eq $exeNorm) -or $low.StartsWith($installRoot.ToLower() + '\\')",
|
||
" } | ForEach-Object {",
|
||
" Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue",
|
||
" }",
|
||
"}",
|
||
"# #64 crash-dialog 根因消除:不再等 WaitPid (host) 优雅退出,因为那样 Electron main",
|
||
"# 会先看到 host 退出 -> main.js:3725 ChildProcess listener 弹\"host stopped\"对话框。",
|
||
"# 改为立即 Stop-HarnessInstall (包括 Electron main + host 一起杀),Electron 还没",
|
||
"# 来得及处理 host 退出事件就被 kill,UI 根本不存在,无法弹 crash dialog。",
|
||
"# PowerShell 本身是 detached 独立进程(spawn 时 detached:true, stdio:ignore),",
|
||
"# 杀 Electron 不会连带杀 PowerShell。",
|
||
"# #197b: 先杀 Electron main。main 是唯一会弹「host stopped」对话框的进程:",
|
||
"# 若先杀 host,main 还活着就会弹框,然后脚本若在挪 asar 前失败,用户就卡在",
|
||
"# 「host 死了、main 半死、asar 没动」的状态(2026-10-31 实测)。先杀 main 就没框。",
|
||
"$mains = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
|
||
" $ep = $_.ExecutablePath",
|
||
" if (-not $ep) { return $false }",
|
||
" ($ep.ToLower() -eq $exeNorm) -and ($_.CommandLine -notmatch '--type=')",
|
||
"})",
|
||
"foreach ($p in $mains) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue }",
|
||
"Start-Sleep -Milliseconds 400",
|
||
"Stop-HarnessInstall",
|
||
"Start-Sleep -Milliseconds 500",
|
||
"Stop-HarnessInstall",
|
||
"Start-Sleep -Milliseconds 300",
|
||
"# 兜底:万一上面没完全杀干净(例如 CimInstance 枚举漏掉某个进程),再按短超时等一下。",
|
||
"$deadline = (Get-Date).AddSeconds(10)",
|
||
"while ((Get-Date) -lt $deadline) {",
|
||
" $self = Get-Process -Id $WaitPid -ErrorAction SilentlyContinue",
|
||
" $holding = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
|
||
" $ep = $_.ExecutablePath",
|
||
" if (-not $ep) { return $false }",
|
||
" $low = $ep.ToLower()",
|
||
" ($low -eq $exeNorm) -or $low.StartsWith($installRoot.ToLower() + '\\')",
|
||
" })",
|
||
" if (-not $self -and $holding.Count -eq 0) { break }",
|
||
" Stop-HarnessInstall",
|
||
" Start-Sleep -Milliseconds 300",
|
||
"}",
|
||
"Start-Sleep -Milliseconds 500",
|
||
"$appDir = Join-Path (Split-Path -Parent $AsarPath) 'app'",
|
||
"$unpacked = $AsarPath + '.unpacked'",
|
||
"$marker = Join-Path $appDir 'dsh\\node_modules\\node-addon-require-builtin-win32-x64-msvc\\prebuilt\\win32-x64-msvc-napi-v9.node'",
|
||
"if ((Test-Path -LiteralPath $unpacked) -and -not (Test-Path -LiteralPath $marker)) {",
|
||
" & robocopy.exe $unpacked $appDir /E /XC /XN /XO /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null",
|
||
"}",
|
||
"$moved = -not (Test-Path -LiteralPath $AsarPath)",
|
||
"$hadAsar = -not $moved",
|
||
"$moveErr = ''",
|
||
"if (Test-Path -LiteralPath $AsarPath) {",
|
||
" $bak = \"$AsarPath.bak\"",
|
||
" if (Test-Path -LiteralPath $bak) { Remove-Item -LiteralPath $bak -Force -ErrorAction SilentlyContinue }",
|
||
" for ($i = 0; $i -lt 80; $i++) {",
|
||
" try {",
|
||
" Move-Item -LiteralPath $AsarPath -Destination $bak -Force -ErrorAction Stop",
|
||
" $moveErr = ''",
|
||
" break",
|
||
" } catch {",
|
||
" $moveErr = $_.Exception.Message",
|
||
" if ($i -eq 20 -or $i -eq 50) { Stop-HarnessInstall; Start-Sleep -Seconds 1 }",
|
||
" Start-Sleep -Milliseconds 400",
|
||
" }",
|
||
" }",
|
||
" if ((Test-Path -LiteralPath $AsarPath) -and $moveErr) {",
|
||
" # Move-Item 偶尔报『拒绝访问』但其实只是句柄没释放;.NET 直调再试一次并保留真实错误。",
|
||
" try {",
|
||
" if (Test-Path -LiteralPath $bak) { [System.IO.File]::Delete($bak) }",
|
||
" [System.IO.File]::Move($AsarPath, $bak)",
|
||
" $moveErr = ''",
|
||
" } catch { $moveErr = $_.Exception.Message }",
|
||
" }",
|
||
" $moved = ($moveErr -eq '') -and (-not (Test-Path -LiteralPath $AsarPath))",
|
||
"}",
|
||
"$lock = $AsarPath + '.rename-pending'",
|
||
"if ($moved) {",
|
||
" Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue",
|
||
" Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue",
|
||
" Write-SwapLog $(if ($hadAsar) { 'moved asar -> bak (verified)' } else { 'asar already absent' })",
|
||
" Start-Process -FilePath $ExePath",
|
||
"} else {",
|
||
" # #197b: 挪不动(asar 仍被占用)时不要把用户扔在空桌面上——把原版拉起来,",
|
||
" # 留下 rename-pending 让下一次空闲重试;失败原因写进 dsh-purge 日志。",
|
||
" Set-Content -LiteralPath $lock -Value ((Get-Date).ToString('o')) -Encoding ascii",
|
||
" Write-SwapLog ('move failed: ' + $moveErr + '; relaunching original app (asar still sealed)')",
|
||
" Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue",
|
||
" Start-Process -FilePath $ExePath",
|
||
"}",
|
||
"",
|
||
].join("\r\n");
|
||
|
||
const ASAR_RENAME_WHEN_IDLE_PS1 = [
|
||
"param(",
|
||
" [Parameter(Mandatory = $true)][string]$AsarPath,",
|
||
" [Parameter(Mandatory = $true)][string]$ExePath,",
|
||
" [Parameter(Mandatory = $true)][string]$LockPath",
|
||
")",
|
||
"$ErrorActionPreference = 'Continue'",
|
||
"$deadline = (Get-Date).AddMinutes(30)",
|
||
"$tries = 0",
|
||
"while ((Get-Date) -lt $deadline) {",
|
||
" $restartFlag = Join-Path $env:TEMP 'dsh-purge-official-restarting'",
|
||
" if (Test-Path -LiteralPath $restartFlag) {",
|
||
" $flagAge = (Get-Date) - (Get-Item -LiteralPath $restartFlag).LastWriteTime",
|
||
" if ($flagAge.TotalSeconds -ge 0 -and $flagAge.TotalSeconds -lt 45) { exit 0 }",
|
||
" Remove-Item -LiteralPath $restartFlag -Force -ErrorAction SilentlyContinue",
|
||
" }",
|
||
" $holding = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { try { $_.Path -eq $ExePath } catch { $false } })",
|
||
" if ($holding.Count -gt 0) { Start-Sleep -Milliseconds 500; continue }",
|
||
" if (-not (Test-Path -LiteralPath $AsarPath)) {",
|
||
" Remove-Item -LiteralPath $LockPath -Force -ErrorAction SilentlyContinue",
|
||
" exit 0",
|
||
" }",
|
||
" $appDir = Join-Path (Split-Path -Parent $AsarPath) 'app'",
|
||
" $unpacked = $AsarPath + '.unpacked'",
|
||
" $marker = Join-Path $appDir 'dsh\\node_modules\\node-addon-require-builtin-win32-x64-msvc\\prebuilt\\win32-x64-msvc-napi-v9.node'",
|
||
" if ((Test-Path -LiteralPath $unpacked) -and -not (Test-Path -LiteralPath $marker)) {",
|
||
" & robocopy.exe $unpacked $appDir /E /XC /XN /XO /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null",
|
||
" }",
|
||
" $bak = \"$AsarPath.bak\"",
|
||
" if (Test-Path -LiteralPath $bak) { Remove-Item -LiteralPath $bak -Force -ErrorAction SilentlyContinue }",
|
||
" $tries++",
|
||
" try {",
|
||
" Move-Item -LiteralPath $AsarPath -Destination $bak -Force -ErrorAction Stop",
|
||
" $renameErr = ''",
|
||
" } catch { $renameErr = $_.Exception.Message }",
|
||
" if ($renameErr -or (Test-Path -LiteralPath $AsarPath)) {",
|
||
" try {",
|
||
" if (Test-Path -LiteralPath $bak) { [System.IO.File]::Delete($bak) }",
|
||
" [System.IO.File]::Move($AsarPath, $bak)",
|
||
" $renameErr = ''",
|
||
" } catch { $renameErr = $_.Exception.Message }",
|
||
" }",
|
||
" if (Test-Path -LiteralPath $AsarPath) {",
|
||
" if ($tries -eq 1 -or ($tries % 300) -eq 0) {",
|
||
" try { Add-Content -LiteralPath (Join-Path $env:TEMP 'dsh-purge-desktop.log') -Value ((Get-Date).ToUniversalTime().ToString('o') + ' [asar-idle] move failed: ' + $renameErr) } catch {}",
|
||
" }",
|
||
" Start-Sleep -Milliseconds 400",
|
||
" continue",
|
||
" }",
|
||
" Remove-Item -LiteralPath $LockPath -Force -ErrorAction SilentlyContinue",
|
||
" Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue",
|
||
" Start-Process -FilePath $ExePath",
|
||
" exit 0",
|
||
"}",
|
||
"Remove-Item -LiteralPath $LockPath -Force -ErrorAction SilentlyContinue",
|
||
"",
|
||
].join("\r\n");
|
||
|
||
const ASAR_RENAME_WHEN_IDLE_SH = [
|
||
"#!/bin/sh",
|
||
"asar=$1",
|
||
"exe=$2",
|
||
"lock=$3",
|
||
"deadline=$(($(date +%s) + 1800))",
|
||
"relaunch() {",
|
||
" unset ELECTRON_RUN_AS_NODE",
|
||
" case \"$(uname -s)\" in",
|
||
" Darwin)",
|
||
" case \"$exe\" in",
|
||
" */Contents/MacOS/*)",
|
||
" bundle=$(dirname \"$(dirname \"$(dirname \"$exe\")\")\")",
|
||
" open \"$bundle\"",
|
||
" return",
|
||
" ;;",
|
||
" esac",
|
||
" ;;",
|
||
" esac",
|
||
" nohup \"$exe\" >/dev/null 2>&1 &",
|
||
"}",
|
||
"app_running() {",
|
||
" ps -Ao command= 2>/dev/null | grep -F \"$exe\" | grep -v \"dsh-purge-asar-rename-when-idle\" | grep -v grep >/dev/null",
|
||
"}",
|
||
"while [ \"$(date +%s)\" -lt \"$deadline\" ]; do",
|
||
" if [ ! -e \"$asar\" ]; then",
|
||
" rm -f \"$lock\"",
|
||
" exit 0",
|
||
" fi",
|
||
" if app_running; then",
|
||
" sleep 1",
|
||
" continue",
|
||
" fi",
|
||
" bak=\"$asar.bak\"",
|
||
" rm -f \"$bak\"",
|
||
" if mv \"$asar\" \"$bak\" 2>/dev/null; then",
|
||
" rm -f \"$lock\"",
|
||
" relaunch",
|
||
" exit 0",
|
||
" fi",
|
||
" sleep 1",
|
||
"done",
|
||
"rm -f \"$lock\"",
|
||
"exit 1",
|
||
"",
|
||
].join("\n");
|
||
|
||
/** 官方壳开着时 app.asar 被占用。不结束进程,等用户退出后再改名并重新打开。 */
|
||
export function scheduleAsarRenameWhenIdle({ exe, asar } = {}) {
|
||
if (!exe || !asar) return { pending: false };
|
||
const lock = `${asar}.rename-pending`;
|
||
try {
|
||
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
||
if (age >= 0 && age < 30 * 60 * 1000) return { pending: true, already: true };
|
||
// age<0 时钟回拨或 age>=30min stale,视为失效,清掉重做。
|
||
try { fs.unlinkSync(lock); } catch { /* ignore */ }
|
||
} catch {
|
||
// 没有待处理标记,下面新建。
|
||
}
|
||
fs.writeFileSync(lock, `${Date.now()}\n`, "utf8");
|
||
if (process.platform === "win32") {
|
||
const script = path.join(os.tmpdir(), `dsh-purge-asar-rename-when-idle-${restartUniqSuffix()}.ps1`);
|
||
fs.writeFileSync(script, ASAR_RENAME_WHEN_IDLE_PS1, "utf8");
|
||
const child = spawn(
|
||
"powershell.exe",
|
||
[
|
||
"-NoProfile",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-File",
|
||
script,
|
||
"-AsarPath",
|
||
asar,
|
||
"-ExePath",
|
||
exe,
|
||
"-LockPath",
|
||
lock,
|
||
],
|
||
{ detached: true, stdio: "ignore", windowsHide: true, shell: false },
|
||
);
|
||
// detached spawn 的 ENOENT / 权限错误走 'error' 事件,没监听会 uncaught → 整个进程崩。
|
||
child.on("error", (err) => {
|
||
try { console.warn("[dsh-purge] asar rename idle PS1 spawn 失败:", err?.message || err); } catch { /* 忽略 */ }
|
||
});
|
||
child.unref();
|
||
return { pending: true };
|
||
}
|
||
const script = path.join(os.tmpdir(), `dsh-purge-asar-rename-when-idle-${restartUniqSuffix()}.sh`);
|
||
fs.writeFileSync(script, ASAR_RENAME_WHEN_IDLE_SH, { encoding: "utf8", mode: 0o755 });
|
||
const child = spawn("/bin/sh", [script, asar, exe, lock], {
|
||
detached: true,
|
||
stdio: "ignore",
|
||
shell: false,
|
||
});
|
||
child.on("error", (err) => {
|
||
try { console.warn("[dsh-purge] asar rename idle sh spawn 失败:", err?.message || err); } catch { /* 忽略 */ }
|
||
});
|
||
child.unref();
|
||
return { pending: true };
|
||
}
|
||
|
||
/** 等当前桌面进程退出后把 app.asar 挪走,再启动 exe。解开后的 resources/app 才会被加载。 */
|
||
export function scheduleAsarSwapRestart({ exe, asar, waitPid = process.pid, exit = false, runtime = null, ctx = null } = {}) {
|
||
if (!exe || !asar) throw new Error("缺少桌面 exe 或 app.asar,无法重启");
|
||
// 若调用方没显式传 runtime,但传了 ctx —— 自己解析一次。
|
||
// 让启动期自愈路径 (index.js:scheduleApplyRestart / 2004 自愈分支) 透传 ctx 就能走优雅握手。
|
||
if (!runtime && ctx) {
|
||
try { runtime = desktopRuntimeOf(ctx); } catch { /* ignore */ }
|
||
}
|
||
// #64 crash-dialog 根因:宿主 main.js:3725 ChildProcess listener 把 host 退出当
|
||
// "host stopped" 弹崩溃对话框。双保险:
|
||
// 1) PowerShell 立即 Stop-HarnessInstall (杀 Electron main + host),Electron
|
||
// 还没来得及看到 host 退出事件就被 kill,UI 不存在,无法弹 crash dialog。
|
||
// 2) runtime.requestRestart 存在就先 ack 一次,给宿主"预期退出"标记窗口(若有)。
|
||
// host 自己的 process.exit 是最后兜底 —— 一般会先被 PowerShell kill。
|
||
const gracefulExit = () => {
|
||
if (!runtime) {
|
||
// 没 runtime —— 短 timeout 给 PowerShell 启动完成的时间(实测 ~200-500ms),
|
||
// 然后主动退出。PowerShell 会在这期间杀掉 Electron,即便 host 自己退出
|
||
// Electron 也已经不在了,不会弹 dialog。
|
||
logInternal("asar swap: no runtime handoff, relying on PowerShell to kill Electron first");
|
||
setTimeout(() => { try { process.exit(0); } catch {} }, 1500);
|
||
return;
|
||
}
|
||
logInternal("asar swap: handing off restart to desktopRuntime");
|
||
try {
|
||
// #64:requestRestart 可能"空返回"但副作用也许仍标记预期退出,双保险之一。
|
||
Promise.resolve(runtime.requestRestart()).catch((err) => {
|
||
logInternal("desktopRuntime.requestRestart rejected: " + (err?.message || err));
|
||
});
|
||
} catch (err) {
|
||
logInternal("desktopRuntime.requestRestart threw: " + (err?.message || err));
|
||
}
|
||
// 2000ms 兜底:给宿主 requestRestart 做完 + PowerShell 启动完成。
|
||
// 即便两条路径都挂,PowerShell 也会在 ~500ms 内 kill Electron,不会弹 crash。
|
||
setTimeout(() => {
|
||
try { process.exit(0); } catch {}
|
||
}, 2000);
|
||
};
|
||
|
||
if (sealedSwapKind() === "helper") {
|
||
const helper = fileURLToPath(new URL("./restart-desktop.js", import.meta.url));
|
||
spawnHelper(helper, {
|
||
respawn: true,
|
||
killApp: true,
|
||
asar,
|
||
execPath: exe,
|
||
cwd: path.dirname(exe),
|
||
waitPid,
|
||
}, { exit: false });
|
||
if (exit !== false) gracefulExit();
|
||
return { scheduled: true };
|
||
}
|
||
const script = path.join(os.tmpdir(), `dsh-purge-asar-swap-${process.pid}.ps1`);
|
||
fs.writeFileSync(script, ASAR_SWAP_PS1, "utf8");
|
||
const child = spawn(
|
||
"powershell.exe",
|
||
[
|
||
"-NoProfile",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-File",
|
||
script,
|
||
"-WaitPid",
|
||
String(waitPid),
|
||
"-AsarPath",
|
||
asar,
|
||
"-ExePath",
|
||
exe,
|
||
],
|
||
{ detached: true, stdio: "ignore", windowsHide: true, shell: false },
|
||
);
|
||
// 没挂 'error' listener,PowerShell 不在 PATH / 被 AV 拦就是 unhandled 'error' event,
|
||
// 会被 Node 当 uncaughtException 直接把宿主崩掉。
|
||
child.on("error", (err) => {
|
||
logInternal("asar swap PowerShell spawn failed: " + (err?.message || err));
|
||
});
|
||
child.unref();
|
||
if (exit !== false) gracefulExit();
|
||
return { scheduled: true };
|
||
}
|
||
|
||
/**
|
||
* 点「应用」之后走哪条重启。#64:宿主 requestRestart 在桌面端会空返回,进程不动。
|
||
* Windows 官方包用 wscript 结束再打开。Linux / macOS 能定位到 exe 就走 restart-desktop.js。
|
||
*/
|
||
export function desktopRestartChoice(input = {}) {
|
||
const platform = input.platform || process.platform;
|
||
const exe = input.exe || "";
|
||
const desktopExe = Boolean(exe && isDesktopExecutable(exe));
|
||
const official = Boolean(exe && isOfficialHarnessExecutable(exe));
|
||
if (input.openBrowser === false) return "skip";
|
||
if (platform === "win32" && official) return "windows-official";
|
||
if (desktopExe && input.asarSealed === true) return "asar-swap";
|
||
if (desktopExe) return "helper";
|
||
if (input.hasRuntime) return "request-restart";
|
||
return "helper";
|
||
}
|
||
|
||
/** 密封 asar 要先退出进程才能挪走文件。Windows 用 PowerShell;Linux / macOS 用同一份重启脚本。 */
|
||
export function sealedSwapKind(platform = process.platform) {
|
||
return platform === "win32" ? "powershell" : "helper";
|
||
}
|
||
|
||
function officialExeFromProcess() {
|
||
if (isOfficialHarnessExecutable(process.execPath) && isFile(process.execPath)) {
|
||
return path.normalize(process.execPath);
|
||
}
|
||
const resources = process.resourcesPath;
|
||
if (!resources) return "";
|
||
const hit = firstExistingExe([path.dirname(path.normalize(resources))]);
|
||
if (hit && isOfficialHarnessExecutable(hit)) return hit;
|
||
return "";
|
||
}
|
||
|
||
function dshHomeForRelaunch() {
|
||
const current = String(process.env.DSH_HOME || "").replace(/[\\/]+$/, "");
|
||
if (!current) return "";
|
||
if (current.toLowerCase().endsWith(".dsh")) return current;
|
||
const nested = path.join(current, ".dsh");
|
||
try {
|
||
if (fs.existsSync(path.join(nested, "profiles"))) return nested;
|
||
} catch {
|
||
// 用当前值。
|
||
}
|
||
return current;
|
||
}
|
||
|
||
export function scheduleRestart(request, ctx, options = {}) {
|
||
// 启动自检会带 openBrowser:false。桌面进程不再因此杀掉自己。
|
||
if (options.openBrowser === false) return { restarting: false, skipped: true };
|
||
const exe =
|
||
officialExeFromProcess() ||
|
||
findDesktopAppExecutable() ||
|
||
"";
|
||
const asar = exe ? appAsarPath(exe) : "";
|
||
// ctx 透传给 restartOfficialHarness → scheduleAsarSwapRestart,让 PowerShell 分支
|
||
// 拿到 desktopRuntime 走 requestRestart 优雅退出,避免宿主误报 crash。
|
||
if (process.platform === "win32" && exe && isOfficialHarnessExecutable(exe)) {
|
||
return restartOfficialHarness({ exe, asar, ctx });
|
||
}
|
||
if (exe && asar && asarArchiveIsFile(asar)) {
|
||
const runtime = desktopRuntimeOf(ctx);
|
||
scheduleAsarSwapRestart({ exe, asar, waitPid: process.pid, exit: true, runtime });
|
||
return { restarting: true, asarSwap: true };
|
||
}
|
||
const runtime = desktopRuntimeOf(ctx);
|
||
const choice = desktopRestartChoice({
|
||
exe,
|
||
asarSealed: false,
|
||
hasRuntime: Boolean(runtime),
|
||
});
|
||
if (choice === "helper" && exe && isDesktopExecutable(exe)) {
|
||
const helper = fileURLToPath(new URL("./restart-desktop.js", import.meta.url));
|
||
try {
|
||
spawnHelper(helper, {
|
||
respawn: true,
|
||
killApp: true,
|
||
execPath: exe,
|
||
cwd: path.dirname(exe),
|
||
}, { exit: false });
|
||
} catch (error) {
|
||
return {
|
||
restarting: false,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
return { restarting: true };
|
||
}
|
||
if (runtime) {
|
||
// 只走桌面整应用 relaunch,不要 exit Host(那会先把内嵌 web 单独拉起来)。
|
||
Promise.resolve(runtime.requestRestart()).catch((err) => {
|
||
logInternal("desktop requestRestart failed: " + (err?.message || err));
|
||
});
|
||
return { restarting: true };
|
||
}
|
||
const helper = fileURLToPath(new URL("./restart-desktop.js", import.meta.url));
|
||
// 没定位到 exe 时不杀进程:helper 现在按 exe 路径筛选,没有路径就找不到任何目标,
|
||
// 继续传 killApp:true 只会让 helper 空跑。
|
||
spawnHelper(helper, { respawn: true, killApp: Boolean(exe) }, { exit: false });
|
||
return { restarting: true };
|
||
}
|
||
|
||
function jsString(value) {
|
||
return wshLiteral(value);
|
||
}
|
||
|
||
function officialRestartScript(exe, asar, dshHome) {
|
||
const log = path.join(os.tmpdir(), "dsh-purge-official-restart.log");
|
||
// WSH 脚本里的 hostClean 与 host-clean.js 共用一份标记表,避免两套判定漂移。
|
||
const q = (s) => "'" + String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'";
|
||
const anyOf = (list) => list.map((m) => `body.indexOf(${q(m)}) >= 0`).join(" || ");
|
||
const hostCleanFn =
|
||
"function hostClean(body) { if (!body) return null; " +
|
||
`var ident = body.indexOf(${q(HOST_CLEAN_MARKERS.identity)}) >= 0; ` +
|
||
`var kept = ${anyOf(HOST_CLEAN_MARKERS.inject)}; ` +
|
||
"if (ident && kept) return true; if (body.indexOf('system-prompt/assemble') < 0) return false; " +
|
||
"var identityLeft = body.indexOf('You are an AI agent powered by DeepSeek Harness.') >= 0; " +
|
||
`var drops = ${anyOf(HOST_CLEAN_MARKERS.drops)}; return !identityLeft && !drops; }`;
|
||
return [
|
||
"var exe = " + jsString(exe) + ";",
|
||
"var asar = " + jsString(asar) + ";",
|
||
"var home = " + jsString(dshHome || "") + ";",
|
||
"var logPath = " + jsString(log) + ";",
|
||
"var fso = new ActiveXObject('Scripting.FileSystemObject');",
|
||
"var sh = new ActiveXObject('WScript.Shell');",
|
||
"var wmi = GetObject('winmgmts:\\\\\\\\.\\\\root\\\\cimv2');",
|
||
"function z(n) { return (n < 10 ? '0' : '') + n; }",
|
||
"function stamp() { var d = new Date(); return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate()) + 'T' + z(d.getHours()) + ':' + z(d.getMinutes()) + ':' + z(d.getSeconds()); }",
|
||
"function log(m) { var f = fso.OpenTextFile(logPath, 8, true); f.WriteLine(stamp() + ' ' + m); f.Close(); }",
|
||
"function samePath(a, b) { return String(a || '').replace(/\\//g, '\\\\').toLowerCase() === String(b || '').replace(/\\//g, '\\\\').toLowerCase(); }",
|
||
"function installRoot() { return fso.GetParentFolderName(exe); }",
|
||
// 不按镜像名查:DSH NEXT / 改过名的官方桌面端进程名不同,写死就找不到;
|
||
// 前缀匹配必须带路径分隔符,否则 D:\App 会连 D:\App-old 一起杀。
|
||
"function pids() { var out = []; var root = String(installRoot() || '').replace(/\\//g, '\\\\').toLowerCase(); if (root && root.charAt(root.length - 1) === '\\\\') root = root.slice(0, -1); var e = new Enumerator(wmi.ExecQuery('SELECT ProcessId, ExecutablePath FROM Win32_Process')); for (; !e.atEnd(); e.moveNext()) { var p = e.item(); var ep = String(p.ExecutablePath || '').replace(/\\//g, '\\\\').toLowerCase(); if (!ep) continue; if (samePath(p.ExecutablePath, exe) || (root && ep.indexOf(root + '\\\\') === 0)) out.push(p.ProcessId); } return out; }",
|
||
"log('begin');",
|
||
"var promptFile = asar.replace(/app\\.asar$/i, 'app') + '\\\\dsh\\\\node_modules\\\\@deepseek-ai\\\\dsh-system-prompt\\\\lib\\\\index.js';",
|
||
"var altFile = asar.replace(/app\\.asar$/i, 'app') + '\\\\node_modules\\\\@deepseek-ai\\\\dsh-system-prompt\\\\lib\\\\index.js';",
|
||
"function readBody(file) { if (!fso.FileExists(file)) return ''; var tf = fso.OpenTextFile(file, 1); var body = tf.ReadAll(); tf.Close(); return body; }",
|
||
hostCleanFn,
|
||
"var clean = false;",
|
||
"var primary = hostClean(readBody(promptFile));",
|
||
"var alt = hostClean(readBody(altFile));",
|
||
"// null = 文件不存在或暂无法判断(首装、读失败)。此时不阻塞启动;只有任一明确 false(真的脏)才拦。",
|
||
"clean = primary != false && alt != false;",
|
||
"if (!clean) { log('clean markers missing; not starting'); try { if (fso.FileExists(" + jsString(path.join(os.tmpdir(), "dsh-purge-official-restarting")) + ")) fso.DeleteFile(" + jsString(path.join(os.tmpdir(), "dsh-purge-official-restarting")) + ", true); } catch (ignoreStop) {} WScript.Quit(1); }",
|
||
"if (clean) {",
|
||
"log('clean markers ok');",
|
||
"WScript.Sleep(800);",
|
||
"var list = pids();",
|
||
"log('pids ' + list.join(','));",
|
||
"for (var i = 0; i < list.length; i++) sh.Run('taskkill.exe /F /PID ' + list[i], 0, true);",
|
||
"// 不再按 /IM 全名杀,避免误伤同名第三方。超时后再按 pid 列表重跑。",
|
||
"var deadline = new Date().getTime() + 20000;",
|
||
"var left = list;",
|
||
"while (new Date().getTime() < deadline) { left = pids(); if (left.length === 0) break; WScript.Sleep(200); }",
|
||
"log('left ' + left.length);",
|
||
"var unpacked = asar + '.unpacked';",
|
||
"var appDir = asar.replace(/app\\.asar$/i, 'app');",
|
||
"var marker = appDir + '\\\\dsh\\\\node_modules\\\\node-addon-require-builtin-win32-x64-msvc\\\\prebuilt\\\\win32-x64-msvc-napi-v9.node';",
|
||
"if (fso.FolderExists(unpacked) && !fso.FileExists(marker)) { sh.Run('robocopy.exe \"' + unpacked + '\" \"' + appDir + '\" /E /XC /XN /XO /NFL /NDL /NJH /NJS /nc /ns /np', 0, true); log('overlay'); }",
|
||
"var renamed = false;",
|
||
"if (!fso.FileExists(asar)) { renamed = true; log('asar already absent'); }",
|
||
"if (fso.FileExists(asar)) { var bak = asar + '.bak'; if (fso.FileExists(bak)) fso.DeleteFile(bak, true); for (var n = 0; n < 80; n++) { try { fso.MoveFile(asar, bak); renamed = true; log('renamed'); break; } catch (err) { log(err && err.message ? err.message : String(err)); if (n === 20 || n === 50) { var hold = pids(); for (var k = 0; k < hold.length; k++) sh.Run('taskkill.exe /F /PID ' + hold[k], 0, true); WScript.Sleep(800); } WScript.Sleep(300); } } }",
|
||
// 换包失败也要把原来的桌面端拉起来:以前这里直接退出,用户看到的是应用凭空消失。
|
||
"if (!renamed) { log('rename failed; relaunching original'); try { sh.Run('\"' + exe + '\"', 1, false); } catch (relaunchError) { log('relaunch failed: ' + (relaunchError && relaunchError.message ? relaunchError.message : String(relaunchError))); } try { if (fso.FileExists(" + jsString(path.join(os.tmpdir(), "dsh-purge-official-restarting")) + ")) fso.DeleteFile(" + jsString(path.join(os.tmpdir(), "dsh-purge-official-restarting")) + ", true); } catch (ignoreStop2) {} WScript.Quit(1); }",
|
||
"if (renamed) {",
|
||
"try { sh.Environment('PROCESS').Remove('ELECTRON_RUN_AS_NODE'); } catch (ignore) {}",
|
||
"try { sh.Environment('PROCESS').Remove('DSH_BASE'); } catch (ignoreBase) {}",
|
||
"if (home) { try { sh.Environment('PROCESS').Item('DSH_HOME') = home; } catch (ignore2) {} }",
|
||
"sh.Run('\"' + exe + '\"', 1, false);",
|
||
"log('started');",
|
||
"}",
|
||
"}",
|
||
// Keep the inflight window just long enough to cover a cold client start;
|
||
// the original 180s left the panel unresponsive for 3 minutes after every restart.
|
||
"WScript.Sleep(45000);",
|
||
"try { if (fso.FileExists(" + jsString(path.join(os.tmpdir(), "dsh-purge-official-restarting")) + ")) fso.DeleteFile(" + jsString(path.join(os.tmpdir(), "dsh-purge-official-restarting")) + ", true); } catch (ignoreFlag) {}",
|
||
"",
|
||
].join("\r\n");
|
||
}
|
||
|
||
function systemPromptFiles(exe) {
|
||
const appDir = appDirForExecutable(exe);
|
||
if (!appDir) return [];
|
||
return [
|
||
path.join(appDir, "dsh", "node_modules", "@deepseek-ai", "dsh-system-prompt", "lib", "index.js"),
|
||
path.join(appDir, "node_modules", "@deepseek-ai", "dsh-system-prompt", "lib", "index.js"),
|
||
].filter((fp) => {
|
||
try { return fs.existsSync(fp); } catch { return false; }
|
||
});
|
||
}
|
||
|
||
function unpackedHostIsClean(exe) {
|
||
const files = systemPromptFiles(exe);
|
||
if (files.length === 0) return false;
|
||
return files.every((fp) => {
|
||
try {
|
||
return hostPromptKeepsInject(fs.readFileSync(fp, "utf8"));
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
|
||
const OFFICIAL_RESTART_FLAG = "dsh-purge-official-restarting";
|
||
const OFFICIAL_RESTART_WINDOW_MS = 45000;
|
||
|
||
/**
|
||
* Windows 官方换包走哪条。
|
||
* 本机 wscript 能跑 .js 时仍用原来的脚本。跑不了(云端很多机器没有 JScript 引擎)
|
||
* 才改用已经在用的 PowerShell。没确认脚本已经开始,不把进行中的标记当成成功。
|
||
*/
|
||
export function planOfficialRestart({ wshJs, scriptAscii, inflightAgeMs, logStarted, logState } = {}) {
|
||
const age = Number(inflightAgeMs);
|
||
const fresh = Number.isFinite(age) && age >= 0 && age < OFFICIAL_RESTART_WINDOW_MS;
|
||
const state = logState || (logStarted === true ? "running" : "none");
|
||
// 只有脚本正在结束当前进程时才算进行中。写过 begin 又停掉的,这次点击要重新开始。
|
||
if (fresh && state === "running") return { action: "already" };
|
||
const kind = wshJs === true && scriptAscii !== false ? "wscript" : "powershell";
|
||
return {
|
||
action: "start",
|
||
kind,
|
||
writeInflight: kind === "wscript",
|
||
clearInflight: kind !== "wscript",
|
||
};
|
||
}
|
||
|
||
function officialRestartFlagPath() {
|
||
return path.join(os.tmpdir(), OFFICIAL_RESTART_FLAG);
|
||
}
|
||
|
||
function officialRestartLogState(sinceMs) {
|
||
try {
|
||
const log = path.join(os.tmpdir(), "dsh-purge-official-restart.log");
|
||
const st = fs.statSync(log);
|
||
if (sinceMs && st.mtimeMs + 1000 < sinceMs) return "none";
|
||
const lines = fs.readFileSync(log, "utf8").split(/\r?\n/).filter(Boolean);
|
||
let begin = -1;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/(^|\s)begin$/.test(lines[i])) begin = i;
|
||
}
|
||
if (begin < 0) return "none";
|
||
const tail = lines.slice(begin);
|
||
if (tail.some((line) => /not starting|rename failed/.test(line))) return "aborted";
|
||
if (tail.some((line) => /(^|\s)started$/.test(line))) return "done";
|
||
return "running";
|
||
} catch {
|
||
return "none";
|
||
}
|
||
}
|
||
|
||
let wshJsEngineCached;
|
||
function wshJsEngineWorks() {
|
||
if (wshJsEngineCached !== undefined) return wshJsEngineCached;
|
||
wshJsEngineCached = probeWshJsEngine();
|
||
return wshJsEngineCached;
|
||
}
|
||
|
||
function probeWshJsEngine() {
|
||
const id = `${process.pid}-${Date.now()}`;
|
||
const script = path.join(os.tmpdir(), `dsh-purge-wsh-probe-${id}.js`);
|
||
const out = path.join(os.tmpdir(), `dsh-purge-wsh-probe-${id}.txt`);
|
||
try {
|
||
const body = "var fso=new ActiveXObject('Scripting.FileSystemObject');var f=fso.CreateTextFile("
|
||
+ wshLiteral(out)
|
||
+ ", true);f.WriteLine('ok');f.Close();\r\n";
|
||
fs.writeFileSync(script, body, "utf8");
|
||
execFileSync("cscript.exe", ["//nologo", script], {
|
||
timeout: 4000,
|
||
windowsHide: true,
|
||
stdio: "ignore",
|
||
});
|
||
return fs.existsSync(out);
|
||
} catch {
|
||
return false;
|
||
} finally {
|
||
try { fs.unlinkSync(script); } catch { /* 探针用完即删 */ }
|
||
try { fs.unlinkSync(out); } catch { /* 探针用完即删 */ }
|
||
}
|
||
}
|
||
|
||
/** 点「重启」后结束当前官方客户端,挪开被占用的 app.asar,再自己打开。 */
|
||
export function restartOfficialHarness(options = {}) {
|
||
const hinted = options.exe ? path.normalize(String(options.exe)) : "";
|
||
const exe =
|
||
(hinted && isDesktopExecutable(hinted) ? hinted : "") ||
|
||
officialExeFromProcess() ||
|
||
findDesktopAppExecutable() ||
|
||
"";
|
||
if (!exe) return { restarting: false };
|
||
// ctx 可用时 → 拿 desktopRuntime 透传给 PowerShell 分支:
|
||
// 让宿主 ack 预期的 restart,避免 host 退出被当成 crash 弹对话框。
|
||
const runtime = options.runtime || (options.ctx ? desktopRuntimeOf(options.ctx) : null);
|
||
if (!isOfficialHarnessExecutable(exe)) {
|
||
const asar = options.asar || appAsarPath(exe);
|
||
if (asar && asarArchiveIsFile(asar)) {
|
||
scheduleAsarSwapRestart({
|
||
exe,
|
||
asar,
|
||
waitPid: process.pid,
|
||
exit: options.exit !== false,
|
||
runtime,
|
||
});
|
||
return { restarting: true, asarSwap: true };
|
||
}
|
||
return { restarting: false };
|
||
}
|
||
// #57:不自动跑官方 installer。宿主版本百花齐放,更新交接由用户自己做;
|
||
// 插件只负责解包/补丁。旧逻辑会在落盘前误判失败并清掉 resources\app。
|
||
if (options.requireClean !== false && !unpackedHostIsClean(exe)) {
|
||
return { restarting: false, clean: false };
|
||
}
|
||
const inflight = officialRestartFlagPath();
|
||
let inflightAgeMs = -1;
|
||
let inflightMtime = 0;
|
||
try {
|
||
const st = fs.statSync(inflight);
|
||
inflightAgeMs = Date.now() - st.mtimeMs;
|
||
inflightMtime = st.mtimeMs;
|
||
} catch {
|
||
// 没有进行中的重启。
|
||
}
|
||
const asar = options.asar || appAsarPath(exe);
|
||
const restartBody = officialRestartScript(exe, asar, dshHomeForRelaunch());
|
||
const scriptAscii = isWshAscii(restartBody);
|
||
const plan = planOfficialRestart({
|
||
wshJs: scriptAscii && wshJsEngineWorks(),
|
||
scriptAscii,
|
||
inflightAgeMs,
|
||
logState: officialRestartLogState(inflightMtime),
|
||
});
|
||
if (plan.action === "already") return { restarting: true, already: true };
|
||
if (plan.clearInflight) {
|
||
try { fs.unlinkSync(inflight); } catch { /* 没有残留标记 */ }
|
||
}
|
||
if (plan.kind === "powershell") {
|
||
// [dsh-purge-fix 2026-10-09] asar 已被挪走(unpacked 模式常态):
|
||
// ASAR_SWAP_PS1 fs.rename asar 会 ENOENT, 返 restarting:false 时 UI 显示
|
||
// "失败: not started"。改走 helper restart-desktop.js:它的 moveAsarAsideAsync
|
||
// 本身就处理 asar=null(第 283 行 fs.existsSync 不存在直接 return true),
|
||
// relaunchDesktop 走 execPath spawn,干净 kill+relaunch,无 asar swap。
|
||
if (!asar || !asarArchiveIsFile(asar)) {
|
||
logInternal("asar 已不存在(unpacked 模式),改走 helper kill+relaunch");
|
||
try {
|
||
const helper = fileURLToPath(new URL("./restart-desktop.js", import.meta.url));
|
||
spawnHelper(helper, {
|
||
respawn: true,
|
||
killApp: true,
|
||
asar: null,
|
||
execPath: exe,
|
||
cwd: path.dirname(exe),
|
||
waitPid: process.pid,
|
||
}, { exit: options.exit !== false });
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logInternal("unpacked helper restart failed: " + message);
|
||
return { restarting: false, error: message };
|
||
}
|
||
return { restarting: true, mode: "unpacked-helper", engine: "helper" };
|
||
}
|
||
logInternal("wscript 不能执行 .js,改用 PowerShell 挪开 app.asar");
|
||
try {
|
||
scheduleAsarSwapRestart({
|
||
exe,
|
||
asar,
|
||
waitPid: process.pid,
|
||
exit: options.exit !== false,
|
||
runtime,
|
||
});
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logInternal("PowerShell asar swap failed: " + message);
|
||
return { restarting: false, error: message };
|
||
}
|
||
return { restarting: true, asarSwap: true, engine: "powershell" };
|
||
}
|
||
if (plan.writeInflight) {
|
||
try { fs.writeFileSync(inflight, `${Date.now()}\n`, "utf8"); } catch { /* 标记写失败仍继续这一次 */ }
|
||
}
|
||
const asarPresent = Boolean(asar && asarArchiveIsFile(asar));
|
||
const swapLock = asar ? `${asar}.swap-lock` : "";
|
||
if (asarPresent && swapLock) {
|
||
try {
|
||
const lockStat = fs.statSync(swapLock);
|
||
const age = Date.now() - lockStat.mtimeMs;
|
||
// 锁还在、但重启日志没有 begin:上一轮 wscript 没跑起来,不能把它当成正在换包。
|
||
if (age >= 0 && age < 90 * 1000 && officialRestartLogState(lockStat.mtimeMs) === "running") {
|
||
return { restarting: true, already: true, asarSwap: true };
|
||
}
|
||
} catch {
|
||
// 没有进行中的切换。
|
||
}
|
||
try { fs.writeFileSync(swapLock, `${Date.now()}\n`, "utf8"); } catch {}
|
||
}
|
||
// 启动时清一次残留的旧 restart 脚本/日志。多实例时,脚本名用 pid+random 避免打架。
|
||
cleanupLegacyRestartScripts();
|
||
const script = path.join(os.tmpdir(), `dsh-purge-official-restart-${restartUniqSuffix()}.js`);
|
||
fs.writeFileSync(script, restartBody, "utf8");
|
||
// 直接起 wscript.exe;不写 .cmd 中介文件,避免路径有空格/引号时解析异常。
|
||
const child = spawn("wscript.exe", ["//nologo", "//B", script], {
|
||
detached: true,
|
||
stdio: "ignore",
|
||
windowsHide: true,
|
||
shell: false,
|
||
});
|
||
child.unref();
|
||
return { restarting: true, engine: "wscript" };
|
||
}
|
||
|
||
export function scheduleCleanupRestart({ cleanup = [], ctx } = {}) {
|
||
const src = fileURLToPath(new URL("./restart-desktop.js", import.meta.url));
|
||
/* 可预测的临时文件名在多用户 POSIX 下能被抢注成符号链接:带上随机后缀。 */
|
||
const dest = path.join(os.tmpdir(), `dsh-purge-uninst-${restartUniqSuffix()}.mjs`);
|
||
fs.copyFileSync(src, dest);
|
||
const runtime = desktopRuntimeOf(ctx);
|
||
if (runtime) {
|
||
spawnHelper(dest, { cleanup, helper: dest, respawn: false }, { exit: false });
|
||
Promise.resolve(runtime.requestRestart()).catch((err) => {
|
||
logInternal("desktop uninstall restart failed: " + (err?.message || err));
|
||
});
|
||
return { restarting: true };
|
||
}
|
||
spawnHelper(dest, { cleanup, helper: dest, respawn: true, killApp: true }, { exit: false });
|
||
return { restarting: true };
|
||
}
|
||
|
||
const CLI_ENTRY_BEGIN = "# dsh-purge cli entry begin";
|
||
const CLI_ENTRY_END = "# dsh-purge cli entry end";
|
||
const CLI_ASAR_ENTRY = "app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js";
|
||
// #94:官方桌面版把 dsh host 放在 app.asar.unpacked(见 isAiBase 的 app.asar.unpacked 分支),
|
||
// 不在封装的 app.asar 里。stock 启动脚本却写死 app.asar/.../cli.js → Cannot find module。
|
||
// 回退链必须是 app.asar → app.asar.unpacked → app/,少了中间这级 macOS 开箱即坏。
|
||
const CLI_UNPACKED_ENTRY = "app.asar.unpacked/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js";
|
||
const CLI_DIR_ENTRY = "app/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js";
|
||
|
||
function ensureAppRuntimeSymlink(resources) {
|
||
const appDir = path.join(resources, "app");
|
||
const runtime = path.join(resources, "runtime");
|
||
const link = path.join(appDir, "runtime");
|
||
if (!isDir(appDir) || !isDir(runtime)) return { ok: false, reason: "missing" };
|
||
try {
|
||
if (fs.existsSync(link)) {
|
||
const st = fs.lstatSync(link);
|
||
if (st.isSymbolicLink() || st.isDirectory()) return { ok: true, already: true };
|
||
}
|
||
} catch {
|
||
// create below
|
||
}
|
||
try {
|
||
try {
|
||
fs.rmSync(link, { recursive: true, force: true });
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (process.platform === "win32") {
|
||
fs.symlinkSync("..\\runtime", link, "junction");
|
||
return { ok: true, created: true, type: "junction" };
|
||
}
|
||
fs.symlinkSync("../runtime", link, "dir");
|
||
return { ok: true, created: true, type: "dir" };
|
||
} catch (e) {
|
||
return { ok: false, error: String(e && e.message ? e.message : e) };
|
||
}
|
||
}
|
||
|
||
function stripShimEntryBlock(text) {
|
||
return text.replace(/# dsh-purge cli entry begin[\s\S]*?# dsh-purge cli entry end\r?\n?/g, "");
|
||
}
|
||
|
||
/** 官方 CLI 启动脚本是否已经补过入口回退块。桌面端修的是这个,不是 "dsh-purge shim" 注入。 */
|
||
export function desktopCliEntryPatched(fp) {
|
||
if (!isFile(fp)) return false;
|
||
try {
|
||
return fs.readFileSync(fp, "utf8").includes("dsh-purge cli entry");
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function patchOfficialCliShimText(text) {
|
||
if (!text) return { changed: false, text };
|
||
// 老版本已改成 "$entry"、asar 原文不在了,但 BEGIN 标记还在 → 也要进来升级成三级回退。
|
||
if (!text.includes(CLI_ASAR_ENTRY) && !text.includes(CLI_ENTRY_BEGIN)) return { changed: false, text };
|
||
|
||
// 先铲掉旧块(可能是两级回退),再把裸 asar 路径归一成 "$entry",最后插入最新三级块,保证幂等升级。
|
||
let next = stripShimEntryBlock(text);
|
||
next = next.replace(
|
||
/"\$resources\/app\.asar\/dsh\/node_modules\/@deepseek-ai\/dsh-desktop-host\/lib\/cli\.js"/g,
|
||
'"$entry"',
|
||
);
|
||
next = next.replace(
|
||
/\$resources\/app\.asar\/dsh\/node_modules\/@deepseek-ai\/dsh-desktop-host\/lib\/cli\.js/g,
|
||
'"$entry"',
|
||
);
|
||
if (!next.includes('"$entry"')) return { changed: false, text };
|
||
|
||
const block = [
|
||
CLI_ENTRY_BEGIN,
|
||
`entry="$resources/${CLI_ASAR_ENTRY}"`,
|
||
`if [ ! -f "$entry" ]; then entry="$resources/${CLI_UNPACKED_ENTRY}"; fi`,
|
||
`if [ ! -f "$entry" ]; then entry="$resources/${CLI_DIR_ENTRY}"; fi`,
|
||
CLI_ENTRY_END,
|
||
"",
|
||
].join("\n");
|
||
const insertAt = next.search(/ELECTRON_RUN_AS_NODE=1/);
|
||
if (insertAt >= 0) {
|
||
// 插到该行行首,避免把 `export ELECTRON_RUN_AS_NODE=1` 从中间劈开。
|
||
const lineStart = next.lastIndexOf("\n", insertAt) + 1;
|
||
next = `${next.slice(0, lineStart)}${block}${next.slice(lineStart)}`;
|
||
} else {
|
||
next = `${block}${next}`;
|
||
}
|
||
|
||
if (next === text) return { changed: false, text, already: true };
|
||
return { changed: true, text: next };
|
||
}
|
||
|
||
const CMD_ENTRY_BEGIN = "REM dsh-purge cli entry begin";
|
||
const CMD_ENTRY_END = "REM dsh-purge cli entry end";
|
||
const CMD_CLI_TAIL = "dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli.js";
|
||
|
||
function cmdEntryAlreadyGood(text) {
|
||
if (!text.includes(CMD_ENTRY_BEGIN) || !text.includes(CMD_ENTRY_END)) return false;
|
||
if (/set\s+"entry=%entry%"/i.test(text)) return false;
|
||
if (!/set\s+"entry=%~dp0[^"\r\n]*app\.asar\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli\.js"/i.test(text)) return false;
|
||
// #94:必须含 app.asar.unpacked 这一级回退,否则判为旧块强制升级。
|
||
if (!/if not exist "%entry%" set "entry=%~dp0[^"\r\n]*app\.asar\.unpacked\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli\.js"/i.test(text)) return false;
|
||
if (!/if not exist "%entry%" set "entry=%~dp0[^"\r\n]*app\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli\.js"/i.test(text)) return false;
|
||
const outside = text.replace(/set\s+"entry=[^"]*"/gi, "");
|
||
if (/app\.asar\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli\.js/i.test(outside)) return false;
|
||
return /"%entry%"/.test(text);
|
||
}
|
||
|
||
function stripCmdEntryBlock(text) {
|
||
return text.replace(/\r?\n?REM dsh-purge cli entry begin[\s\S]*?REM dsh-purge cli entry end\r?\n?/gi, "\r\n");
|
||
}
|
||
|
||
function replaceQuotedInsensitive(text, pathValue, replacement) {
|
||
const needle = `"${pathValue}"`;
|
||
const lower = text.toLowerCase();
|
||
const want = needle.toLowerCase();
|
||
let out = "";
|
||
let i = 0;
|
||
while (i < text.length) {
|
||
const at = lower.indexOf(want, i);
|
||
if (at < 0) {
|
||
out += text.slice(i);
|
||
break;
|
||
}
|
||
out += text.slice(i, at) + replacement;
|
||
i = at + needle.length;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Windows 官方 dsh.cmd 用 %~dp0,没有 %resources%。先改原文,再插入回退,避免正则改到刚插入的行。 */
|
||
export function patchOfficialCliCmdText(text) {
|
||
if (!text) return { changed: false, text };
|
||
const hasAsar = text.toLowerCase().includes("app.asar\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli.js")
|
||
|| text.includes(CLI_ASAR_ENTRY);
|
||
const hasMarker = text.includes(CMD_ENTRY_BEGIN);
|
||
if (!hasAsar && !hasMarker) return { changed: false, text };
|
||
if (cmdEntryAlreadyGood(text)) return { changed: false, text, already: true };
|
||
|
||
const nl = text.includes("\r\n") ? "\r\n" : "\n";
|
||
// 先从原文(含旧块的 set 行)取 %~dp0 前缀,这样两级旧块也能升级成三级。
|
||
const found = text.match(/%~dp0[^"\r\n]*?app\.asar\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli\.js/i);
|
||
if (!found) return { changed: false, text };
|
||
const cut = found[0].toLowerCase().indexOf("app.asar\\");
|
||
if (cut < 0) return { changed: false, text };
|
||
const prefix = found[0].slice(0, cut);
|
||
let next = stripCmdEntryBlock(text);
|
||
next = replaceQuotedInsensitive(next, `${prefix}app.asar\\${CMD_CLI_TAIL}`, '"%entry%"');
|
||
next = replaceQuotedInsensitive(next, `${prefix}app.asar.unpacked\\${CMD_CLI_TAIL}`, '"%entry%"');
|
||
next = replaceQuotedInsensitive(next, `${prefix}app\\${CMD_CLI_TAIL}`, '"%entry%"');
|
||
const block = [
|
||
CMD_ENTRY_BEGIN,
|
||
`set "entry=${prefix}app.asar\\${CMD_CLI_TAIL}"`,
|
||
`if not exist "%entry%" set "entry=${prefix}app.asar.unpacked\\${CMD_CLI_TAIL}"`,
|
||
`if not exist "%entry%" set "entry=${prefix}app\\${CMD_CLI_TAIL}"`,
|
||
CMD_ENTRY_END,
|
||
"",
|
||
].join(nl);
|
||
const insertAt = next.search(/ELECTRON_RUN_AS_NODE=1/);
|
||
if (insertAt >= 0) {
|
||
const lineEnd = next.indexOf("\n", insertAt);
|
||
const at = lineEnd >= 0 ? lineEnd + 1 : next.length;
|
||
next = `${next.slice(0, at)}${block}${next.slice(at)}`;
|
||
} else {
|
||
next = block + next;
|
||
}
|
||
if (/set\s+"entry=%entry%"/i.test(next)) return { changed: false, text };
|
||
if (!/"%entry%"/.test(next)) return { changed: false, text };
|
||
if (!cmdEntryAlreadyGood(next)) return { changed: false, text };
|
||
return { changed: next !== text, text: next };
|
||
}
|
||
|
||
/** 解包 app.asar 后修补官方 CLI:入口回退到 app/,并补 app/runtime → ../runtime。 */
|
||
export function repairOfficialCliAfterUnpack(exe = findDesktopAppExecutable()) {
|
||
if (!exe || !isOfficialHarnessExecutable(exe)) return { ok: false, reason: "not_official" };
|
||
const resources = resourcesDirForExecutable(exe);
|
||
if (!resources || !isDir(resources)) return { ok: false, reason: "no_resources" };
|
||
const report = { ok: true, resources, symlink: null, shims: [] };
|
||
report.symlink = ensureAppRuntimeSymlink(resources);
|
||
const shimPaths = [
|
||
path.join(resources, "runtime", "cli", "bin", "dsh"),
|
||
path.join(resources, "runtime", "cli", "bin", "dsh.cmd"),
|
||
];
|
||
for (const fp of shimPaths) {
|
||
if (!isFile(fp)) {
|
||
report.shims.push({ path: fp, status: "missing" });
|
||
continue;
|
||
}
|
||
let text = "";
|
||
try {
|
||
text = fs.readFileSync(fp, "utf8");
|
||
} catch (e) {
|
||
report.shims.push({ path: fp, status: "read_error", error: String(e && e.message ? e.message : e) });
|
||
continue;
|
||
}
|
||
const patched = fp.toLowerCase().endsWith(".cmd")
|
||
? patchOfficialCliCmdText(text)
|
||
: patchOfficialCliShimText(text);
|
||
if (!patched.changed) {
|
||
report.shims.push({ path: fp, status: patched.already ? "already" : "skipped" });
|
||
continue;
|
||
}
|
||
try {
|
||
// 原子写之前先存一份 .dsh-purge.bak,断电/磁盘满也能回退到官方原文。
|
||
const bak = `${fp}.dsh-purge.bak`;
|
||
if (!isFile(bak)) {
|
||
try { atomicCopyFileSync(fp, bak); } catch { /* 写不了 bak 不致命 */ }
|
||
}
|
||
atomicWriteSync(fp, patched.text, "utf8");
|
||
// 启动脚本必须可执行。官方发的是 0755,一旦被写成 0644,`dsh` 就再也起不来。
|
||
// 原子写只保留原权限,权限已经丢过一次的情况下要显式补回来。
|
||
if (!fp.toLowerCase().endsWith(".cmd")) {
|
||
try { fs.chmodSync(fp, 0o755); } catch { /* 补不上不算这次修补失败 */ }
|
||
}
|
||
report.shims.push({ path: fp, status: "patched" });
|
||
} catch (e) {
|
||
report.shims.push({ path: fp, status: "write_error", error: String(e && e.message ? e.message : e) });
|
||
}
|
||
}
|
||
return report;
|
||
}
|
||
|