zen-gitsync 2.17.31 → 2.17.32

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 (42) hide show
  1. package/README.md +6 -6
  2. package/package.json +3 -1
  3. package/scripts/release.js +66 -29
  4. package/src/ui/public/assets/{AgentEngineSelector-bQmJlH1b.js → AgentEngineSelector-CPS9WawU.js} +1 -1
  5. package/src/ui/public/assets/{AgentView-DpNj6VFj.js → AgentView-CHj8Esf5.js} +1 -1
  6. package/src/ui/public/assets/{AppVersionBadge-2etZBF2E.js → AppVersionBadge-CbYQ0KFm.js} +2 -2
  7. package/src/ui/public/assets/{BranchSelector-BvbMlZW6.js → BranchSelector-i_DZoGr4.js} +1 -1
  8. package/src/ui/public/assets/{CommitForm-Bp41Q0Oo.js → CommitForm-DrAhqXPt.js} +1 -1
  9. package/src/ui/public/assets/{CommonDialog-UrQUmb6G.js → CommonDialog-Nwwi4-_9.js} +1 -1
  10. package/src/ui/public/assets/{EditorView-DIu2cIai.js → EditorView-Cj7r5oIY.js} +1 -1
  11. package/src/ui/public/assets/{FileDiffViewer-BlDGBxKd.js → FileDiffViewer-2vvvRZ_R.js} +1 -1
  12. package/src/ui/public/assets/{FlowExecutionViewer-MPq4Fh6G.js → FlowExecutionViewer-STZphigj.js} +1 -1
  13. package/src/ui/public/assets/{FlowOrchestrationWorkspace-ByBJ4B3G.js → FlowOrchestrationWorkspace-BdW1MijZ.js} +1 -1
  14. package/src/ui/public/assets/{IconButton-BvbRjQ_D.js → IconButton-BuiJBsJ3.js} +1 -1
  15. package/src/ui/public/assets/{LogList-Xs52JPC1.js → LogList-C14b7EUJ.js} +1 -1
  16. package/src/ui/public/assets/{MindmapView-Br7Fj7Ga.js → MindmapView-dcm1kcrO.js} +1 -1
  17. package/src/ui/public/assets/{MonitorView-DSV5_Q-6.js → MonitorView-7SOXc-I2.js} +1 -1
  18. package/src/ui/public/assets/{ProjectStartupButton-BCS2YQdX.js → ProjectStartupButton-BM2RrBbL.js} +1 -1
  19. package/src/ui/public/assets/{RecentDirectoriesChat-Dv47YPKL.js → RecentDirectoriesChat-CXxGKkqI.js} +1 -1
  20. package/src/ui/public/assets/{RemoteManagerDialog-CIHTYzr7.js → RemoteManagerDialog-BTtVeges.js} +1 -1
  21. package/src/ui/public/assets/{RemoteRepoCard-DCYTEmuq.js → RemoteRepoCard-B0n8k5Go.js} +1 -1
  22. package/src/ui/public/assets/{SourceMapView-BANSmwrh.js → SourceMapView-CT2C3tAc.js} +1 -1
  23. package/src/ui/public/assets/{SvgIcon-b50XyCO5.js → SvgIcon-U-AI95UV.js} +1 -1
  24. package/src/ui/public/assets/{TemplateManager-D5-gC33Z.js → TemplateManager-BIMeoTp0.js} +1 -1
  25. package/src/ui/public/assets/{UserInputNode-BFmTQYZP.js → UserInputNode-D_V8dhh9.js} +1 -1
  26. package/src/ui/public/assets/WorkbenchView-BeC5sg_V.css +1 -0
  27. package/src/ui/public/assets/WorkbenchView-C74ciC03.js +20 -0
  28. package/src/ui/public/assets/{_plugin-vue_export-helper-MIPci_U9.js → _plugin-vue_export-helper-DkeTtFKF.js} +2 -2
  29. package/src/ui/public/assets/{agentConversations-D8mdZaNY.js → agentConversations-B2aTliJe.js} +1 -1
  30. package/src/ui/public/assets/{configStore-BMixvVAA.js → configStore-Y1biBqmb.js} +1 -1
  31. package/src/ui/public/assets/{gitStore-DriJnutC.js → gitStore-CiBz68_Z.js} +1 -1
  32. package/src/ui/public/assets/{index-CEEQaAF4.js → index-BoZNUwmd.js} +5 -5
  33. package/src/ui/public/assets/{previewSandbox-D86LH88s.js → previewSandbox-D7HcNin7.js} +1 -1
  34. package/src/ui/public/assets/{toolsStore-B1Lx5gWu.js → toolsStore-Dhfr-VDL.js} +1 -1
  35. package/src/ui/public/index.html +11 -11
  36. package/src/ui/server/routes/workbench/llmClient.js +17 -2
  37. package/src/ui/server/routes/workbench/progressReport.js +135 -50
  38. package/src/ui/server/routes/workbench/progressReport.test.js +101 -4
  39. package/src/utils/desktopNotify.js +199 -41
  40. package/src/utils/desktopNotify.test.js +84 -29
  41. package/src/ui/public/assets/WorkbenchView-Dc7bMMfe.css +0 -1
  42. package/src/ui/public/assets/WorkbenchView-DsPII7uQ.js +0 -20
@@ -12,25 +12,34 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  //
15
- // 长任务结束后的"人不在终端前也能收到"的提醒:系统通知 + 提示音 + 终端标题。
15
+ // 长任务结束后的"人不在终端前也能收到"的提醒:系统通知 + 置顶弹窗 + 提示音 + 终端标题。
16
16
  //
17
17
  // 为什么需要它:release 的"装全局"阶段实测能磨十几分钟(registry 元数据/对象就绪慢,
18
18
  // 见 scripts/release.js 顶部那一长串注释),用户不可能一直盯着终端。终端里再醒目的一行
19
19
  // "🎉 发布完成",对已经走去干别的事的人等于没打 —— 提醒必须走**终端之外**的信道。
20
20
  //
21
- // 三条信道各自独立、各自 best-effort,一条挂掉不影响另外两条:
22
- // ① 系统通知(Windows toast / macOS osascript / Linux notify-send);
23
- // ② 提示音(成功与失败**不同**的音 —— 人不在屏幕前时,响成什么样是唯一能分辨结果的信号);
24
- // ③ 终端标题(任务栏上那行字变成结果,回头扫一眼就知道)。
21
+ // ============================ 两个必须踩过的坑 ============================
25
22
  //
26
- // 硬约束(踩过的坑都在这三条里):
27
- // - **绝不抛异常、绝不阻塞**:提醒是附加品,不许把一次成功的发布变成失败退出,
28
- // 也不许让父进程等着它。派生进程一律 unref(),`main()` 该返回就返回。
29
- // - spawn 一个不存在的命令(比如 Linux 上没装 notify-send)是走 `'error'` 事件而不是
30
- // 抛异常 —— 不挂这个监听就是**未捕获异常**,会直接打挂发布流程。
31
- // - 文案里带用户可控内容(版本号 / 报错原文 / 任务标题)时,**按目标信道各自转义**:
32
- // toast 正文进的是 XML(要转义 `& < >`),PowerShell 走单引号字符串(内部单引号翻倍)。
33
- // 漏了这两处不会报错,只会"什么都没弹出来",排查起来毫无线索。
23
+ // ① **`spawn` 之后 `unref()` 就走人,提醒根本发不出去。**
24
+ // 第一版就是这么写的(当时想的是"别让提醒拖住发布"),结果是:cmd 里老老实实打出
25
+ // "已发送结束提醒",而系统通知中心里一条都没有 —— 2026-09-30 实测,发版 v2.17.30 /
26
+ // v2.17.31 两次都没有任何提醒落地(注册表里 AUMID 的 LastNotificationAddedTime 停在
27
+ // 更早的一次手工测试上)。隔离实验(子进程睡 3s 再写 marker,父进程立刻退出):
28
+ // 普通 spawn、`detached: true`、甚至让子进程用 Start-Process 甩孙进程 —— **三个 marker
29
+ // 一个都没写出来**;父进程只要不 unref(让 node 等着),marker 立刻就有。
30
+ // 结论:**发提醒必须等子进程**,调用方 `await`。发布本来就跑了十几分钟,多等一两秒
31
+ // 换"提醒真的送出去"完全值得。
32
+ //
33
+ // ② **弹窗不能由 release 进程自己扛着。** 置顶弹窗是要停留几十秒给人看的,可子进程活不过
34
+ // 父进程(见①),node 一退弹窗就没了。办法是让"启动器"用 `Start-Process` 把它**甩出去**
35
+ // —— 实验证明被甩出去的进程能独立活到用户关掉它(启动器退出、甚至 node 退出之后,窗口
36
+ // 标题仍在枚举里)。所以链路是:node wait 启动器 → 启动器把弹窗甩出去后立刻退出。
37
+ //
38
+ // 另外两个 Windows 细节(都实测过,改的时候别想当然):
39
+ // - `Start-Process -WindowStyle Hidden` 会把**窗体也一起藏掉**(STARTUPINFO 的
40
+ // wShowWindow=SW_HIDE 会被子进程继承,而进程的第一次 ShowWindow 会强制用它)。
41
+ // → 先 `Show()` 一个丢弃窗体把这一次用掉,真窗体再正常显示。
42
+ // - 不给 `-WindowStyle Hidden` 的话会多出一个黑色控制台窗口 → 必须给。
34
43
  import { spawn } from 'node:child_process'
35
44
 
36
45
  // ---------------------------------------------------------------------------
@@ -80,12 +89,27 @@ const POWERSHELL_AUMID =
80
89
  // 这几个英文名)。逐个试,一个都没有才退回 SystemSounds 的默认音。
81
90
  const WIN_WAVS = {
82
91
  ok: ['Windows Notify System Generic.wav', 'notify.wav', 'chimes.wav'],
83
- fail: ['Windows Critical Stop.wav', 'Windows Battery Critical.wav', 'Alarm01.wav'],
92
+ error: ['Windows Critical Stop.wav', 'Windows Battery Critical.wav', 'Alarm01.wav'],
93
+ }
94
+ const WIN_SYSTEM_SOUND = { ok: 'Asterisk', error: 'Hand' }
95
+
96
+ // 三条可能出现的结局各一套配色(置顶弹窗的底色)。warn 用琥珀色:"包已经发出去了、
97
+ // 只是没装到全局"既不是成功也不是失败,涂成绿的或红的都是误导。
98
+ const LEVEL_COLORS = {
99
+ ok: { accent: [46, 160, 67], bg: [24, 28, 26] },
100
+ warn: { accent: [204, 141, 30], bg: [32, 28, 18] },
101
+ error: { accent: [200, 70, 70], bg: [34, 22, 24] },
84
102
  }
85
- const WIN_SYSTEM_SOUND = { ok: 'Asterisk', fail: 'Hand' }
86
103
 
87
- function winSoundScript(ok) {
88
- const kind = ok ? 'ok' : 'fail'
104
+ /** `level` 缺省时按 ok 回落:true → ok,false → error */
105
+ function normalizeLevel(level, ok) {
106
+ if (level === 'ok' || level === 'warn' || level === 'error') return level
107
+ return ok === false ? 'error' : 'ok'
108
+ }
109
+
110
+ function winSoundScript(level) {
111
+ // warn 也走"普通提示音":它要人看一眼,但不是出错
112
+ const kind = level === 'error' ? 'error' : 'ok'
89
113
  const candidates = WIN_WAVS[kind].map((f) => `(Join-Path $env:WINDIR 'Media\\${f}')`).join(', ')
90
114
  return [
91
115
  '$played = $false',
@@ -112,6 +136,102 @@ function winToastScript(title, message) {
112
136
  ].join('; ')
113
137
  }
114
138
 
139
+ // 置顶弹窗的停留时长。给足 5 分钟:系统横幅只挂 MessageDuration(本机实测 5 秒)就没了,
140
+ // 而"人走开几分钟再回来"才是常态;再长就没必要了(通知中心 + 终端标题都是常驻的)。
141
+ export const POPUP_TIMEOUT_MS = 5 * 60 * 1000
142
+
143
+ /**
144
+ * 置顶弹窗的脚本。**由启动器用 Start-Process 甩出去单独跑**(理由见文件头坑②)。
145
+ *
146
+ * 用 -EncodedCommand(UTF-16LE base64)传,绕开所有命令行引号问题:标题/正文里带单引号、
147
+ * 双引号、`&`、`%` 都不会出事。
148
+ */
149
+ export function winPopupScript({ title, message, level, ok = true, timeoutMs = POPUP_TIMEOUT_MS } = {}) {
150
+ const lv = normalizeLevel(level, ok)
151
+ const { accent, bg } = LEVEL_COLORS[lv]
152
+ const rgb = ([r, g, b]) => `[System.Drawing.Color]::FromArgb(${r}, ${g}, ${b})`
153
+ const minutes = Math.max(1, Math.round(timeoutMs / 60000))
154
+ return [
155
+ "$ErrorActionPreference = 'SilentlyContinue'",
156
+ 'Add-Type -AssemblyName System.Windows.Forms',
157
+ 'Add-Type -AssemblyName System.Drawing',
158
+ // 先 Show 一个丢弃窗体:进程的**第一次** ShowWindow 会被 STARTUPINFO 里的
159
+ // wShowWindow(SW_HIDE,来自 Start-Process -WindowStyle Hidden)强制覆盖,
160
+ // 让它顶掉,真窗体才能正常显示 —— 不加这段弹窗永远不出现。
161
+ '$d = New-Object System.Windows.Forms.Form',
162
+ '$d.Show(); $d.Hide(); $d.Dispose()',
163
+ '$f = New-Object System.Windows.Forms.Form',
164
+ `$f.Text = ${psQuote(title)}`,
165
+ '$f.TopMost = $true',
166
+ "$f.StartPosition = 'CenterScreen'",
167
+ '$f.ClientSize = New-Object System.Drawing.Size(540, 250)',
168
+ '$f.MinimizeBox = $false',
169
+ '$f.MaximizeBox = $false',
170
+ "$f.FormBorderStyle = 'FixedToolWindow'",
171
+ `$f.BackColor = ${rgb(bg)}`,
172
+ '$f.KeyPreview = $true',
173
+ // 整块窗体都能点掉:人在键盘前时一下就走,不用瞄准按钮
174
+ '$f.Add_Click({ $f.Close() })',
175
+ "$f.Add_KeyDown({ if ($_.KeyCode -eq 'Escape') { $f.Close() } })",
176
+ '$bar = New-Object System.Windows.Forms.Label',
177
+ '$bar.Dock = "Top"',
178
+ '$bar.Height = 5',
179
+ `$bar.BackColor = ${rgb(accent)}`,
180
+ '$f.Controls.Add($bar)',
181
+ '$t = New-Object System.Windows.Forms.Label',
182
+ `$t.Text = ${psQuote(title)}`,
183
+ "$t.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 13, [System.Drawing.FontStyle]::Bold)",
184
+ "$t.ForeColor = [System.Drawing.Color]::FromArgb(240, 242, 245)",
185
+ '$t.Dock = "Top"',
186
+ '$t.Height = 56',
187
+ '$t.Padding = New-Object System.Windows.Forms.Padding(20, 14, 20, 0)',
188
+ '$f.Controls.Add($t)',
189
+ '$m = New-Object System.Windows.Forms.Label',
190
+ `$m.Text = ${psQuote(message)}`,
191
+ "$m.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 10)",
192
+ "$m.ForeColor = [System.Drawing.Color]::FromArgb(190, 196, 205)",
193
+ '$m.Dock = "Fill"',
194
+ '$m.Padding = New-Object System.Windows.Forms.Padding(20, 8, 20, 0)',
195
+ '$f.Controls.Add($m)',
196
+ '$h = New-Object System.Windows.Forms.Label',
197
+ `$h.Text = ${psQuote(`点击窗口任意位置关闭(${minutes} 分钟后自动关闭)`)}`,
198
+ "$h.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 8)",
199
+ "$h.ForeColor = [System.Drawing.Color]::FromArgb(130, 138, 150)",
200
+ '$h.Dock = "Bottom"',
201
+ '$h.Height = 34',
202
+ '$h.TextAlign = "MiddleLeft"',
203
+ '$h.Padding = New-Object System.Windows.Forms.Padding(20, 0, 20, 6)',
204
+ '$f.Controls.Add($h)',
205
+ '$timer = New-Object System.Windows.Forms.Timer',
206
+ `$timer.Interval = ${timeoutMs}`,
207
+ '$timer.Add_Tick({ $f.Close() })',
208
+ '$timer.Start()',
209
+ '[void]$f.ShowDialog()',
210
+ ].join('\n')
211
+ }
212
+
213
+ /**
214
+ * Windows 的"启动器":在**一个** PowerShell 里按顺序做完三件事,然后退出。
215
+ * 三件事各自 try 住 —— 通知被策略挡了不该把提示音一起带走。
216
+ *
217
+ * 顺序是有讲究的:toast 和甩弹窗都是瞬间返回的(体感上同时出现),提示音是同步阻塞的
218
+ * (PlaySync 要把 wav 放完),放最后才不会拖慢前两者。
219
+ */
220
+ export function winLauncherScript({ title, message, level, ok = true, timeoutMs } = {}) {
221
+ const lv = normalizeLevel(level, ok)
222
+ const popupB64 = Buffer.from(
223
+ winPopupScript({ title, message, level: lv, timeoutMs }),
224
+ 'utf16le'
225
+ ).toString('base64')
226
+ return [
227
+ "$ErrorActionPreference = 'SilentlyContinue'",
228
+ `try { ${winToastScript(title, message)} } catch { }`,
229
+ "try { Start-Process -WindowStyle Hidden -FilePath 'powershell.exe'"
230
+ + ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${popupB64}') } catch { }`,
231
+ `try { ${winSoundScript(lv)} } catch { }`,
232
+ ].join('; ')
233
+ }
234
+
115
235
  // ---------------------------------------------------------------------------
116
236
  // 计划(纯函数 —— 单测只断言这里)
117
237
  // ---------------------------------------------------------------------------
@@ -119,28 +239,26 @@ function winToastScript(title, message) {
119
239
  /**
120
240
  * 按平台把"提醒一次"翻译成要跑的进程列表。
121
241
  *
122
- * 纯函数,不碰进程也不碰文件系统,便于单测;真正 spawn 的是 notifyDesktop()。
242
+ * 纯函数,不碰进程也不碰文件系统,便于单测;真正 spawn + 等待的是 notifyDesktop()。
123
243
  * 返回 `{ title, cmds: [{ cmd, args }] }`。
124
244
  */
125
- export function buildNotifyPlan({ platform = process.platform, title, message, ok = true } = {}) {
245
+ export function buildNotifyPlan({ platform = process.platform, title, message, ok = true, level, timeoutMs } = {}) {
126
246
  const safeTitle = String(title ?? '')
127
247
  const safeMessage = String(message ?? '')
248
+ const lv = normalizeLevel(level, ok)
128
249
 
129
250
  if (platform === 'win32') {
130
- const ps = ['-NoProfile', '-NonInteractive', '-Command']
251
+ const script = winLauncherScript({ title: safeTitle, message: safeMessage, level: lv, timeoutMs })
131
252
  return {
132
253
  title: safeTitle,
133
- cmds: [
134
- { cmd: 'powershell.exe', args: [...ps, winToastScript(safeTitle, safeMessage)] },
135
- { cmd: 'powershell.exe', args: [...ps, winSoundScript(ok)] },
136
- ],
254
+ cmds: [{ cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', script] }],
137
255
  }
138
256
  }
139
257
 
140
258
  if (platform === 'darwin') {
141
259
  const script = `display notification ${appleScriptQuote(safeMessage)}`
142
260
  + ` with title ${appleScriptQuote(safeTitle)}`
143
- + ` sound name ${appleScriptQuote(ok ? 'Glass' : 'Basso')}`
261
+ + ` sound name ${appleScriptQuote(lv === 'error' ? 'Basso' : 'Glass')}`
144
262
  return { title: safeTitle, cmds: [{ cmd: 'osascript', args: ['-e', script] }] }
145
263
  }
146
264
 
@@ -150,54 +268,94 @@ export function buildNotifyPlan({ platform = process.platform, title, message, o
150
268
  cmds: [
151
269
  {
152
270
  cmd: 'notify-send',
153
- args: ['-a', 'zen-gitsync', '-u', ok ? 'normal' : 'critical', safeTitle, safeMessage],
271
+ args: ['-a', 'zen-gitsync', '-u', lv === 'error' ? 'critical' : 'normal', safeTitle, safeMessage],
154
272
  },
155
273
  {
156
274
  cmd: 'paplay',
157
275
  args: [
158
- ok
159
- ? '/usr/share/sounds/freedesktop/stereo/complete.oga'
160
- : '/usr/share/sounds/freedesktop/stereo/dialog-error.oga',
276
+ lv === 'error'
277
+ ? '/usr/share/sounds/freedesktop/stereo/dialog-error.oga'
278
+ : '/usr/share/sounds/freedesktop/stereo/complete.oga',
161
279
  ],
162
280
  },
163
281
  ],
164
282
  }
165
283
  }
166
284
 
285
+ // 等子进程结束(或超时后杀掉它,免得把调用方一直吊着)
286
+ function waitForChild(child, timeoutMs) {
287
+ return new Promise((resolve) => {
288
+ let done = false
289
+ let timer = null
290
+ const finish = (settled) => {
291
+ if (done) return
292
+ done = true
293
+ if (timer) clearTimeout(timer)
294
+ resolve(settled)
295
+ }
296
+ timer = setTimeout(() => {
297
+ try {
298
+ child.kill()
299
+ } catch {
300
+ /* 已经退了 */
301
+ }
302
+ finish(false)
303
+ }, timeoutMs)
304
+ // 命令不存在(比如 Linux 上没装 notify-send)只会走 'error' 事件,不挂这个监听
305
+ // 就是未捕获异常 —— 会直接把一次成功的发布打挂。提醒是附加品,不许反噬主线。
306
+ child.on('error', () => finish(false))
307
+ child.on('exit', () => finish(true))
308
+ })
309
+ }
310
+
167
311
  /**
168
- * 发一次提醒。返回成功派生的进程数(拿不到具体结果 —— 提醒本身没有失败可言)。
312
+ * 发一次提醒,**并等它真的做完**(默认最多 15s)。返回 `{ launched, delivered }`。
169
313
  *
170
- * 不 await、不阻塞:所有子进程 unref(),调用方紧接着 process.exit() 也不会把它们掐掉。
314
+ * 为什么必须 await(而不是 spawn 完就走):见文件头坑①。第一版就是 fire-and-forget,
315
+ * 结果 cmd 里打着"已发送结束提醒"、而系统里一条通知都没有。
316
+ *
317
+ * 超时不会抛异常,只是把子进程杀掉并如实报 `delivered: false` —— 提醒失败不影响发布结果。
171
318
  */
172
- export function notifyDesktop({ title, message, ok = true, platform = process.platform } = {}) {
319
+ export async function notifyDesktop({
320
+ title,
321
+ message,
322
+ ok = true,
323
+ level,
324
+ platform = process.platform,
325
+ timeoutMs = 15000,
326
+ } = {}) {
173
327
  let plan
174
328
  try {
175
- plan = buildNotifyPlan({ platform, title, message, ok })
329
+ plan = buildNotifyPlan({ platform, title, message, ok, level })
176
330
  } catch {
177
- return 0
331
+ return { launched: 0, delivered: false }
178
332
  }
179
333
 
180
- let launched = 0
334
+ const children = []
181
335
  for (const { cmd, args } of plan.cmds) {
182
336
  try {
183
337
  const child = spawn(cmd, args, { stdio: 'ignore' })
184
- // 命令不存在只会触发 'error' 事件;没有这个监听就是未捕获异常
185
- child.on('error', () => {})
186
- child.unref()
187
- launched += 1
338
+ children.push(child)
188
339
  } catch {
189
340
  /* 参数非法等同步异常:吞掉,提醒失败不反噬主线 */
190
341
  }
191
342
  }
192
- return launched
343
+
344
+ const settled = await Promise.all(children.map((c) => waitForChild(c, timeoutMs)))
345
+ return {
346
+ launched: children.length,
347
+ delivered: children.length > 0 && settled.every(Boolean),
348
+ }
193
349
  }
194
350
 
195
351
  /**
196
352
  * 改终端标题。
197
353
  *
198
- * Windows 走 `process.title`(Node 内部是 SetConsoleTitle,实测 cmd 窗口标题会变);
354
+ * Windows 走 `process.title`(Node 内部是 SetConsoleTitle,实测 cmd 窗口标题会变),
199
355
  * 其他平台 process.title 只改进程名,所以额外写一次 OSC 0 转义 —— 只在 TTY 上写,
200
356
  * 重定向到文件时写这个等于往文件里灌乱码。
357
+ *
358
+ * 这条信道**不需要任何子进程**,所以哪怕通知/弹窗全被系统挡住,任务栏上那行字也还在。
201
359
  */
202
360
  export function setTerminalTitle(text) {
203
361
  const title = String(text ?? '')
@@ -14,14 +14,17 @@
14
14
  //
15
15
  // src/utils/desktopNotify.js 里"纯函数 + 转义"部分的单元测试(node:test 内置)。
16
16
  //
17
- // 只测到 buildNotifyPlan 为止,**不真的 spawn** —— 单测里弹 toast / 响一声会打扰跑测试的人。
18
- // 这里要守住的两件事,都是"错了不会报错、只会静默失效"的类型:
17
+ // 只测到 buildNotifyPlan 为止,**不真的 spawn** —— 单测里弹窗/响一声会打扰跑测试的人。
18
+ // 这里要守住的三件事,全都是"错了不会报错、只会静默失效"的类型:
19
19
  // ① 文案里的 `<` / `&` 没转义 → toast 的 XML 解析失败 → 一条都不弹,且错误被吞;
20
- // ② 文案里的单引号没翻倍 → PowerShell 语法错 → 静默什么都不做。
20
+ // ② 文案里的单引号没翻倍 → PowerShell 语法错 → 静默什么都不做;
21
+ // ③ 弹窗脚本没被正确 base64 编进启动器 → Start-Process 收到一段垃圾,弹窗永远不出现。
21
22
  import { test } from 'node:test'
22
23
  import assert from 'node:assert/strict'
23
24
  import {
24
25
  buildNotifyPlan,
26
+ winPopupScript,
27
+ winLauncherScript,
25
28
  psQuote,
26
29
  xmlEscape,
27
30
  appleScriptQuote,
@@ -47,56 +50,108 @@ test('appleScriptQuote: 先转反斜杠再转双引号(顺序反了会把 \\"
47
50
  assert.equal(appleScriptQuote('C:\\tmp'), '"C:\\\\tmp"')
48
51
  })
49
52
 
50
- // ========== Windows ==========
53
+ // ========== Windows:启动器 ==========
51
54
 
52
- test('win32 plan: 两个进程 —— toast 一个、提示音一个', () => {
55
+ test('win32 plan: 只起一个 PowerShell(三件事都在同一个进程里做,省两次冷启动)', () => {
53
56
  const plan = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', ok: true })
54
- assert.equal(plan.cmds.length, 2)
55
- assert.ok(plan.cmds.every((c) => c.cmd === 'powershell.exe'))
56
- assert.ok(plan.cmds.every((c) => c.args.includes('-NoProfile') && c.args.includes('-NonInteractive')))
57
+ assert.equal(plan.cmds.length, 1)
58
+ assert.equal(plan.cmds[0].cmd, 'powershell.exe')
59
+ assert.ok(plan.cmds[0].args.includes('-NoProfile'))
60
+ assert.ok(plan.cmds[0].args.includes('-NonInteractive'))
57
61
  })
58
62
 
59
- test('win32 toast: 正文走 XML 转义,且整段脚本是单引号字面量(带引号也不炸)', () => {
60
- const plan = buildNotifyPlan({
63
+ test('win32 启动器:toast + 甩弹窗 + 提示音三件事齐,且各自 try 住', () => {
64
+ const script = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', ok: true }).cmds[0].args.at(-1)
65
+ assert.ok(script.includes('ToastNotificationManager'), '缺 toast')
66
+ assert.ok(script.includes('Start-Process -WindowStyle Hidden'), '缺甩弹窗')
67
+ assert.ok(script.includes('SoundPlayer'), '缺提示音')
68
+ assert.ok(script.includes('try {') && script.includes('catch { }'), '三件事没各自 try 住')
69
+ })
70
+
71
+ test('win32 启动器:弹窗脚本按 UTF-16LE base64 编进 -EncodedCommand', () => {
72
+ const title = '发布完成 · zen-gitsync v1.2.3'
73
+ const message = "包里有 'quote' 与 <tag>"
74
+ const script = winLauncherScript({ title, message, level: 'ok' })
75
+ const popup = winPopupScript({ title, message, level: 'ok' })
76
+ const b64 = Buffer.from(popup, 'utf16le').toString('base64')
77
+ assert.ok(script.includes(b64), '启动器里的 base64 与 winPopupScript 对不上')
78
+ // 反向自证:解回来必须还是原脚本(自证不是空断言)
79
+ const decoded = Buffer.from(b64, 'base64').toString('utf16le')
80
+ assert.equal(decoded, popup)
81
+ assert.ok(decoded.includes("包里有 ''quote'' 与 <tag>"), 'PS 单引号未翻倍')
82
+ })
83
+
84
+ test('win32 toast: 正文走 XML 转义,且整段脚本是单引号字面量', () => {
85
+ const script = buildNotifyPlan({
61
86
  platform: 'win32',
62
87
  title: "it's done",
63
88
  message: 'Unexpected token < in JSON & "x"',
64
89
  ok: true,
65
- })
66
- const script = plan.cmds[0].args.at(-1)
90
+ }).cmds[0].args.at(-1)
67
91
  assert.ok(script.includes('Unexpected token &lt; in JSON &amp; "x"'), 'XML 未转义')
68
92
  assert.ok(script.includes("it''s done"), 'PS 单引号未翻倍')
69
- // 转义后的正文不能再出现裸的 <text> 之外的尖括号
70
93
  assert.ok(!script.includes('< in JSON'), '原始 < 漏进了 XML')
71
94
  })
72
95
 
73
- test('win32 提示音:成功与失败是不同的音源', () => {
74
- const okScript = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', ok: true }).cmds[1].args.at(-1)
75
- const failScript = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', ok: false }).cmds[1].args.at(-1)
76
- assert.notEqual(okScript, failScript)
96
+ test('win32 提示音:成功与失败是不同的音源,且成功走普通提示音', () => {
97
+ const okScript = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', ok: true }).cmds[0].args.at(-1)
98
+ const errScript = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', ok: false }).cmds[0].args.at(-1)
77
99
  assert.ok(okScript.includes('Windows Notify System Generic.wav'))
78
- assert.ok(failScript.includes('Windows Critical Stop.wav'))
79
- // 各自的兜底音也要分开
100
+ assert.ok(errScript.includes('Windows Critical Stop.wav'))
80
101
  assert.ok(okScript.includes('SystemSounds]::Asterisk.Play()'))
81
- assert.ok(failScript.includes('SystemSounds]::Hand.Play()'))
102
+ assert.ok(errScript.includes('SystemSounds]::Hand.Play()'))
103
+ })
104
+
105
+ test('win32 提示音:warn 用普通提示音(要人看一眼,但不是出错)', () => {
106
+ const script = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm', level: 'warn' }).cmds[0].args.at(-1)
107
+ assert.ok(script.includes('Windows Notify System Generic.wav'))
108
+ assert.ok(!script.includes('Windows Critical Stop.wav'))
109
+ })
110
+
111
+ // ========== Windows:弹窗 ==========
112
+
113
+ test('弹窗:置顶 + 可点掉 + 有兜底自动关闭(不然会一直挂在桌面上)', () => {
114
+ const script = winPopupScript({ title: 't', message: 'm', level: 'ok', timeoutMs: 5000 })
115
+ assert.ok(script.includes('$f.TopMost = $true'))
116
+ assert.ok(script.includes('$f.Add_Click({ $f.Close() })'))
117
+ assert.ok(script.includes("$_.KeyCode -eq 'Escape'"))
118
+ assert.ok(script.includes('$timer.Interval = 5000'))
119
+ })
120
+
121
+ test('弹窗:先 Show 一个丢弃窗体吞掉 STARTUPINFO 的 SW_HIDE,否则真窗体永远不显示', () => {
122
+ const script = winPopupScript({ title: 't', message: 'm' })
123
+ const dummyAt = script.indexOf('$d.Show(); $d.Hide(); $d.Dispose()')
124
+ const realAt = script.indexOf('ShowDialog()')
125
+ assert.ok(dummyAt >= 0 && realAt > dummyAt, '丢弃窗体没出现在真窗体之前')
126
+ })
127
+
128
+ test('弹窗:三种结局三套配色,ok / warn / 未给 level 时按 ok 回落', () => {
129
+ // 取顶部色条的颜色(背景色在脚本里更靠前,不能拿"第一个 Argb"当判据)
130
+ const colorOf = (opts) =>
131
+ winPopupScript({ title: 't', message: 'm', ...opts }).match(/\$bar\.BackColor = .*?Argb\((\d+, \d+, \d+)\)/)[1]
132
+ assert.equal(colorOf({ level: 'ok' }), '46, 160, 67')
133
+ assert.equal(colorOf({ level: 'warn' }), '204, 141, 30')
134
+ assert.equal(colorOf({ level: 'error' }), '200, 70, 70')
135
+ // 没给 level:ok:false 应当拿到错误色,而不是默认色
136
+ assert.equal(colorOf({ ok: true }), '46, 160, 67')
137
+ assert.equal(colorOf({ ok: false }), '200, 70, 70')
82
138
  })
83
139
 
84
- test('win32 提示音:先 PlaySync 播 wav,播到了就不放兜底音', () => {
85
- const script = buildNotifyPlan({ platform: 'win32', title: 't', message: 'm' }).cmds[1].args.at(-1)
86
- assert.ok(script.includes('PlaySync()'))
87
- assert.ok(script.includes('if (-not $played)'), '兜底音没被 $played 拦住 → 会响两遍')
140
+ test('弹窗:正文里的单引号 / 换行不会破语法(整体走 psQuote)', () => {
141
+ const script = winPopupScript({ title: "it's", message: "line1\nline2 'quoted'" })
142
+ assert.ok(script.includes("line1\nline2 ''quoted''"))
88
143
  })
89
144
 
90
145
  // ========== macOS / Linux ==========
91
146
 
92
- test('darwin plan: 一条 osascript,成功失败用不同的 sound name', () => {
147
+ test('darwin plan: 一条 osascript,成功用 Glass、出错用 Basso', () => {
93
148
  const okPlan = buildNotifyPlan({ platform: 'darwin', title: 't', message: 'm', ok: true })
94
- const failPlan = buildNotifyPlan({ platform: 'darwin', title: 't', message: 'm', ok: false })
149
+ const errPlan = buildNotifyPlan({ platform: 'darwin', title: 't', message: 'm', ok: false })
95
150
  assert.equal(okPlan.cmds.length, 1)
96
151
  assert.equal(okPlan.cmds[0].cmd, 'osascript')
97
152
  assert.ok(okPlan.cmds[0].args[1].includes('sound name "Glass"'))
98
- assert.ok(failPlan.cmds[0].args[1].includes('sound name "Basso"'))
99
- assert.ok(failPlan.cmds[0].args[1].includes('with title "t"'))
153
+ assert.ok(errPlan.cmds[0].args[1].includes('sound name "Basso"'))
154
+ assert.ok(errPlan.cmds[0].args[1].includes('with title "t"'))
100
155
  })
101
156
 
102
157
  test('darwin plan: 文案里的双引号转义后再进 AppleScript', () => {
@@ -118,5 +173,5 @@ test('未知平台按 linux 兜底(不抛异常)', () => {
118
173
  test('缺省参数不炸:title / message 全空也能生成计划', () => {
119
174
  const plan = buildNotifyPlan({ platform: 'win32' })
120
175
  assert.equal(plan.title, '')
121
- assert.equal(plan.cmds.length, 2)
176
+ assert.equal(plan.cmds.length, 1)
122
177
  })