@nimoxie/dsh-ui-tweaks 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * dsh-ui-tweaks host half.
3
3
  *
4
- * 两条回环路由:
5
- * - GET /api/ui-tweaks/local-plugins:返回当前 profile package.json
6
- * dependencies 里非 registry 直装(link:/file:/git+/github:/http)的包名清单,
7
- * 外加运行中的 DSH 版本(`dsh` 字段,读不到为空串)—— 客户端更新面板据此把
8
- * 「新版要求更高 DSH」的行标成「DSH 不符」,不让人装上去起不来。
9
- * 浏览器半区的更新面板据此把这些插件标成「本地链接」或「Git 直装」、跳过
10
- * registry 比对 —— listBundles 不透传安装来源,而「npm 上恰好也发布过」的
11
- * 非 registry 源(如 @nimoxie/dsh-model-organizer)在客户端侧没有别的办法认。
12
- * - POST /api/ui-tweaks/normalize-excludes:手动触发一次下面的归并,回
13
- * {ok, changed, kept, removed};插件加载时也会静默跑一次。
14
- * - GET /api/ui-tweaks/restart:只读「重启方案」(shell / relaunch / manual),
15
- * POST 同路径才真的执行(先写响应、约 0.4s 后退出进程);非回环 403、其它方法 405。
16
- * 故意拆成两个方法:GET 无副作用,浏览器预取或直接粘 URL 都不会把宿主停掉。
17
- * 三种模式见文件末尾「模块 7」。
4
+ * 回环路由(只放行 127.0.0.1 / ::1;写操作集中在这里,客户端半区调它们):
5
+ * - GET /local-plugins:profile 里非 registry 直装的包名 + 运行中的 DSH 版本。
6
+ * 客户端据此把「本地链接 / Git 直装」标出来并跳过 registry 比对,也用来判「DSH 不符」。
7
+ * - POST /normalize-excludes:手动归并豁免表(加载时也会静默跑一次)。
8
+ * - POST /precheck-exempt:强制更新前的预检(列出仍在冷静期、会拦住这次安装的包)。
9
+ * - POST /force-exempt:把 name@version 写成豁免表里该包生效的第一条。
10
+ * - POST /schedule-exempt-cleanup:登记「到期自动删豁免」(版本满 24h 后清掉)。
11
+ * - POST /open-path:打开 / 显示位置 / 用指定应用打开(会话释放后改动卡的动作补位);
12
+ * action:"probe" 只诊断子进程能不能起,不弹窗。
13
+ * - POST /open-apps:系统文件关联枚举(同上,供「打开方式」菜单)。
14
+ * - GET|POST /restart:GET 只读重启方案(shell / relaunch / manual),POST 才执行。
15
+ * 故意拆两个方法:GET 无副作用,预取或直粘 URL 都不会把宿主停掉(见文末「模块 7」)。
18
16
  *
19
17
  * 写操作(全文件只此一处):归并 profile 的 pnpm-workspace.yaml 里
20
18
  * minimumReleaseAgeExclude 序列。为什么必须收敛:
@@ -54,7 +52,7 @@
54
52
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
55
53
  import { homedir, tmpdir } from "node:os";
56
54
  import { dirname, join } from "node:path";
57
- import { fileURLToPath } from "node:url";
55
+ import { fileURLToPath, pathToFileURL } from "node:url";
58
56
  import { spawn, spawnSync } from "node:child_process";
59
57
 
60
58
  export const name = "ui-tweaks";
@@ -116,6 +114,18 @@ function unquote(s) {
116
114
  return m ? m[2] : s;
117
115
  }
118
116
 
117
+ /**
118
+ * YAML 标量输出:plain scalar **不能**以 `@` / `` ` `` 等指示符开头,而 scoped 包名
119
+ * (@scope/pkg@1.2.3)全以 @ 开头 —— 直接裸写会让整个 pnpm-workspace.yaml 解析失败
120
+ * (pnpm 报 bad indentation of a sequence entry),profile 里所有 pnpm 操作全挂。
121
+ * 安全字符集以外的值一律用单引号包起来(内部单引号翻倍)。
122
+ */
123
+ function yamlScalar(value) {
124
+ const s = String(value);
125
+ if (/^[A-Za-z0-9_][A-Za-z0-9._~\/@+-]*$/.test(s)) { return s; }
126
+ return "'" + s.replace(/'/g, "''") + "'";
127
+ }
128
+
119
129
  /** 条目原文 → 裸值:去空白、去行尾注释、去引号 */
120
130
  function entryValue(raw) {
121
131
  const s = String(raw).trim();
@@ -254,7 +264,20 @@ export function normalizeReleaseAgeExcludes(dirOverride) {
254
264
 
255
265
  const entryIdx = new Set(entries.map((e) => e.idx));
256
266
  const keptInOrder = entries.filter((e) => keep.has(e.idx)).map((e) => e.value);
257
- const newRaw = lines.filter((line, i) => !entryIdx.has(i) || keep.has(i)).join("");
267
+ const byIdx = new Map(entries.map((e) => [e.idx, e]));
268
+ const newRaw = lines.map((line, i) => {
269
+ if (!entryIdx.has(i)) { return line; }
270
+ if (!keep.has(i)) { return ""; }
271
+ /* 顺手修掉历史上「以 @/` 开头的裸标量」:scoped 包名做 plain scalar 会让整个
272
+ pnpm-workspace.yaml 解析失败(pnpm: bad indentation of a sequence entry),
273
+ 一坏就是 profile 里所有 pnpm 操作全挂。只改确实不安全的那几行,其余保持原字节。 */
274
+ if (!/^\s*-\s+['"]/.test(line) && /^\s*-\s+[@`]/.test(line)) {
275
+ const e = byIdx.get(i);
276
+ const indent = (/^\s*/.exec(line) || [""])[0];
277
+ return indent + "- " + yamlScalar(e.value) + "\n";
278
+ }
279
+ return line;
280
+ }).join("");
258
281
 
259
282
  if (newRaw === raw) { return { ok: true, changed: false, kept: keptInOrder, removed: [], path: wsPath }; }
260
283
 
@@ -416,11 +439,11 @@ export function forceExemptVersion(name, version, dirOverride) {
416
439
  if (blk.keyLine < 0) {
417
440
  // 没有这个键:文件末尾追加一段(原字节全部保留)
418
441
  const sep = raw.length === 0 || raw.endsWith("\n") ? "" : "\n";
419
- newRaw = raw + sep + EXCLUDE_KEY + ":\n - " + value + "\n";
442
+ newRaw = raw + sep + EXCLUDE_KEY + ":\n - " + yamlScalar(value) + "\n";
420
443
  } else {
421
444
  const first = blk.entries[0];
422
445
  const indent = first ? (/^(\s*)-/.exec(first.line) || [])[1] || " " : " ";
423
- const insert = indent + "- " + value + "\n";
446
+ const insert = indent + "- " + yamlScalar(value) + "\n";
424
447
  const dup = blk.entries.find((e) => e.value === value);
425
448
  const lines = blk.lines.slice();
426
449
  if (dup) { lines.splice(dup.idx, 1); } // 已有同值条目:移到块首
@@ -437,6 +460,229 @@ export function forceExemptVersion(name, version, dirOverride) {
437
460
  }
438
461
  }
439
462
 
463
+ /**
464
+ * 到期清理:把 `name@version` 这一条从豁免表里删掉(幂等:没有就 no-op)。
465
+ * 用于强制更新的「装完不删、到期再删」—— 见 apply 里的 scheduleCleanup。
466
+ */
467
+ export function dropExemptVersion(name, version, dirOverride) {
468
+ try {
469
+ const dir = dirOverride || findProfileDir();
470
+ if (!dir) { return { ok: false, code: "profile-not-found" }; }
471
+ const wsPath = join(dir, WORKSPACE_FILE);
472
+ let raw;
473
+ try { raw = readFileSync(wsPath, "utf8"); }
474
+ catch (e) { return { ok: false, code: "workspace-unreadable: " + String((e && e.code) || e) }; }
475
+ const value = String(name) + "@" + String(version);
476
+ const blk = excludeBlockOf(raw);
477
+ const hit = blk.entries.find((e) => e.value === value);
478
+ if (!hit) { return { ok: true, changed: false, path: wsPath, entry: value }; }
479
+ const lines = blk.lines.slice();
480
+ lines.splice(hit.idx, 1);
481
+ const newRaw = lines.join("");
482
+ if (newRaw === raw) { return { ok: true, changed: false, path: wsPath, entry: value }; }
483
+ const w = atomicWriteWorkspace(dir, wsPath, raw, newRaw);
484
+ if (!w.ok) { return { ok: false, code: w.code, path: wsPath, backup: w.backup }; }
485
+ return { ok: true, changed: true, path: wsPath, backup: w.backup, entry: value };
486
+ } catch (e) {
487
+ return { ok: false, code: String((e && e.message) || e) };
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Explorer 只认「编码过的 file URI」:它在自己的命令行里按逗号/等号切字段,
493
+ * 裸路径遇到分隔符会把后半截丢掉(官方 dsh-native-command 同款处理)。
494
+ * 非 ASCII 的百分号转义要还原(Explorer 拒绝百分号编码的非 ASCII,会打开「文档」目录)。
495
+ */
496
+ export function explorerTargetOf(windowsPath) {
497
+ let uri;
498
+ try { uri = pathToFileURL(windowsPath, { windows: true }).href; }
499
+ catch { return windowsPath; }
500
+ return uri
501
+ .replace(/(?:%[89A-F][0-9A-F])+/gi, (escaped) => { try { return decodeURIComponent(escaped); } catch { return escaped; } })
502
+ .replace(/,/g, "%2C")
503
+ .replace(/=/g, "%3D");
504
+ }
505
+
506
+ /**
507
+ * 纯函数:把「路径 + 动作」翻成一条原生命令(便于离线单测,不 spawn)。
508
+ * 只负责「打开 / 定位」这两种;用指定应用打开由官方 native-command 负责。
509
+ * @returns {{cmd: string, args: string[]} | null} 路径不合法时 null。
510
+ */
511
+ export function nativeOpenCommand(targetPath, action) {
512
+ const p = String(targetPath || "");
513
+ const act = action === "reveal" ? "reveal" : "open";
514
+ if (!p) { return null; }
515
+ const windowsAbs = /^[A-Za-z]:[\\/]/.test(p) || p.slice(0, 2) === "\\\\";
516
+ const posixAbs = p.charAt(0) === "/";
517
+ if (!windowsAbs && !posixAbs) { return null; }
518
+ if (process.platform === "win32") {
519
+ if (act === "reveal") { return { cmd: "explorer.exe", args: ["/select,", explorerTargetOf(p)] }; }
520
+ return { cmd: "explorer.exe", args: [explorerTargetOf(p)] };
521
+ }
522
+ if (process.platform === "darwin") {
523
+ if (act === "reveal") { return { cmd: "open", args: ["-R", p] }; }
524
+ return { cmd: "open", args: [p] };
525
+ }
526
+ if (act === "reveal") { return { cmd: "xdg-open", args: [dirname(p)] }; }
527
+ return { cmd: "xdg-open", args: [p] };
528
+ }
529
+
530
+ /* ========================================================================
531
+ * 复用官方 @deepseek-ai/dsh-native-command(会话释放后补位的「打开方式」)
532
+ *
533
+ * 会话释放后官方 /api/changes.open 只回 404,但「枚举系统关联 + 打开/定位」
534
+ * 本身是那个包里与 Session 无关的纯工具函数,就住在同一个 Host 进程里。
535
+ * 这里按 Electron 布局定位到 app.asar 里的那份,动态 import 后直接复用:
536
+ * 应用列表、默认打开、显示位置、用指定应用打开,全部与官方一致;取不到模块
537
+ * 才退回本文件自带的 explorer 实现(nativeOpenCommand)。
538
+ * ===================================================================== */
539
+ const NATIVE_CMD_REL = join("node_modules", "@deepseek-ai", "dsh-native-command", "lib", "index.js");
540
+ let nativeCmdPromise = null;
541
+
542
+ function officialNativeCommand() {
543
+ if (nativeCmdPromise !== null) { return nativeCmdPromise; }
544
+ nativeCmdPromise = (async () => {
545
+ const exeDir = dirname(process.execPath);
546
+ const roots = [
547
+ join(exeDir, "resources", "app.asar", "dsh"),
548
+ join(exeDir, "resources", "app", "dsh"),
549
+ ];
550
+ for (const root of roots) {
551
+ const file = join(root, NATIVE_CMD_REL);
552
+ try {
553
+ if (!existsSync(file)) { continue; }
554
+ const mod = await import(pathToFileURL(file).href);
555
+ if (mod && typeof mod.nativeFileApplications === "function") { return mod; }
556
+ } catch { /* 试下一个布局 */ }
557
+ }
558
+ return null;
559
+ })().catch(() => null);
560
+ return nativeCmdPromise;
561
+ }
562
+
563
+ /** 官方应用条目 → 客户端 validator 能接受的形状(icon 只留 data:image,其余置 null)。 */
564
+ function nativeAppEntry(app) {
565
+ if (!app || typeof app.id !== "string" || !app.id) { return null; }
566
+ const name = typeof app.name === "string" && app.name ? app.name : app.id;
567
+ const icon = typeof app.icon === "string" && /^data:image\//.test(app.icon) ? app.icon : null;
568
+ return { id: app.id, name: name, default: app.default === true, icon: icon };
569
+ }
570
+
571
+ /** spawn 一个 GUI 启动器:等到 spawn/error 落定再回,别让 ENOENT 静默吞掉。 */
572
+ function spawnGui(cmd, args) {
573
+ return new Promise((resolve) => {
574
+ let settled = false;
575
+ const done = (r) => { if (!settled) { settled = true; resolve(r); } };
576
+ let child;
577
+ try {
578
+ /* windowsHide 必须为 false:GUI 启动器一旦带上 CREATE_NO_WINDOW/SW_HIDE,
579
+ 由它拉起的窗口会继承「隐藏」,看起来就像点了没反应(DSH 自己也用 visible)。 */
580
+ child = spawn(cmd, args, { detached: true, windowsHide: false, stdio: "ignore" });
581
+ } catch (e) {
582
+ done({ ok: false, code: String((e && e.code) || (e && e.message) || e), detail: String((e && e.message) || e) });
583
+ return;
584
+ }
585
+ child.on("error", (e) => done({ ok: false, code: String((e && e.code) || (e && e.message) || e), detail: String((e && e.message) || e) }));
586
+ child.on("spawn", () => { try { child.unref(); } catch { } done({ ok: true, pid: child.pid }); });
587
+ const timer = setTimeout(() => done({ ok: false, code: "spawn-timeout" }), 4000);
588
+ if (timer && typeof timer.unref === "function") { timer.unref(); }
589
+ });
590
+ }
591
+
592
+ /**
593
+ * 「打开方式」的应用列表(给客户端合成 GET /api/changes.open 用)。
594
+ * 口径与官方完全一致:**只列系统关联里的真实应用**,不再发「系统默认应用」
595
+ * 「选择其他应用…」这类自造项 —— 官方控件的规则是「主按钮=列表第一个应用、
596
+ * chevron 菜单列出全部、底部固定一条『显示文件位置』」,喂真实清单即可对齐。
597
+ * 枚举不出来就回空数组:控件会退化成「只有显示文件位置」的形态,绝不编造应用。
598
+ */
599
+ export async function listOpenApplications(targetPath) {
600
+ const p = String(targetPath || "");
601
+ if (!p) { return { ok: true, source: "fallback", apps: [] }; }
602
+ const mod = await officialNativeCommand();
603
+ if (!mod) { return { ok: true, source: "fallback", code: "native-command-unavailable", apps: [] }; }
604
+ const ctl = new AbortController();
605
+ const timer = setTimeout(() => { try { ctl.abort(); } catch { } }, 15000);
606
+ try {
607
+ const list = await mod.nativeFileApplications(p, ctl.signal);
608
+ const apps = [], seen = new Set();
609
+ for (const raw of Array.isArray(list) ? list : []) {
610
+ const entry = nativeAppEntry(raw);
611
+ if (entry && !seen.has(entry.id)) { seen.add(entry.id); apps.push(entry); }
612
+ }
613
+ return { ok: true, source: "native", count: apps.length, apps: apps };
614
+ } catch (e) {
615
+ return { ok: true, source: "fallback", code: String((e && e.code) || (e && e.message) || e), apps: [] };
616
+ } finally {
617
+ clearTimeout(timer);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * 打开 / 定位 / 用指定应用打开。优先用官方实现(与线上会话完全一致),
623
+ * 失败时把错误码带回去(客户端只看到失败,但日志与本机探测能看到真因)。
624
+ */
625
+ export async function openNativeTarget(targetPath, action, application) {
626
+ const p = String(targetPath || "");
627
+ const act = action === "reveal" ? "reveal" : "open";
628
+ const app = String(application || "");
629
+ if (!p) { return { ok: false, code: "path required" }; }
630
+ const windowsAbs = /^[A-Za-z]:[\\/]/.test(p) || p.slice(0, 2) === "\\\\";
631
+ const posixAbs = p.charAt(0) === "/";
632
+ if (!windowsAbs && !posixAbs) { return { ok: false, code: "absolute-path required" }; }
633
+ if (!existsSync(p)) { return { ok: false, code: "path-not-found", path: p }; }
634
+ const mod = await officialNativeCommand();
635
+ if (mod) {
636
+ const ctl = new AbortController();
637
+ const timer = setTimeout(() => { try { ctl.abort(); } catch { } }, 20000);
638
+ try {
639
+ if (act === "reveal") {
640
+ await mod.revealNativePath(p, ctl.signal);
641
+ } else if (app) {
642
+ await mod.openNativeFileApplication(p, app, ctl.signal);
643
+ } else {
644
+ await mod.openNativePath(p, ctl.signal);
645
+ }
646
+ return { ok: true, source: "native", action: act, application: app || null, path: p };
647
+ } catch (e) {
648
+ return { ok: false, source: "native", code: String((e && e.code) || (e && e.message) || e), action: act, application: app || null, path: p };
649
+ } finally {
650
+ clearTimeout(timer);
651
+ }
652
+ }
653
+ const command = nativeOpenCommand(p, act);
654
+ if (command === null) { return { ok: false, code: "absolute-path required" }; }
655
+ const r = await spawnGui(command.cmd, command.args);
656
+ if (!r.ok) { return { ok: false, source: "spawn", code: r.code, detail: r.detail, action: act, application: app || null, path: p }; }
657
+ return { ok: true, source: "spawn", action: act, application: app || null, path: p, pid: r.pid };
658
+ }
659
+
660
+ /** 只诊断、不弹窗:Host 进程能不能真正拉起子进程(cmd 输出 "probe" 即算通过)。 */
661
+ export async function probeNativeLaunch() {
662
+ const out = { ok: false };
663
+ try {
664
+ out.nativeCommand = !!(await officialNativeCommand());
665
+ out.child = await new Promise((resolve) => {
666
+ let settled = false;
667
+ const done = (r) => { if (!settled) { settled = true; resolve(r); } };
668
+ let child;
669
+ try { child = spawn("cmd.exe", ["/c", "echo", "probe"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); }
670
+ catch (e) { done({ spawn: false, code: String((e && e.code) || (e && e.message) || e) }); return; }
671
+ let stdout = "";
672
+ if (child.stdout) { child.stdout.on("data", (c) => { stdout += String(c); }); }
673
+ child.on("error", (e) => done({ spawn: false, code: String((e && e.code) || (e && e.message) || e) }));
674
+ child.on("close", (code) => done({ spawn: true, exitCode: code, stdout: stdout.trim() }));
675
+ const timer = setTimeout(() => done({ spawn: false, code: "timeout" }), 5000);
676
+ if (timer && typeof timer.unref === "function") { timer.unref(); }
677
+ });
678
+ out.ok = out.child.spawn === true && out.child.stdout === "probe";
679
+ return out;
680
+ } catch (e) {
681
+ out.code = String((e && e.message) || e);
682
+ return out;
683
+ }
684
+ }
685
+
440
686
  // 发布时长缓存:预检要查多个包,10 分钟内复用(发布时刻不会变,缓存只为省流量)
441
687
  const PUB_TIME_CACHE = new Map();
442
688
  const PUB_TIME_TTL_MS = 10 * 60 * 1000;
@@ -681,7 +927,7 @@ export function apply(ctx) {
681
927
  };
682
928
  const isLoopback = (req) => LOOPBACK_RE.test(String((req.socket && req.socket.remoteAddress) || ""));
683
929
 
684
- // 小体积 JSON body 读取(只给下面两条强制更新路由用;上限 8KB,超了当非法)
930
+ // 小体积 JSON body 读取(只给下面几条强制更新路由用;上限 8KB,超了当非法)
685
931
  const readJsonBody = (req, cb) => {
686
932
  let buf = "";
687
933
  let bad = false;
@@ -693,6 +939,61 @@ export function apply(ctx) {
693
939
  req.on("error", () => cb({}));
694
940
  };
695
941
 
942
+ /* ---- 强制更新豁免的到期清理 ----------------------------------------------
943
+ * 「装完立刻删豁免」是错的:pnpm 每次安装都会拿**整个 lockfile** 去校验供应链策略,
944
+ * 被强制装上的版本还没满 24 小时时删掉豁免,接下来几小时里游 profile 的**所有** pnpm
945
+ * 操作都会被整体拒绝(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION),比留着更糟。
946
+ * 正确时机是「该版本成熟之后」:客户端在强制更新时把到期时刻登记过来,这里落盘 +
947
+ * 到点删表项;宿重启后按文件记录重新排期(到期未处理的立刻删)。 */
948
+ const cleanupTimers = new Map(); // name@version → timer
949
+ const cleanupFile = () => {
950
+ const dir = findProfileDir();
951
+ return dir ? join(dir, "ui-tweaks", "exempt-pending.json") : null;
952
+ };
953
+ const readCleanupList = () => {
954
+ const f = cleanupFile();
955
+ if (!f) { return []; }
956
+ try {
957
+ const list = JSON.parse(readFileSync(f, "utf8"));
958
+ return Array.isArray(list) ? list.filter((it) => it && typeof it.name === "string" && typeof it.version === "string" && isFinite(it.dropAt)) : [];
959
+ } catch { return []; }
960
+ };
961
+ const writeCleanupList = (list) => {
962
+ const f = cleanupFile();
963
+ if (!f) { return; }
964
+ try {
965
+ mkdirSync(dirname(f), { recursive: true });
966
+ writeFileSync(f, JSON.stringify(list, null, 2) + "\n", "utf8");
967
+ } catch { /* 写不了就只在内存里生效 */ }
968
+ };
969
+ const runCleanup = (item) => {
970
+ const key = item.name + "@" + item.version;
971
+ const timer = cleanupTimers.get(key);
972
+ if (timer) { try { clearTimeout(timer); } catch { } cleanupTimers.delete(key); }
973
+ const r = dropExemptVersion(item.name, item.version);
974
+ const rest = readCleanupList().filter((it) => !(it.name === item.name && it.version === item.version));
975
+ if (!r.ok) {
976
+ // 删失败(文件被占用等):10 分钟后再试,别把记录丢了
977
+ writeCleanupList(rest.concat([{ name: item.name, version: item.version, dropAt: Date.now() + 10 * 60 * 1000 }]));
978
+ return;
979
+ }
980
+ writeCleanupList(rest);
981
+ };
982
+ const armCleanup = (item) => {
983
+ const key = item.name + "@" + item.version;
984
+ const prev = cleanupTimers.get(key);
985
+ if (prev) { try { clearTimeout(prev); } catch { } cleanupTimers.delete(key); }
986
+ const delay = Math.max(0, Number(item.dropAt) - Date.now());
987
+ if (!isFinite(delay) || delay > 2147483000) { return; }
988
+ const timer = setTimeout(() => runCleanup(item), delay);
989
+ if (timer && typeof timer.unref === "function") { timer.unref(); }
990
+ cleanupTimers.set(key, timer);
991
+ };
992
+ const scheduleCleanup = (item) => {
993
+ writeCleanupList(readCleanupList().filter((it) => !(it.name === item.name && it.version === item.version)).concat([item]));
994
+ armCleanup(item);
995
+ };
996
+
696
997
  const routes = [
697
998
  {
698
999
  kind: "exact",
@@ -770,6 +1071,73 @@ export function apply(ctx) {
770
1071
  });
771
1072
  }
772
1073
  },
1074
+ {
1075
+ /* 改动卡补位的「打开 / 显示位置 / 选择应用」:会话释放后官方 /api/changes.open
1076
+ 只回 404,这里交给 Host 桌面接手(官方 native-command 优先,见 openNativeTarget)。
1077
+ action 还可以是 "probe":只诊断子进程能不能跑,不弹任何窗口。 */
1078
+ kind: "exact",
1079
+ path: "/api/ui-tweaks/open-path",
1080
+ handler: (req, res) => {
1081
+ const done = respond(res);
1082
+ if (!isLoopback(req)) { done(403, JSON.stringify({ ok: false, code: "forbidden" })); return; }
1083
+ if (req.method !== "POST") { done(405, JSON.stringify({ ok: false, code: "method not allowed" })); return; }
1084
+ readJsonBody(req, (body) => {
1085
+ const target = body && typeof body.path === "string" ? body.path : "";
1086
+ const action = body && (body.action === "reveal" || body.action === "probe") ? body.action : "open";
1087
+ const application = body && typeof body.application === "string" ? body.application : "";
1088
+ if (action === "probe") {
1089
+ probeNativeLaunch().then(
1090
+ (r) => done(200, JSON.stringify(r)),
1091
+ (e) => done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) })));
1092
+ return;
1093
+ }
1094
+ openNativeTarget(target, action, application).then((r) => {
1095
+ if (r.ok) { done(200, JSON.stringify(r)); return; }
1096
+ const status = r.code === "path-not-found" ? 404
1097
+ : (r.code === "path required" || r.code === "absolute-path required") ? 400 : 500;
1098
+ done(status, JSON.stringify(r));
1099
+ }, (e) => done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) })));
1100
+ });
1101
+ }
1102
+ },
1103
+ {
1104
+ /* 官方 /api/changes.open 的 GET(应用列表)补位:复用官方 native-command 枚举系统关联 */
1105
+ kind: "exact",
1106
+ path: "/api/ui-tweaks/open-apps",
1107
+ handler: (req, res) => {
1108
+ const done = respond(res);
1109
+ if (!isLoopback(req)) { done(403, JSON.stringify({ ok: false, code: "forbidden" })); return; }
1110
+ if (req.method !== "POST") { done(405, JSON.stringify({ ok: false, code: "method not allowed" })); return; }
1111
+ readJsonBody(req, (body) => {
1112
+ const target = body && typeof body.path === "string" ? body.path : "";
1113
+ listOpenApplications(target).then(
1114
+ (r) => done(200, JSON.stringify(r)),
1115
+ (e) => done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) })));
1116
+ });
1117
+ }
1118
+ },
1119
+ {
1120
+ /* 登记「强行装上的版本到期后清掉它的豁免」;客户端在强制更新成功后调用 */
1121
+ kind: "exact",
1122
+ path: "/api/ui-tweaks/schedule-exempt-cleanup",
1123
+ handler: (req, res) => {
1124
+ const done = respond(res);
1125
+ if (!isLoopback(req)) { done(403, JSON.stringify({ ok: false, code: "forbidden" })); return; }
1126
+ if (req.method !== "POST") { done(405, JSON.stringify({ ok: false, code: "method not allowed" })); return; }
1127
+ readJsonBody(req, (body) => {
1128
+ const name = body && typeof body.name === "string" ? body.name : "";
1129
+ const version = body && typeof body.version === "string" ? body.version : "";
1130
+ const dropAt = body && isFinite(body.dropAt) ? Number(body.dropAt) : 0;
1131
+ if (!name || !version || !(dropAt > 0)) { done(400, JSON.stringify({ ok: false, code: "name/version/dropAt required" })); return; }
1132
+ try {
1133
+ scheduleCleanup({ name, version, dropAt });
1134
+ done(200, JSON.stringify({ ok: true, name, version, dropAt, at: new Date(dropAt).toISOString() }));
1135
+ } catch (e) {
1136
+ done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) }));
1137
+ }
1138
+ });
1139
+ }
1140
+ },
773
1141
  {
774
1142
  /* 立即重启:GET 只读方案(无副作用),POST 才执行;无法重拉时如实回 manual */
775
1143
  kind: "exact",
@@ -798,6 +1166,19 @@ export function apply(ctx) {
798
1166
  // 加载时静默收敛一次(fire-and-forget:异常绝不影响插件加载)
799
1167
  try { normalizeReleaseAgeExcludes(); } catch { /* 归并自身已吞异常,这里只兜底 */ }
800
1168
 
1169
+ /* 恢复上次没清完的豁免清理:已到期的立刻删,其余重新排期。
1170
+ 清单在 profile/ui-tweaks/exempt-pending.json(跨客户端重启有效)。 */
1171
+ try {
1172
+ for (const it of readCleanupList()) {
1173
+ if (Number(it.dropAt) <= Date.now()) { runCleanup(it); }
1174
+ else { armCleanup(it); }
1175
+ }
1176
+ } catch { /* 记录坏了就当没有 */ }
1177
+ ctx.effect(() => () => {
1178
+ for (const t of cleanupTimers.values()) { try { clearTimeout(t); } catch { } }
1179
+ cleanupTimers.clear();
1180
+ }, "ui-tweaks: exempt cleanup timers");
1181
+
801
1182
  // 桌面客户端:拉起窗口记忆 helper(非桌面环境内部自动不动作)
802
1183
  startWindowState(ctx);
803
1184
  }