dsh-expert-group 0.1.1

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.
Files changed (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
@@ -0,0 +1,916 @@
1
+ /**
2
+ * Host 关注点模块:Windows 系统 Toast(系统通知是**唯一**通知界面)。
3
+ *
4
+ * 用户验收要求:即使 DSH 在后台、其它应用在前台,通知也必须出现在 Windows 桌面右下角。
5
+ * renderer 的 Notification API 无法保证宿主身份与后台存活,因此投递发生在 Host 进程:
6
+ * 用系统自带的 **Windows PowerShell 5.1** 调 WinRT
7
+ * Windows.UI.Notifications.ToastNotificationManager,并通过与 process.execPath 匹配的
8
+ * DSH 开始菜单快捷方式取得 AUMID。
9
+ *
10
+ * 严格边界(ADR 0005,逐条对应实现):
11
+ * - 不 import electron、不向 DSH 主进程发送 IPC、不改 DSH 任何文件;只 spawn powershell.exe;
12
+ * - 不写注册表、不创建快捷方式、不安装 helper、不引入第三方依赖;
13
+ * - AUMID 从**当前用户与所有用户的开始菜单**里 TargetPath 与 process.execPath 匹配的
14
+ * .lnk 读取(System.AppUserModel.ID),不把 AUMID 硬编码成唯一来源;两个根都不可读、
15
+ * 无匹配、命中但无 AUMID、非 Windows 各自返回固定结构化 reason;
16
+ * - 只启动 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` 这个绝对路径,
17
+ * 拿不到就结构化 `powershell-unavailable` 并且**不 spawn**(绝不回退 PATH 上的同名程序);
18
+ * - 脚本固定,标题与正文**只**经 stdin JSON 传递:命令行、日志、异常诊断里都不出现正文;
19
+ * - shell:false + windowsHide:true,参数固定为
20
+ * -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand(不使用
21
+ * -ExecutionPolicy Bypass);
22
+ * - 有固定超时、并发与队列上限、退出码与 spawn error 映射、dispose 清理;
23
+ * 任何失败都不抛出到 Host 主流程,也不回退成应用内卡片;
24
+ * - 提示音:Toast XML 固定带 <audio silent="true"/>(单条 Toast 没有音量 API,且 DEG 借用
25
+ * DSH AUMID 无法可靠使用 ms-appx 自定义音频),系统默认音一律静音;自定义"咻~"短音由同一个
26
+ * 固定脚本用 System.Windows.Media.MediaPlayer 播放包内 assets/notification-whoosh.wav,
27
+ * 音量 = 插件音量 / 100(只缩放本条播放流,系统主音量仍由 Windows 音频栈自然应用)。
28
+ * 音频路径**只**出现在 stdin JSON 里,绝不进入命令行、日志、诊断或脚本文本;音频任何失败都被
29
+ * 脚本内部吞掉,不影响已经显示的 Toast,也不改变投递结果。
30
+ *
31
+ * 诊断面只暴露固定 token 与非负计数,不含正文、命令行、URL、AUMID 或任何绝对路径。
32
+ *
33
+ * @module dsh-expert-group/host/system-toast
34
+ */
35
+ import { spawn as spawnChildProcess } from "node:child_process";
36
+ import { existsSync } from "node:fs";
37
+ import { readdir as readdirDirectory } from "node:fs/promises";
38
+ import { join } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+
41
+ /** 依赖平台:只有 Windows 有 WinRT Toast;其它平台一律结构化 unsupported。 */
42
+ export const SYSTEM_TOAST_PLATFORM = "win32";
43
+
44
+ /**
45
+ * 固定结构化 reason 集合。
46
+ *
47
+ * 为什么是白名单:调用方(Host 主流程与诊断面)按 token 分支,任何自由文本都会把
48
+ * 子进程输出、正文或路径带进日志与诊断。新增失败类别只能在这里加 token。
49
+ */
50
+ export const SYSTEM_TOAST_REASONS = Object.freeze({
51
+ ok: "ok",
52
+ /** 非 Windows 平台。 */
53
+ unsupported: "unsupported",
54
+ /** 开始菜单读取失败(APPDATA 缺失、目录不可读、probe 脚本失败)。 */
55
+ shortcutReadFailed: "shortcut-read-failed",
56
+ /** 读到了快捷方式,但没有一条的 TargetPath 命中 process.execPath。 */
57
+ noMatchingShortcut: "no-matching-shortcut",
58
+ /** 命中了 TargetPath,但该快捷方式没有 System.AppUserModel.ID。 */
59
+ shortcutMissingAumid: "shortcut-missing-aumid",
60
+ /** 拿不到 Windows PowerShell 5.1 的绝对路径:不 spawn,也不回退 PATH。 */
61
+ powershellUnavailable: "powershell-unavailable",
62
+ /** spawn 本身失败(errno)。 */
63
+ spawnError: "spawn-error",
64
+ /** 子进程以非零退出码结束(含脚本异常)。 */
65
+ processExit: "process-exit",
66
+ /** 超时:已 kill 子进程,结果不再等待。 */
67
+ timeout: "timeout",
68
+ /** 队列已满:按上限丢弃本次投递(不做无界排队)。 */
69
+ queueFull: "queue-full",
70
+ /** 模块已释放。 */
71
+ disposed: "disposed",
72
+ });
73
+
74
+ /** 默认有界超时:Windows PowerShell 冷启动 + WinRT 调用通常 < 2s,8s 足够且不悬挂。 */
75
+ export const DEFAULT_SYSTEM_TOAST_TIMEOUT_MS = 8000;
76
+ /** 默认并发与队列上限:Toast 是用户可见的串行提示,不允许多路并发刷屏。 */
77
+ export const DEFAULT_SYSTEM_TOAST_MAX_CONCURRENT = 1;
78
+ export const DEFAULT_SYSTEM_TOAST_MAX_QUEUE = 8;
79
+ /** AUMID 解析失败后的重试冷却:避免每次通知都递归扫描开始菜单。 */
80
+ export const DEFAULT_AUMID_RETRY_COOLDOWN_MS = 30000;
81
+ /** 开始菜单扫描上限:只用于有界遍历,避免异常目录结构造成无界 IO。 */
82
+ export const DEFAULT_SHORTCUT_SCAN_MAX_FILES = 512;
83
+ export const DEFAULT_SHORTCUT_SCAN_MAX_DEPTH = 8;
84
+ /** 标题/正文长度上限(字符):Toast 展示面有界,同时限制 XML 体积。 */
85
+ export const TOAST_TITLE_MAX_LENGTH = 120;
86
+ export const TOAST_BODY_MAX_LENGTH = 400;
87
+ /**
88
+ * 默认通知音量(0..100):100 = 只受 Windows 主音量影响,插件不做任何衰减。
89
+ * 缺失/非法/读取异常都回落到这个值,保证"用户没设过"与"设成 100"行为一致。
90
+ */
91
+ export const DEFAULT_NOTIFICATION_VOLUME = 100;
92
+ /** 包内自定义提示音:相对本模块文件定位,开发目录与实际安装目录都指向同一文件。 */
93
+ export const NOTIFICATION_AUDIO_RELATIVE_PATH = "../assets/notification-whoosh.wav";
94
+
95
+ /** 开始菜单 Programs 目录相对 %APPDATA% 的固定位置。 */
96
+ const START_MENU_PROGRAMS_SEGMENTS = Object.freeze(["Microsoft", "Windows", "Start Menu", "Programs"]);
97
+
98
+ /**
99
+ * 固定文案:终态通知只按**固定 reason 类别**取状态文案;任务名称单独作为第二行。
100
+ *
101
+ * 为什么状态文案不是动态文案:终止原因来自 DSH turn/end 的 reason.kind,异常与工具输出
102
+ * 一律不得进入通知正文(既避免泄露,也保证状态信息可预期)。任务名称仅使用 DSH 已发布的
103
+ * session/title,并在 XML 构造边界做控制字符清洗、长度限制和实体转义。
104
+ */
105
+ const TERMINATION_TOAST_TEXT = Object.freeze({
106
+ completed: Object.freeze({ title: "Expert Group", body: "任务已完成。" }),
107
+ blocked: Object.freeze({ title: "Expert Group", body: "任务被阻塞,需要你处理。" }),
108
+ error: Object.freeze({ title: "Expert Group", body: "任务因错误终止。" }),
109
+ aborted: Object.freeze({ title: "Expert Group", body: "任务已中止。" }),
110
+ interrupted: Object.freeze({ title: "Expert Group", body: "任务被中断。" }),
111
+ "max-tokens": Object.freeze({ title: "Expert Group", body: "任务达到 token 上限。" }),
112
+ unknown: Object.freeze({ title: "Expert Group", body: "任务已终止。" }),
113
+ });
114
+
115
+ /** 固定文案:用户等待(DSH 等待用户回答/确认)只认两种工具名。 */
116
+ const USER_WAIT_TOAST_TEXT = Object.freeze({
117
+ ask_user_question: Object.freeze({ title: "Expert Group", body: "需要你操作" }),
118
+ exit_plan_mode: Object.freeze({ title: "Expert Group", body: "需要你操作" }),
119
+ });
120
+
121
+ /**
122
+ * 以独立字段附加任务名称,避免把任务名称和状态信息拼成不可读的一行。
123
+ * 缺少权威 session/title 时保留旧的安全结构,只显示固定标题与状态。
124
+ */
125
+ function withTaskName(text, taskName) {
126
+ const label = typeof taskName === "string" ? taskName.trim() : "";
127
+ return label.length > 0 ? { ...text, taskName: label } : text;
128
+ }
129
+
130
+ /** 终态文案:未知 kind 一律落到通用文案,绝不复用 completed。 */
131
+ export function terminationToastText(reason, taskName) {
132
+ const key = typeof reason === "string" ? reason.trim() : "";
133
+ return withTaskName(TERMINATION_TOAST_TEXT[key] ?? TERMINATION_TOAST_TEXT.unknown, taskName);
134
+ }
135
+
136
+ /** 用户等待文案:caller 传入未知工具名时返回 null(宁可不提示,也不编造提示)。 */
137
+ export function userWaitToastText(tool, taskName) {
138
+ const key = typeof tool === "string" ? tool.trim() : "";
139
+ const text = USER_WAIT_TOAST_TEXT[key];
140
+ return text === undefined ? null : withTaskName(text, taskName);
141
+ }
142
+
143
+ /**
144
+ * XML 文本节点转义。
145
+ *
146
+ * 正文来自 Host 已知状态,但仍然是**外部输入**(模型/工具可影响 reason 之外的字段),
147
+ * 因此所有文本都当成不可信内容处理:五个实体全部转义,杜绝标签/属性注入。
148
+ */
149
+ export function escapeXmlText(value) {
150
+ return String(value ?? "")
151
+ .replace(/&/g, "&amp;")
152
+ .replace(/</g, "&lt;")
153
+ .replace(/>/g, "&gt;")
154
+ .replace(/"/g, "&quot;")
155
+ .replace(/'/g, "&apos;");
156
+ }
157
+
158
+ /**
159
+ * 文本归一:去掉 XML 1.0 非法的 C0 控制字符(保留制表/换行/回车),折叠到有界长度。
160
+ */
161
+ export function clampToastText(value, maxLength = TOAST_BODY_MAX_LENGTH) {
162
+ const limit = Number.isSafeInteger(maxLength) && maxLength > 0 ? maxLength : TOAST_BODY_MAX_LENGTH;
163
+ const text = String(value ?? "").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "").trim();
164
+ return text.length > limit ? text.slice(0, limit) : text;
165
+ }
166
+
167
+ /**
168
+ * 构造 Toast XML(固定模板 + 转义后的文本)。
169
+ *
170
+ * 只使用 ToastGeneric 模板:它在 Windows 10/11 桌面右下角以标准横幅展示,且不依赖
171
+ * 任何应用内 UI。标题为空时回落到固定标题;任务名称与状态正文分别占一行,空字段不生成
172
+ * 空文本节点。
173
+ *
174
+ * 音频节点固定为 <audio silent="true"/>,且必须是 <visual> 之后的合法顺序(schema 顺序为
175
+ * visual → audio → actions):
176
+ * - 单条 Toast 没有音量 API,无法让系统默认音按插件音量播放;
177
+ * - DEG 借用 DSH 的 AUMID 投递,不能用 ms-appx: 等相对协议可靠引用包内自定义音频;
178
+ * 因此系统音一律静音,自定义"咻~"由 TOAST_SHOW_SCRIPT 里的 MediaPlayer 播放,
179
+ * 避免两段声音叠加。
180
+ */
181
+ export function buildToastXml({ title, taskName, body } = {}) {
182
+ const safeTitle = clampToastText(title, TOAST_TITLE_MAX_LENGTH) || "Expert Group";
183
+ const safeTaskName = clampToastText(taskName, TOAST_TITLE_MAX_LENGTH);
184
+ const safeBody = clampToastText(body, TOAST_BODY_MAX_LENGTH);
185
+ const nodes = ['<text>' + escapeXmlText(safeTitle) + '</text>'];
186
+ if (safeTaskName.length > 0) nodes.push('<text>' + escapeXmlText(safeTaskName) + '</text>');
187
+ if (safeBody.length > 0) nodes.push('<text>' + escapeXmlText(safeBody) + '</text>');
188
+ return '<toast><visual><binding template="ToastGeneric">' + nodes.join("") + '</binding></visual>'
189
+ + '<audio silent="true"/></toast>';
190
+ }
191
+
192
+ /**
193
+ * 默认提示音路径:以本模块文件位置解析包内 assets/notification-whoosh.wav。
194
+ *
195
+ * 用 import.meta.url 而不是 __dirname/cwd:模块被 install.ps1 复制到
196
+ * profiles\packages\dsh-expert-group 后依然指向同一份随包发布的资源。
197
+ * 返回值只用于 stdin JSON,绝不进入命令行、日志、诊断或脚本文本。
198
+ */
199
+ export function defaultNotificationAudioPath() {
200
+ return fileURLToPath(new URL(NOTIFICATION_AUDIO_RELATIVE_PATH, import.meta.url));
201
+ }
202
+
203
+ /**
204
+ * 音量归一:合法安全整数 0..100 原样返回;缺失、非法(小数/越界/字符串/NaN)一律回落到
205
+ * DEFAULT_NOTIFICATION_VOLUME(100)。归一在 Node 侧完成,脚本只负责除以 100。
206
+ */
207
+ export function normalizeToastVolume(value) {
208
+ return Number.isSafeInteger(value) && value >= 0 && value <= 100 ? value : DEFAULT_NOTIFICATION_VOLUME;
209
+ }
210
+
211
+ /**
212
+ * 规范化快捷方式目标路径用于比较:去引号、统一分隔符、去尾部斜杠、统一大小写。
213
+ *
214
+ * 只做文本归一(不做 realpath/短路径展开):目标是"同一条 DSH 可执行文件在快捷方式里
215
+ * 怎么写就怎么比",任何磁盘访问都会让探测更脆弱。
216
+ */
217
+ export function normalizeShortcutPath(value) {
218
+ if (typeof value !== "string") return "";
219
+ let text = value.trim();
220
+ if (text.length === 0) return "";
221
+ if (text.length > 1 && text.startsWith('"') && text.endsWith('"')) text = text.slice(1, -1);
222
+ text = text.replace(/\//g, "\\").replace(/\\+$/, "");
223
+ return text.toLowerCase();
224
+ }
225
+
226
+ /**
227
+ * 纯匹配:从快捷方式元数据里挑出 TargetPath 命中 execPath 且带 AUMID 的那一条。
228
+ *
229
+ * @param {object} input
230
+ * @param {Array<{targetPath?: string, aumid?: string}>} [input.shortcuts] - 快捷方式元数据。
231
+ * @param {string} [input.execPath] - 当前 DSH 可执行文件(process.execPath)。
232
+ * @returns {{aumid: string|null, reason: string}} aumid 为空时 reason 是固定失败 token。
233
+ */
234
+ export function matchShortcutAumid({ shortcuts, execPath } = {}) {
235
+ const wanted = normalizeShortcutPath(execPath);
236
+ const entries = Array.isArray(shortcuts) ? shortcuts : [];
237
+ if (wanted.length === 0) return { aumid: null, reason: SYSTEM_TOAST_REASONS.noMatchingShortcut };
238
+ let matchedWithoutAumid = false;
239
+ for (const entry of entries) {
240
+ if (normalizeShortcutPath(entry?.targetPath) !== wanted) continue;
241
+ const aumid = typeof entry?.aumid === "string" ? entry.aumid.trim() : "";
242
+ if (aumid.length === 0) {
243
+ matchedWithoutAumid = true;
244
+ continue;
245
+ }
246
+ return { aumid, reason: SYSTEM_TOAST_REASONS.ok };
247
+ }
248
+ return {
249
+ aumid: null,
250
+ reason: matchedWithoutAumid ? SYSTEM_TOAST_REASONS.shortcutMissingAumid : SYSTEM_TOAST_REASONS.noMatchingShortcut,
251
+ };
252
+ }
253
+
254
+ /** 按规范化路径去重(大小写/分隔符/尾斜杠判等),保持首次出现顺序;纯函数。 */
255
+ function uniqueDirectories(values) {
256
+ const seen = new Set();
257
+ const roots = [];
258
+ for (const value of Array.isArray(values) ? values : []) {
259
+ if (typeof value !== "string") continue;
260
+ const trimmed = value.trim();
261
+ if (trimmed.length === 0) continue;
262
+ const key = normalizeShortcutPath(trimmed);
263
+ if (key.length === 0 || seen.has(key)) continue;
264
+ seen.add(key);
265
+ roots.push(trimmed);
266
+ }
267
+ return roots;
268
+ }
269
+
270
+ /**
271
+ * 需要扫描的开始菜单 Programs 根目录(按顺序、去重)。
272
+ *
273
+ * 为什么是两个根:DSH 可能由当前用户安装(%APPDATA%),也可能由管理员为所有用户安装
274
+ * (%ProgramData%)。只扫其中一个会把"存在带 AUMID 的快捷方式"变成假阴性,而 AUMID
275
+ * 是 Windows 展示 Toast 的身份前提。环境变量缺失的根直接跳过,聚合规则见
276
+ * collectShortcutPaths:只要还有一个根可读,就不算读取失败。
277
+ */
278
+ export function startMenuProgramsDirs(env = {}) {
279
+ const roots = [];
280
+ for (const variable of ["APPDATA", "ProgramData"]) {
281
+ const base = typeof env?.[variable] === "string" ? env[variable].trim() : "";
282
+ if (base.length === 0) continue;
283
+ roots.push(join(base, ...START_MENU_PROGRAMS_SEGMENTS));
284
+ }
285
+ return uniqueDirectories(roots);
286
+ }
287
+
288
+ /** 当前用户的开始菜单 Programs 目录(第一个根);两个环境变量都缺失时返回 null。 */
289
+ export function startMenuProgramsDir(env = {}) {
290
+ return startMenuProgramsDirs(env)[0] ?? null;
291
+ }
292
+
293
+ /**
294
+ * 有界递归收集 .lnk 路径(可一次扫描多个开始菜单根)。
295
+ *
296
+ * 聚合语义(每个根独立遍历,全部不抛错):
297
+ * - 至少一个根目录可列出 => `readable:true`,可读根的条目照常汇总;单个根缺失/不可读
298
+ * 只跳过它,绝不让另一个有效根一起失败;
299
+ * - **所有**根都不可用/不可读 => `readable:false`(调用方给出 shortcut-read-failed);
300
+ * - 全部根都读到了但没有 .lnk => `readable:true` + 空 paths:"没有快捷方式"与"读不到"
301
+ * 是两种必须区分的结构化结论。
302
+ * 路径按规范化路径去重(两个根可能被重定向到同一目录),文件总数与递归深度都有上界。
303
+ *
304
+ * readdir 是可注入 seam(契约:readdir(directory) => Promise<Array<{name, kind}>>,
305
+ * kind 为 "file" | "directory" | "other");默认实现只包装 node:fs 的 Dirent,
306
+ * 不触碰注册表。
307
+ *
308
+ * @param {object} [options]
309
+ * @param {string} [options.dir] - 单个根目录(兼容旧调用)。
310
+ * @param {string[]} [options.dirs] - 多个根目录;与 dir 合并后去重。
311
+ * @returns {Promise<{paths: string[], readable: boolean, truncated: boolean}>}
312
+ */
313
+ export async function collectShortcutPaths({
314
+ dir,
315
+ dirs,
316
+ readdir,
317
+ maxFiles = DEFAULT_SHORTCUT_SCAN_MAX_FILES,
318
+ maxDepth = DEFAULT_SHORTCUT_SCAN_MAX_DEPTH,
319
+ } = {}) {
320
+ const listDirectory = typeof readdir === "function" ? readdir : defaultReadDirectory;
321
+ const fileLimit = Number.isSafeInteger(maxFiles) && maxFiles > 0 ? maxFiles : DEFAULT_SHORTCUT_SCAN_MAX_FILES;
322
+ const depthLimit = Number.isSafeInteger(maxDepth) && maxDepth > 0 ? maxDepth : DEFAULT_SHORTCUT_SCAN_MAX_DEPTH;
323
+ const roots = uniqueDirectories([...(Array.isArray(dirs) ? dirs : []), dir]);
324
+ if (roots.length === 0) return { paths: [], readable: false, truncated: false };
325
+ const paths = [];
326
+ const seenPaths = new Set();
327
+ let truncated = false;
328
+ let readableRoots = 0;
329
+ for (let rootIndex = 0; rootIndex < roots.length; rootIndex += 1) {
330
+ const root = roots[rootIndex];
331
+ const queue = [{ directory: root, depth: 0 }];
332
+ while (queue.length > 0) {
333
+ const current = queue.shift();
334
+ let entries;
335
+ try {
336
+ entries = await listDirectory(current.directory);
337
+ } catch {
338
+ // 根本身不可读:只影响这个根(全部根都不可读时最终 readable:false);
339
+ // 子目录不可读:跳过该子树,仍然可以命中别的快捷方式。
340
+ continue;
341
+ }
342
+ if (current.depth === 0) readableRoots += 1;
343
+ if (!Array.isArray(entries)) continue;
344
+ for (const entry of entries) {
345
+ if (entry === null || typeof entry !== "object") continue;
346
+ const name = typeof entry.name === "string" ? entry.name : "";
347
+ if (name.length === 0) continue;
348
+ const kind = entry.kind === "directory" || entry.kind === "file" ? entry.kind : "other";
349
+ if (kind === "directory") {
350
+ if (current.depth + 1 > depthLimit) { truncated = true; continue; }
351
+ queue.push({ directory: join(current.directory, name), depth: current.depth + 1 });
352
+ continue;
353
+ }
354
+ if (kind !== "file" || name.toLowerCase().endsWith(".lnk") !== true) continue;
355
+ if (paths.length >= fileLimit) { truncated = true; break; }
356
+ const candidate = join(current.directory, name);
357
+ const key = normalizeShortcutPath(candidate);
358
+ if (seenPaths.has(key)) continue;
359
+ seenPaths.add(key);
360
+ paths.push(candidate);
361
+ }
362
+ if (paths.length >= fileLimit) {
363
+ // 到达上限后不再继续遍历(IO 有界)。若还有未访问的目录或未扫描的根,
364
+ // 就明确标记 truncated:不能把"没看完"报成"看完了"。
365
+ if (queue.length > 0 || rootIndex < roots.length - 1) truncated = true;
366
+ break;
367
+ }
368
+ }
369
+ if (paths.length >= fileLimit) break;
370
+ }
371
+ // 所有根都不可读才算读取失败;只要有一个根可列出就按"可读"汇总。
372
+ if (readableRoots === 0) return { paths: [], readable: false, truncated };
373
+ return { paths, readable: true, truncated };
374
+ }
375
+
376
+ /** 默认 readdir 适配:只把 Dirent 归一成 {name, kind},不读文件内容。 */
377
+ async function defaultReadDirectory(directory) {
378
+ const entries = await readdirDirectory(directory, { withFileTypes: true });
379
+ return entries.map((entry) => ({
380
+ name: entry.name,
381
+ kind: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other",
382
+ }));
383
+ }
384
+
385
+ /**
386
+ * 固定脚本 1:读取给定 .lnk 的 TargetPath 与 System.AppUserModel.ID。
387
+ *
388
+ * 输入经 stdin JSON({paths:[...]})传入:命令行里不出现任何用户目录路径。
389
+ * 输出是单行 JSON 数组(ConvertTo-Json -Compress),Node 侧再按纯函数匹配 AUMID。
390
+ * 只使用系统自带 COM 解析器,不写注册表、不创建快捷方式。
391
+ */
392
+ export const SHORTCUT_PROBE_SCRIPT = [
393
+ "$ErrorActionPreference = 'Stop'",
394
+ // 输出编码固定为 UTF-8:否则含中文的安装路径会按控制台代码页输出,Node 侧读成乱码,
395
+ // 与 process.execPath 的精确匹配会假失败。
396
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
397
+ "$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json",
398
+ "$shell = New-Object -ComObject Shell.Application",
399
+ "$entries = New-Object System.Collections.ArrayList",
400
+ "foreach ($path in @($payload.paths)) {",
401
+ // 单条快捷方式损坏(解析抛错)只跳过这一条:不能让一条坏 .lnk 让整台机器再也收不到通知。
402
+ " try {",
403
+ " $directory = [System.IO.Path]::GetDirectoryName($path)",
404
+ " $leaf = [System.IO.Path]::GetFileName($path)",
405
+ " $folder = $shell.Namespace($directory)",
406
+ " if ($null -eq $folder) { continue }",
407
+ " $item = $folder.ParseName($leaf)",
408
+ " if ($null -eq $item) { continue }",
409
+ " $target = [string]$item.ExtendedProperty('System.Link.TargetParsingPath')",
410
+ " if ([string]::IsNullOrEmpty($target)) { $target = [string]$item.ExtendedProperty('System.Link.TargetPath') }",
411
+ " $aumid = [string]$item.ExtendedProperty('System.AppUserModel.ID')",
412
+ " [void]$entries.Add([pscustomobject]@{ targetPath = $target; aumid = $aumid })",
413
+ " } catch {",
414
+ " continue",
415
+ " }",
416
+ "}",
417
+ "ConvertTo-Json -InputObject @($entries) -Compress -Depth 3",
418
+ ].join("\n");
419
+
420
+ /**
421
+ * 固定脚本 2:按 stdin JSON({aumid, xml, audioPath, volume})显示 Toast 并尽力播放提示音。
422
+ *
423
+ * XML 由 Node 侧构造并转义(buildToastXml),脚本只负责解析与投递,因此正文永远不会
424
+ * 出现在命令行、PowerShell 脚本文本或异常消息里。
425
+ */
426
+ export const TOAST_SHOW_SCRIPT = [
427
+ "$ErrorActionPreference = 'Stop'",
428
+ "$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json",
429
+ "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
430
+ "[void][Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime]",
431
+ "[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]",
432
+ "$document = New-Object Windows.Data.Xml.Dom.XmlDocument",
433
+ "$document.LoadXml([string]$payload.xml)",
434
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($document)",
435
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier([string]$payload.aumid).Show($toast)",
436
+ "# ── 自定义提示音(尽力而为,完全独立于上面的 Toast 投递) ────────────────────",
437
+ "# Toast XML 已用静音 audio 节点关掉系统默认音,这里只播 DEG 自己的短音(约 1.46s)。",
438
+ "# 顺序:先 Show 已经完成,再打开音频;本块任何异常都只被吞掉,绝不改变投递结果。",
439
+ "try {",
440
+ " $level = 0",
441
+ " if ($null -ne $payload.volume) { $level = [int]$payload.volume }",
442
+ " $sound = [string]$payload.audioPath",
443
+ " # volume=0(或没有音频路径)时完全不加载音频栈:静音就是什么都不放。",
444
+ " if ($level -gt 0 -and -not [string]::IsNullOrWhiteSpace($sound)) {",
445
+ " Add-Type -AssemblyName presentationCore",
446
+ " $player = New-Object System.Windows.Media.MediaPlayer",
447
+ " $frame = New-Object System.Windows.Threading.DispatcherFrame",
448
+ " $timer = New-Object System.Windows.Threading.DispatcherTimer",
449
+ " $audioState = @{ frame = $frame; timer = $timer; opened = $false; failed = $false }",
450
+ " $timer.Interval = [TimeSpan]::FromSeconds(3)",
451
+ " $timer.Add_Tick({ $audioState.timer.Stop(); $audioState.frame.Continue = $false })",
452
+ " $player.add_MediaOpened({ $audioState.opened = $true; $audioState.timer.Stop(); $audioState.frame.Continue = $false })",
453
+ " $player.add_MediaFailed({ $audioState.failed = $true; $audioState.timer.Stop(); $audioState.frame.Continue = $false })",
454
+ " try {",
455
+ " $player.Open((New-Object System.Uri($sound)))",
456
+ " $timer.Start()",
457
+ " # WPF 媒体事件依赖 STA Dispatcher;显式泵事件,最多等待 3 秒,不用固定 Sleep 猜是否已打开。",
458
+ " [System.Windows.Threading.Dispatcher]::PushFrame($frame)",
459
+ " if ($audioState.opened -and -not $audioState.failed) {",
460
+ " # 插件音量只缩放本条播放流:MediaPlayer.Volume 是 0..1,系统主音量仍由 Windows 音频栈应用。",
461
+ " $player.Volume = [Math]::Min(1.0, [Math]::Max(0.0, ($level / 100.0)))",
462
+ " $playFrame = New-Object System.Windows.Threading.DispatcherFrame",
463
+ " $playTimer = New-Object System.Windows.Threading.DispatcherTimer",
464
+ " $playState = @{ frame = $playFrame; timer = $playTimer; ended = $false; failed = $false }",
465
+ " $playTimer.Interval = [TimeSpan]::FromSeconds(2)",
466
+ " $playTimer.Add_Tick({ $playState.timer.Stop(); $playState.frame.Continue = $false })",
467
+ " $player.add_MediaEnded({ $playState.ended = $true; $playState.timer.Stop(); $playState.frame.Continue = $false })",
468
+ " $player.add_MediaFailed({ $playState.failed = $true; $playState.timer.Stop(); $playState.frame.Continue = $false })",
469
+ " $player.Play()",
470
+ " $playTimer.Start()",
471
+ " # 持续泵 STA Dispatcher 直到 MediaEnded 或 2 秒超时,避免播放期间阻塞 WPF 事件线程。",
472
+ " [System.Windows.Threading.Dispatcher]::PushFrame($playFrame)",
473
+ " $playTimer.Stop()",
474
+ " }",
475
+ " } finally {",
476
+ " $timer.Stop()",
477
+ " if ($null -ne $playTimer) { $playTimer.Stop() }",
478
+ " $player.Stop()",
479
+ " $player.Close()",
480
+ " }",
481
+ " }",
482
+ "} catch {",
483
+ " # 音频不可用(缺文件、无音频设备、WPF 不可用)时静默忽略:通知已经显示过了。",
484
+ "}",
485
+ ].join("\n");
486
+
487
+ /**
488
+ * 把 stdin 载荷编码成**纯 ASCII** 的 JSON。
489
+ *
490
+ * 为什么必须这样:Windows PowerShell 5.1 的 `[Console]::In` 按**控制台代码页**解码
491
+ * 重定向的 stdin,而不是 UTF-8。中文通知正文或含中文的快捷方式路径若按 UTF-8 写入,
492
+ * 会被按 GBK 之类的代码页误读,`ConvertFrom-Json` 直接报"无法识别的转义序列"——
493
+ * 也就是所有通知都投递失败。把非 ASCII 字符转成 \uXXXX 转义后载荷只剩 ASCII 字节,
494
+ * ASCII 在所有代码页下解码一致,因此与代码页无关。
495
+ */
496
+ export function encodeJsonPayload(value) {
497
+ return JSON.stringify(value ?? {}).replace(/[\u007F-\uFFFF]/g, (character) =>
498
+ "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0"));
499
+ }
500
+
501
+ /** -EncodedCommand 要求 UTF-16LE + Base64:脚本固定,命令行走固定参数列表。 */
502
+ export function encodePowerShellCommand(script) {
503
+ return Buffer.from(String(script), "utf16le").toString("base64");
504
+ }
505
+
506
+ /**
507
+ * 固定 PowerShell 参数:WPF MediaPlayer 需要 STA;不使用 -ExecutionPolicy Bypass(编码命令本身不落盘,
508
+ * 但策略绕过会改变安全语义),也不把标题/正文放进参数。
509
+ */
510
+ export function powershellArgumentsFor(script) {
511
+ return ["-NoProfile", "-NonInteractive", "-STA", "-WindowStyle", "Hidden", "-EncodedCommand", encodePowerShellCommand(script)];
512
+ }
513
+
514
+ /**
515
+ * 解析 Windows PowerShell 5.1 的可执行文件路径;拿不到就返回 **null**。
516
+ *
517
+ * 为什么只接受 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` 这个绝对路径:
518
+ * 1. 必须是 Windows PowerShell 5.1(pwsh 7 解析不了部分 WinRT 类型),System32 下的
519
+ * 固定安装位置是唯一能证明这一点的证据;
520
+ * 2. 裸 "powershell.exe" 会走 PATH 解析:任何能在 PATH 前置同名程序的环境(第三方工具、
521
+ * 被篡改的用户环境变量)都能劫持 Host 启动的解释器——而它跑的是会弹用户可见通知的
522
+ * 固定脚本。因此 SystemRoot 缺失、探测抛错或文件不存在时一律返回 null,
523
+ * 由调用方给出固定结构化 reason,绝不回退 PATH。
524
+ *
525
+ * @param {object} [options]
526
+ * @param {object} [options.env] - 环境变量(只读 SystemRoot)。
527
+ * @param {(candidate: string) => boolean} [options.fileExists] - 存在性探测 seam。
528
+ * @returns {string|null} 绝对路径,或 null(不可用)。
529
+ */
530
+ export function resolvePowerShellPath({ env = {}, fileExists = existsSync } = {}) {
531
+ const systemRoot = typeof env?.SystemRoot === "string" ? env.SystemRoot.trim() : "";
532
+ if (systemRoot.length === 0) return null;
533
+ const candidate = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
534
+ try {
535
+ return typeof fileExists === "function" && fileExists(candidate) === true ? candidate : null;
536
+ } catch {
537
+ // 探测本身失败同样按"不可用"处理:宁可结构化失败,也不猜一个可执行文件。
538
+ return null;
539
+ }
540
+ }
541
+
542
+ /** 结构化结果构造:只有固定 token,绝不携带正文/命令行/路径。 */
543
+ function toastResult(shown, reason, detail) {
544
+ const payload = { shown: shown === true, reason };
545
+ if (detail !== void 0 && detail !== null) payload.detail = detail;
546
+ return payload;
547
+ }
548
+
549
+ /** 把外部 reason 归一到白名单 token:未知/非字符串一律回落。 */
550
+ function normalizeReasonToken(value, fallback) {
551
+ if (typeof value !== "string") return fallback;
552
+ const tokens = Object.values(SYSTEM_TOAST_REASONS);
553
+ return tokens.includes(value) ? value : fallback;
554
+ }
555
+
556
+ /**
557
+ * 创建 Windows 系统 Toast 适配器。
558
+ *
559
+ * @param {object} [options]
560
+ * @param {string} [options.platform] - 依赖平台,默认 process.platform(非 win32 => unsupported)。
561
+ * @param {object} [options.env] - 环境变量(APPDATA/SystemRoot),默认 process.env。
562
+ * @param {string} [options.execPath] - 需要匹配的可执行文件,默认 process.execPath。
563
+ * @param {Function} [options.spawn] - 子进程工厂 seam(契约同 node:child_process.spawn)。
564
+ * @param {Function} [options.readdir] - 目录读取 seam(契约同 collectShortcutPaths)。
565
+ * @param {Function} [options.fileExists] - PowerShell 绝对路径存在性探测 seam(默认 node:fs)。
566
+ * @param {Function} [options.listShortcuts] - 快捷方式读取 seam:
567
+ * () => Promise<{entries: Array<{targetPath, aumid}>, reason: string|null}>;默认实现
568
+ * 递归扫描开始菜单并调用固定 probe 脚本。
569
+ * @param {string} [options.audioPath] - 自定义提示音文件路径(测试 seam)。默认解析包内
570
+ * assets/notification-whoosh.wav;传空串表示不放音效。该路径只进 stdin JSON。
571
+ * @param {number} [options.timeoutMs] - 单次子进程超时。
572
+ * @param {number} [options.maxConcurrent] - 并发上限。
573
+ * @param {number} [options.maxQueue] - 队列上限(超限按 queue-full 丢弃)。
574
+ * @param {number} [options.aumidCooldownMs] - AUMID 解析失败后的重试冷却。
575
+ * @param {() => number} [options.now] - 可注入时钟(冷却判定)。
576
+ * @param {{warn?: Function}} [options.logger] - 只接收固定 token 的 warn 出口。
577
+ */
578
+ export function createSystemToastModule(options = {}) {
579
+ const platform = typeof options.platform === "string" ? options.platform : process.platform;
580
+ const env = options.env !== undefined && options.env !== null ? options.env : process.env;
581
+ const execPath = typeof options.execPath === "string" ? options.execPath : process.execPath;
582
+ const spawnProcess = typeof options.spawn === "function" ? options.spawn : spawnChildProcess;
583
+ const readdir = typeof options.readdir === "function" ? options.readdir : defaultReadDirectory;
584
+ const fileExists = typeof options.fileExists === "function" ? options.fileExists : existsSync;
585
+ const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_SYSTEM_TOAST_TIMEOUT_MS;
586
+ const maxConcurrent = Number.isSafeInteger(options.maxConcurrent) && options.maxConcurrent > 0
587
+ ? options.maxConcurrent
588
+ : DEFAULT_SYSTEM_TOAST_MAX_CONCURRENT;
589
+ const maxQueue = Number.isSafeInteger(options.maxQueue) && options.maxQueue >= 0
590
+ ? options.maxQueue
591
+ : DEFAULT_SYSTEM_TOAST_MAX_QUEUE;
592
+ const aumidCooldownMs = Number.isSafeInteger(options.aumidCooldownMs) && options.aumidCooldownMs >= 0
593
+ ? options.aumidCooldownMs
594
+ : DEFAULT_AUMID_RETRY_COOLDOWN_MS;
595
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
596
+ const logger = options.logger ?? null;
597
+ const listShortcuts = typeof options.listShortcuts === "function" ? options.listShortcuts : defaultListShortcuts;
598
+ // 音频路径同样只在这里解析一次:它绝不参与命令行拼装,只作为 stdin 载荷字段下发。
599
+ const audioPath = typeof options.audioPath === "string" ? options.audioPath.trim() : defaultNotificationAudioPath();
600
+
601
+ const stats = { sent: 0, failed: 0, dropped: 0, lastReason: null };
602
+ const aumidState = { aumid: null, reason: null, attemptAt: null };
603
+ /**
604
+ * 在途子进程 -> 收口函数:dispose 时先把本次投递归口成 disposed,再 kill 子进程,
605
+ * 因此释放后不会再有 Toast 弹出来,调用方也不会等一个已经无意义的退出码。
606
+ */
607
+ const inFlight = new Map();
608
+ const queue = [];
609
+ let inflight = 0;
610
+ let disposed = false;
611
+
612
+ function note(reason) {
613
+ stats.lastReason = reason;
614
+ if (reason !== SYSTEM_TOAST_REASONS.ok) stats.failed += 1;
615
+ }
616
+
617
+ function warn(token) {
618
+ if (logger === null || typeof logger.warn !== "function") return;
619
+ try {
620
+ logger.warn("dsh-expert-group: system toast " + token);
621
+ } catch {
622
+ // 日志出口失败绝不影响投递结果。
623
+ }
624
+ }
625
+
626
+ /**
627
+ * 单次子进程调用:stdin 写 JSON、可选读取 stdout、超时 kill。
628
+ *
629
+ * 返回值只有固定 reason 与有界 detail(退出码/信号),绝不携带 stderr 文本。
630
+ */
631
+ function runPowerShell({ script, payload, captureStdout = false }) {
632
+ return new Promise((resolve) => {
633
+ let settled = false;
634
+ let cancelled = false;
635
+ const finish = (value) => {
636
+ if (settled) return;
637
+ settled = true;
638
+ if (timer !== null) clearTimeout(timer);
639
+ if (child !== null) inFlight.delete(child);
640
+ resolve(value);
641
+ };
642
+ let timer = null;
643
+ let child = null;
644
+ // 先解析解释器:拿不到绝对路径就立刻收口,绝不 spawn(不回退 PATH、不猜别的解释器)。
645
+ const executable = resolvePowerShellPath({ env, fileExists });
646
+ if (executable === null) {
647
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.powershellUnavailable));
648
+ return;
649
+ }
650
+ try {
651
+ child = spawnProcess(executable, powershellArgumentsFor(script), {
652
+ shell: false,
653
+ windowsHide: true,
654
+ stdio: captureStdout === true ? ["pipe", "pipe", "ignore"] : ["pipe", "ignore", "ignore"],
655
+ });
656
+ } catch {
657
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.spawnError));
658
+ return;
659
+ }
660
+ if (child === null || typeof child !== "object") {
661
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.spawnError));
662
+ return;
663
+ }
664
+ // dispose 的收口入口:先判定 disposed(断言不再等待退出码),再 kill 子进程。
665
+ inFlight.set(child, () => {
666
+ cancelled = true;
667
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.disposed));
668
+ });
669
+ let stdout = "";
670
+ if (captureStdout === true && child.stdout !== null && typeof child.stdout?.on === "function") {
671
+ try {
672
+ child.stdout.setEncoding("utf8");
673
+ } catch {
674
+ // 替身/裁剪环境没有 setEncoding:仍然收数据。
675
+ }
676
+ child.stdout.on("data", (chunk) => {
677
+ // stdout 上界:probe 输出是受控 JSON,超出即视为损坏,避免无界缓冲。
678
+ if (stdout.length < 262144) stdout += String(chunk);
679
+ });
680
+ }
681
+ try {
682
+ child.stdin?.on?.("error", () => {});
683
+ } catch {
684
+ // stdin 替身没有事件面:忽略。
685
+ }
686
+ try {
687
+ child.stdin?.end?.(encodeJsonPayload(payload));
688
+ } catch {
689
+ // 写入失败(子进程已退出/管道关闭):由 close/error 事件给出结论。
690
+ }
691
+ try {
692
+ child.once?.("error", () => { finish(toastResult(false, SYSTEM_TOAST_REASONS.spawnError)); });
693
+ child.once?.("close", (code, signal) => {
694
+ if (cancelled) {
695
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.disposed));
696
+ return;
697
+ }
698
+ if (code === 0) {
699
+ finish({ ok: true, stdout });
700
+ return;
701
+ }
702
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.processExit, {
703
+ exitCode: Number.isSafeInteger(code) ? code : null,
704
+ signal: typeof signal === "string" ? signal : null,
705
+ }));
706
+ });
707
+ } catch {
708
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.spawnError));
709
+ return;
710
+ }
711
+ timer = setTimeout(() => {
712
+ cancelled = true;
713
+ try {
714
+ child.kill?.();
715
+ } catch {
716
+ // kill 失败不影响结论:本次投递已经按超时收口。
717
+ }
718
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.timeout));
719
+ }, timeoutMs);
720
+ // dispose 与在途调用竞争:已经释放时立即收口并 kill。
721
+ if (disposed) {
722
+ cancelled = true;
723
+ try {
724
+ child.kill?.();
725
+ } catch {
726
+ // 同上。
727
+ }
728
+ finish(toastResult(false, SYSTEM_TOAST_REASONS.disposed));
729
+ }
730
+ });
731
+ }
732
+
733
+ /**
734
+ * 默认快捷方式读取:有界扫描**当前用户 + 所有用户**开始菜单 + 一次固定 probe 脚本调用。
735
+ *
736
+ * 只有两个根都不可用/不可读才是 shortcut-read-failed;单个根缺失只跳过它,
737
+ * 另一个根里的快捷方式照常参与 AUMID 匹配。
738
+ */
739
+ async function defaultListShortcuts() {
740
+ const directories = startMenuProgramsDirs(env);
741
+ if (directories.length === 0) return { entries: [], reason: SYSTEM_TOAST_REASONS.shortcutReadFailed };
742
+ const walk = await collectShortcutPaths({ dirs: directories, readdir });
743
+ if (walk.readable !== true) return { entries: [], reason: SYSTEM_TOAST_REASONS.shortcutReadFailed };
744
+ if (walk.paths.length === 0) return { entries: [], reason: null };
745
+ const probe = await runPowerShell({ script: SHORTCUT_PROBE_SCRIPT, payload: { paths: walk.paths }, captureStdout: true });
746
+ if (probe.ok !== true) return { entries: [], reason: probe.reason };
747
+ let parsed;
748
+ try {
749
+ parsed = JSON.parse(probe.stdout.trim());
750
+ } catch {
751
+ return { entries: [], reason: SYSTEM_TOAST_REASONS.shortcutReadFailed };
752
+ }
753
+ // ConvertTo-Json 对单元素数组可能退化成对象:两种形状都接受。
754
+ const raw = Array.isArray(parsed) ? parsed : (parsed !== null && typeof parsed === "object" ? [parsed] : []);
755
+ const entries = [];
756
+ for (const entry of raw) {
757
+ if (entry === null || typeof entry !== "object") continue;
758
+ entries.push({ targetPath: entry.targetPath, aumid: entry.aumid });
759
+ }
760
+ return { entries, reason: null };
761
+ }
762
+
763
+ /**
764
+ * 解析(并缓存)AUMID:成功缓存引用;失败按冷却窗口重试,
765
+ * 避免每次通知都递归扫描开始菜单。
766
+ */
767
+ async function ensureAumid() {
768
+ if (aumidState.aumid !== null) return { aumid: aumidState.aumid, reason: SYSTEM_TOAST_REASONS.ok };
769
+ const timestamp = Number(now());
770
+ if (aumidState.attemptAt !== null && Number.isFinite(timestamp)
771
+ && timestamp - aumidState.attemptAt < aumidCooldownMs) {
772
+ return { aumid: null, reason: aumidState.reason ?? SYSTEM_TOAST_REASONS.noMatchingShortcut };
773
+ }
774
+ aumidState.attemptAt = Number.isFinite(timestamp) ? timestamp : null;
775
+ let loaded;
776
+ try {
777
+ loaded = await listShortcuts();
778
+ } catch {
779
+ loaded = { entries: [], reason: SYSTEM_TOAST_REASONS.shortcutReadFailed };
780
+ }
781
+ const reported = loaded?.reason ?? null;
782
+ if (reported !== null && reported !== void 0) {
783
+ const reason = normalizeReasonToken(reported, SYSTEM_TOAST_REASONS.shortcutReadFailed);
784
+ aumidState.aumid = null;
785
+ aumidState.reason = reason;
786
+ return { aumid: null, reason };
787
+ }
788
+ const matched = matchShortcutAumid({ shortcuts: loaded?.entries, execPath });
789
+ aumidState.aumid = matched.aumid;
790
+ aumidState.reason = matched.reason;
791
+ return matched;
792
+ }
793
+
794
+ /** 真正投递一次:AUMID → XML → 子进程;任何失败都返回结构化结果。 */
795
+ async function deliver({ title, taskName, body, volume }) {
796
+ const resolved = await ensureAumid();
797
+ if (resolved.aumid === null) {
798
+ note(resolved.reason);
799
+ warn(resolved.reason);
800
+ return toastResult(false, resolved.reason);
801
+ }
802
+ const xml = buildToastXml({ title, taskName, body });
803
+ // 载荷字段:正文只经 xml、音量与音频路径都是扁平字段(脚本据此决定是否播放)。
804
+ const outcome = await runPowerShell({
805
+ script: TOAST_SHOW_SCRIPT,
806
+ payload: { aumid: resolved.aumid, xml, audioPath, volume },
807
+ });
808
+ if (outcome.ok !== true) {
809
+ note(outcome.reason);
810
+ warn(outcome.reason);
811
+ return outcome;
812
+ }
813
+ stats.sent += 1;
814
+ stats.lastReason = SYSTEM_TOAST_REASONS.ok;
815
+ return toastResult(true, SYSTEM_TOAST_REASONS.ok);
816
+ }
817
+
818
+ /** 队列排空:串行投递,队列长度有界。 */
819
+ function drain() {
820
+ while (disposed !== true && inflight < maxConcurrent && queue.length > 0) {
821
+ const job = queue.shift();
822
+ void runJob(job);
823
+ }
824
+ }
825
+
826
+ async function runJob(job) {
827
+ inflight += 1;
828
+ let result;
829
+ try {
830
+ result = await deliver(job.request);
831
+ } catch {
832
+ // 适配器对上层绝不抛出:兜底收口成固定 token。
833
+ note(SYSTEM_TOAST_REASONS.spawnError);
834
+ result = toastResult(false, SYSTEM_TOAST_REASONS.spawnError);
835
+ } finally {
836
+ inflight -= 1;
837
+ }
838
+ job.resolve(result);
839
+ drain();
840
+ }
841
+
842
+ return Object.freeze({
843
+ /**
844
+ * 显示一条系统 Toast。
845
+ *
846
+ * @param {{title?: string, taskName?: string, body?: string, volume?: number}} [request]
847
+ * volume:0..100 的插件音量;缺失或非法按 100。只缩放 DEG 自己的提示音播放流,
848
+ * 不影响系统主音量,也不影响 Toast 文案与投递语义。
849
+ * @returns {Promise<{shown: boolean, reason: string, detail?: object}>} 永不 reject。
850
+ */
851
+ show(request) {
852
+ if (disposed) return Promise.resolve(toastResult(false, SYSTEM_TOAST_REASONS.disposed));
853
+ if (platform !== SYSTEM_TOAST_PLATFORM) {
854
+ // 非 Windows 不 spawn 任何子进程:直接结构化 unsupported。
855
+ note(SYSTEM_TOAST_REASONS.unsupported);
856
+ return Promise.resolve(toastResult(false, SYSTEM_TOAST_REASONS.unsupported));
857
+ }
858
+ const normalized = {
859
+ title: typeof request?.title === "string" ? request.title : "",
860
+ taskName: typeof request?.taskName === "string" ? request.taskName : "",
861
+ body: typeof request?.body === "string" ? request.body : "",
862
+ // 音量在归一化时就固定下来:排队期间设置变更不影响已经入队的这一条。
863
+ volume: normalizeToastVolume(request?.volume),
864
+ };
865
+ if (inflight >= maxConcurrent) {
866
+ if (queue.length >= maxQueue) {
867
+ stats.dropped += 1;
868
+ note(SYSTEM_TOAST_REASONS.queueFull);
869
+ warn(SYSTEM_TOAST_REASONS.queueFull);
870
+ return Promise.resolve(toastResult(false, SYSTEM_TOAST_REASONS.queueFull));
871
+ }
872
+ return new Promise((resolve) => {
873
+ queue.push({ request: normalized, resolve });
874
+ });
875
+ }
876
+ return new Promise((resolve) => {
877
+ void runJob({ request: normalized, resolve });
878
+ });
879
+ },
880
+ /** 只读诊断:固定 token 与非负计数,不含正文/命令行/路径/AUMID。 */
881
+ describe() {
882
+ return Object.freeze({
883
+ version: 1,
884
+ platform: platform === SYSTEM_TOAST_PLATFORM ? "windows" : "unsupported",
885
+ aumidResolved: aumidState.aumid !== null,
886
+ aumidReason: aumidState.reason,
887
+ inflight,
888
+ queued: queue.length,
889
+ sent: stats.sent,
890
+ failed: stats.failed,
891
+ dropped: stats.dropped,
892
+ lastReason: stats.lastReason,
893
+ });
894
+ },
895
+ /** 释放:清空队列、kill 在途子进程;幂等且不抛出。 */
896
+ dispose() {
897
+ if (disposed) return Promise.resolve();
898
+ disposed = true;
899
+ for (const job of queue.splice(0)) job.resolve(toastResult(false, SYSTEM_TOAST_REASONS.disposed));
900
+ for (const [child, cancelInFlight] of [...inFlight]) {
901
+ inFlight.delete(child);
902
+ try {
903
+ cancelInFlight();
904
+ } catch {
905
+ // 收口失败不影响后续 kill;调用方仍会拿到 disposed。
906
+ }
907
+ try {
908
+ child.kill?.();
909
+ } catch {
910
+ // 释放路径不抛出。
911
+ }
912
+ }
913
+ return Promise.resolve();
914
+ },
915
+ });
916
+ }