dsh-plugin-desktop-notify 0.0.0-stage → 0.5.6

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/client.js ADDED
@@ -0,0 +1,709 @@
1
+ /**
2
+ * dsh-plugin-desktop-notify — renderer half.
3
+ *
4
+ * Mirrors what the Codex desktop app does for "something needs you":
5
+ *
6
+ * - a native macOS banner for a finished turn, a pending approval / question /
7
+ * plan review, or a turn that failed;
8
+ * - a Dock badge whose number is the count of sessions currently needing you,
9
+ * i.e. every banner also leaves a persistent red dot until you open that
10
+ * session (the user's rule: a notification always implies the badge);
11
+ * - clicking the banner opens the session and raises the window.
12
+ *
13
+ * Both surfaces run in the renderer: `new Notification(...)` is forwarded by
14
+ * Electron to the macOS notification centre **as DeepSeek Harness**, and
15
+ * `navigator.setAppBadge()` reaches Electron's BadgeManager and ends up at
16
+ * `NSDockTile.badgeLabel`. The host process cannot touch either.
17
+ *
18
+ * Facts, not bookkeeping: the completion and waiting facts come from the
19
+ * harness's own `uiSession.sessionStatus` (the same ones the sidebar dots read),
20
+ * so "read" means "opened that session in the main view", exactly like the
21
+ * official UI. Failures are event-sourced from `api-session/error`.
22
+ *
23
+ * Diagnostics: `window.__DSH_DESKTOP_NOTIFY__` in DevTools (⌘⌥I) reports the
24
+ * capabilities and the live attention state, and can drive each surface by
25
+ * hand.
26
+ */
27
+
28
+ window.__ModuleLoader__.load({
29
+ id: "dsh-plugin-desktop-notify",
30
+ factory: (require) => {
31
+ const NS = "dsh-plugin-desktop-notify"
32
+ /** Reported by capabilities(); keep in step with package.json (cross-machine support). */
33
+ const VERSION = "0.5.6"
34
+ const PREFERENCES_KEY = `${NS}:preferences`
35
+
36
+ /** Defaults; override per field with the localStorage JSON described in README. */
37
+ const DEFAULTS = {
38
+ notifications: true,
39
+ notifyComplete: true,
40
+ notifyPending: true,
41
+ notifyError: true,
42
+ notifyViewed: false,
43
+ badge: true,
44
+ excludeSubagents: true,
45
+ // Empty means "use the built-in copy for the active UI language".
46
+ titleComplete: "",
47
+ titlePending: "",
48
+ titleError: "",
49
+ maxBodyChars: 140,
50
+ }
51
+
52
+ /**
53
+ * Notification copy. Terse product language, mirroring how the app itself
54
+ * words things; the language follows the UI (`<html lang>`), not the OS.
55
+ */
56
+ const COPY = {
57
+ zh: {
58
+ completeTitle: "任务已完成",
59
+ complete: (name, duration) => withName(name, duration === "" ? "已完成" : `已完成,用时 ${duration}`),
60
+ approvalTitle: "等待你的批准",
61
+ approval: (name, tool) => withName(name, tool === "" ? "有一项操作等待你批准" : `请求使用 ${tool},等待你的批准`),
62
+ questionTitle: "等待你的回答",
63
+ question: (name) => withName(name, "有提问等待你回复"),
64
+ planTitle: "等待你的确认",
65
+ plan: (name) => withName(name, "执行计划等待你确认"),
66
+ errorTitle: "任务执行失败",
67
+ error: (name, message) => withName(name, message === "" ? "执行中断" : message),
68
+ seconds: (value) => `${value} 秒`,
69
+ minutes: (minutes, seconds) => (seconds === 0 ? `${minutes} 分` : `${minutes} 分 ${seconds} 秒`),
70
+ hours: (hours, minutes) => (minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`),
71
+ },
72
+ en: {
73
+ completeTitle: "Task completed",
74
+ complete: (name, duration) => withName(name, duration === "" ? "Completed" : `Completed in ${duration}`),
75
+ approvalTitle: "Approval needed",
76
+ approval: (name, tool) => withName(name, tool === "" ? "A tool is waiting for your approval" : `${tool} is waiting for your approval`),
77
+ questionTitle: "Your reply is needed",
78
+ question: (name) => withName(name, "A question is waiting for your reply"),
79
+ planTitle: "Plan review needed",
80
+ plan: (name) => withName(name, "The execution plan is waiting for your review"),
81
+ errorTitle: "Task failed",
82
+ error: (name, message) => withName(name, message === "" ? "The run was interrupted" : message),
83
+ seconds: (value) => `${value}s`,
84
+ minutes: (minutes, seconds) => (seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`),
85
+ hours: (hours, minutes) => (minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`),
86
+ },
87
+ }
88
+
89
+ /** Prefix the state sentence with the session name, when the session has one. */
90
+ function withName(name, text) {
91
+ return name === "" ? text : `「${name}」${text}`
92
+ }
93
+
94
+ /** The UI language drives the copy; the app always sets `<html lang>`. */
95
+ function activeLanguage() {
96
+ try {
97
+ const html = typeof document === "undefined" ? "" : document.documentElement?.lang ?? ""
98
+ const declared = String(html !== "" ? html : (typeof navigator === "undefined" ? "" : navigator.language) ?? "").toLowerCase()
99
+ if (declared.startsWith("zh")) return "zh"
100
+ if (declared !== "") return "en"
101
+ } catch {
102
+ /* fall through to the default */
103
+ }
104
+ return "zh"
105
+ }
106
+
107
+ /** The copy table for the current UI language. */
108
+ function copy() {
109
+ return COPY[activeLanguage()] ?? COPY.zh
110
+ }
111
+
112
+ /** Compact elapsed time: "42 秒" / "3 分 12 秒" / "1 小时 5 分". */
113
+ function durationText(milliseconds) {
114
+ const table = copy()
115
+ const total = Math.max(1, Math.round(milliseconds / 1000))
116
+ if (total < 60) return table.seconds(total)
117
+ const minutes = Math.floor(total / 60)
118
+ if (minutes < 60) return table.minutes(minutes, total % 60)
119
+ return table.hours(Math.floor(minutes / 60), minutes % 60)
120
+ }
121
+
122
+ /** Pending-interaction kinds the plugin reports. */
123
+ const PENDING_KINDS = ["approval", "question", "plan-review"]
124
+
125
+ /** Read the browser storage when this renderer has one. */
126
+ function storage() {
127
+ try {
128
+ return typeof localStorage === "undefined" ? undefined : localStorage
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+
134
+ /** Normalize one stored section; malformed or unknown fields fall back to defaults. */
135
+ function readPreferences() {
136
+ const preferences = { ...DEFAULTS }
137
+ let raw
138
+ try {
139
+ raw = storage()?.getItem(PREFERENCES_KEY)
140
+ } catch {
141
+ raw = undefined
142
+ }
143
+ if (typeof raw !== "string" || raw === "") return preferences
144
+ let parsed
145
+ try {
146
+ parsed = JSON.parse(raw)
147
+ } catch {
148
+ return preferences
149
+ }
150
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return preferences
151
+ for (const [key, fallback] of Object.entries(DEFAULTS)) {
152
+ const value = parsed[key]
153
+ if (value === undefined) continue
154
+ if (typeof value !== typeof fallback) continue
155
+ preferences[key] = value
156
+ }
157
+ return preferences
158
+ }
159
+
160
+ /** Collapse whitespace and cut to the body budget. */
161
+ function bodyText(text, maxChars) {
162
+ const collapsed = String(text).replace(/\s+/gu, " ").trim()
163
+ if (collapsed.length <= maxChars) return collapsed
164
+ return `${collapsed.slice(0, Math.max(0, maxChars - 1))}…`
165
+ }
166
+
167
+ /** Whether one status is waiting for a human answer or approval. */
168
+ function pendingKindOf(status) {
169
+ const kind = status?.pendingInteraction?.kind
170
+ return typeof kind === "string" && kind !== "" ? kind : undefined
171
+ }
172
+
173
+ /** The tool a pending approval names, when the interaction carries one. */
174
+ function pendingToolName(status) {
175
+ const tool = status?.pendingInteraction?.toolName
176
+ return typeof tool === "string" ? tool.trim() : ""
177
+ }
178
+
179
+ /** Whether the main view is currently showing this session. */
180
+ function isViewed(summary) {
181
+ const retained = summary?.retainedBy?.mainView
182
+ return typeof retained === "number" && retained > 0
183
+ }
184
+
185
+ /** A session row that belongs to a subagent never gets a banner or a badge. */
186
+ function isSubagent(summary) {
187
+ return summary?.origin === "subagent"
188
+ }
189
+
190
+ /** The session title as the body prefix, or a neutral fallback. */
191
+ function sessionName(summary, maxChars) {
192
+ const title = typeof summary?.title === "string" ? summary.title.trim() : ""
193
+ return title === "" ? "" : bodyText(title, maxChars)
194
+ }
195
+
196
+ /** Whether this renderer can post a native notification. */
197
+ function canNotify() {
198
+ return typeof Notification !== "undefined" && Notification.permission !== "denied"
199
+ }
200
+
201
+ /** Whether this renderer can write the Dock badge. */
202
+ function canBadge() {
203
+ return typeof navigator !== "undefined" && typeof navigator.setAppBadge === "function"
204
+ }
205
+
206
+ /** Whether the application window currently has keyboard focus. */
207
+ function windowFocused() {
208
+ try {
209
+ return typeof document === "undefined" || typeof document.hasFocus !== "function" ? true : document.hasFocus()
210
+ } catch {
211
+ return true
212
+ }
213
+ }
214
+
215
+ /** Whether the application window is visible (not hidden or minimized). */
216
+ function windowVisible() {
217
+ try {
218
+ return typeof document === "undefined" || document.visibilityState === undefined
219
+ ? true
220
+ : document.visibilityState === "visible"
221
+ } catch {
222
+ return true
223
+ }
224
+ }
225
+
226
+ /**
227
+ * "Watching this session" = it is the main view AND the window has focus and
228
+ * is visible. The harness's own `completionUnread` only covers "another
229
+ * session is in the main view"; a backgrounded or hidden window is invisible
230
+ * to the host, so this plugin adds the focus/visibility half itself.
231
+ */
232
+ function isLooking(summary) {
233
+ return isViewed(summary) && windowFocused() && windowVisible()
234
+ }
235
+
236
+ /** Ask once for notification permission; Electron normally already granted it. */
237
+ function askPermission() {
238
+ if (typeof Notification === "undefined") return
239
+ try {
240
+ if (Notification.permission === "default") void Notification.requestPermission()
241
+ } catch {
242
+ /* a denied or unavailable permission surface simply leaves banners off */
243
+ }
244
+ }
245
+
246
+ const inject = ["sessions", "uiSession", "uiWorkspace"]
247
+
248
+ /**
249
+ * @param ctx - Client root context.
250
+ */
251
+ function apply(ctx) {
252
+ const preferences = readPreferences()
253
+ askPermission()
254
+
255
+ /** sessionId -> { completed?: true, pendingKind?: string, error?: string } */
256
+ const attention = new Map()
257
+ /** sessionId -> last observed { running, completed, pendingKind }, for edge detection. */
258
+ const previous = new Map()
259
+ /** sessionId -> ms timestamp when the current turn was first seen running. */
260
+ const turnStartedAt = new Map()
261
+ let lastBadge
262
+ /** The last banner attempt and its outcome — Windows has several ways to swallow it. */
263
+ let lastNotify
264
+ /** The last banner click and what it managed to do — Windows/macOS differ here. */
265
+ let lastClick
266
+
267
+ /** Record why a banner did or did not reach the OS. */
268
+ function recordNotify(entry) {
269
+ lastNotify = {
270
+ at: new Date().toISOString(),
271
+ permission: typeof Notification === "undefined" ? "unavailable" : Notification.permission,
272
+ ...entry,
273
+ }
274
+ }
275
+
276
+ /** Write the Dock badge only when the number actually changes. */
277
+ function applyBadge() {
278
+ if (!canBadge()) return
279
+ const value = preferences.badge && attention.size > 0 ? attention.size : 0
280
+ if (value === lastBadge) return
281
+ lastBadge = value
282
+ try {
283
+ if (value > 0) void navigator.setAppBadge(value)
284
+ else void navigator.clearAppBadge()
285
+ } catch {
286
+ /* a renderer without badge support keeps running without one */
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Open one session in the main view. `uiWorkspace` is the service the sidebar
292
+ * uses; older builds may only expose it through `ctx.get`, so try both and
293
+ * report what happened instead of swallowing a failure.
294
+ */
295
+ function openSessionTarget(sessionId) {
296
+ if (typeof sessionId !== "string" || sessionId === "") return { ok: false, attempts: ["empty session id"] }
297
+ const candidates = []
298
+ try {
299
+ if (typeof ctx.uiWorkspace?.openSession === "function") candidates.push(["ctx.uiWorkspace (injected)", ctx.uiWorkspace])
300
+ } catch (error) {
301
+ candidates.push(["ctx.uiWorkspace", `unavailable: ${String(error)}`])
302
+ }
303
+ try {
304
+ if (ctx.uiWorkspace !== undefined) candidates.push(["ctx.uiWorkspace", ctx.uiWorkspace])
305
+ } catch {
306
+ candidates.push(["ctx.uiWorkspace", "unavailable"])
307
+ }
308
+ try {
309
+ const resolved = typeof ctx.get === "function" ? ctx.get("uiWorkspace") : undefined
310
+ if (resolved !== undefined) candidates.push(["ctx.get('uiWorkspace')", resolved])
311
+ } catch {
312
+ candidates.push(["ctx.get('uiWorkspace')", "unavailable"])
313
+ }
314
+ const attempts = []
315
+ for (const [name, service] of candidates) {
316
+ if (typeof service?.openSession !== "function") {
317
+ attempts.push(`${name}: no openSession`)
318
+ continue
319
+ }
320
+ try {
321
+ service.openSession(sessionId)
322
+ return { ok: true, via: name, attempts }
323
+ } catch (error) {
324
+ attempts.push(`${name}: ${String(error)}`)
325
+ }
326
+ }
327
+ if (candidates.length === 0) attempts.push("uiWorkspace service is not reachable")
328
+ return { ok: false, attempts }
329
+ }
330
+
331
+ /** Post one banner; clicking it opens the session and raises the window. */
332
+ function notify(kind, sessionId, title, body) {
333
+ if (!preferences.notifications) {
334
+ recordNotify({ kind, sessionId, result: "skipped: preferences.notifications=false" })
335
+ return
336
+ }
337
+ if (!canNotify()) {
338
+ const permission = typeof Notification === "undefined" ? "unavailable" : Notification.permission
339
+ recordNotify({ kind, sessionId, result: `skipped: Notification permission=${permission}` })
340
+ return
341
+ }
342
+ let notification
343
+ try {
344
+ // No `tag` and no `silent`: Windows 11 silently drops web notifications
345
+ // whose tag is longer than ~34 characters (electron/electron#40433), and
346
+ // a long per-session tag was exactly what this used to pass. Edge-based
347
+ // dedupe below already prevents the same banner firing twice.
348
+ notification = new Notification(title, { body })
349
+ } catch (error) {
350
+ recordNotify({ kind, sessionId, result: `threw: ${String(error)}` })
351
+ return
352
+ }
353
+ recordNotify({ kind, sessionId, title, body, result: "posted" })
354
+ notification.onclick = () => {
355
+ // Raise the window first: the navigation below is a layout operation and
356
+ // behaves best on an active window.
357
+ let focused = false
358
+ try {
359
+ window.focus()
360
+ focused = true
361
+ } catch {
362
+ /* focus is best effort */
363
+ }
364
+ const opened = openSessionTarget(sessionId)
365
+ lastClick = {
366
+ at: new Date().toISOString(),
367
+ kind,
368
+ sessionId,
369
+ opened,
370
+ focused,
371
+ }
372
+ // A window that was closed to the tray can swallow the first attempt;
373
+ // retry once after the OS finishes activating the application.
374
+ if (!opened.ok) {
375
+ setTimeout(() => {
376
+ const retry = openSessionTarget(sessionId)
377
+ lastClick = { ...lastClick, retried: retry }
378
+ }, 150)
379
+ }
380
+ // Windows applies foreground changes asynchronously and may swallow the
381
+ // first attempt; retry the raise without touching the selected session.
382
+ for (const delay of [60, 300]) {
383
+ setTimeout(() => {
384
+ try {
385
+ window.focus()
386
+ } catch {
387
+ /* focus is best effort */
388
+ }
389
+ }, delay)
390
+ }
391
+ try {
392
+ notification.close()
393
+ } catch {
394
+ /* already gone */
395
+ }
396
+ }
397
+ }
398
+
399
+ /** Record one attention fact and drop the session's badge entry when it empties. */
400
+ function setAttention(sessionId, patch) {
401
+ const entry = { ...(attention.get(sessionId) ?? {}), ...patch }
402
+ for (const key of Object.keys(entry)) {
403
+ if (entry[key] === undefined) delete entry[key]
404
+ }
405
+ if (Object.keys(entry).length === 0) attention.delete(sessionId)
406
+ else attention.set(sessionId, entry)
407
+ }
408
+
409
+ /** Recompute notifications, attention entries, and the badge from live status. */
410
+ function sync() {
411
+ const statuses = ctx.uiSession.sessionStatus.getSnapshot()
412
+ const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
413
+ const seen = new Set()
414
+
415
+ for (const [sessionId, status] of statuses) {
416
+ const summary = summaries[sessionId]
417
+ if (preferences.excludeSubagents && isSubagent(summary)) {
418
+ attention.delete(sessionId)
419
+ previous.delete(sessionId)
420
+ continue
421
+ }
422
+ seen.add(sessionId)
423
+ const before = previous.get(sessionId) ?? {}
424
+ const entry = attention.get(sessionId) ?? {}
425
+ // A failure already recorded for this turn owns the banner and the
426
+ // badge; the `running: true -> false` edge that follows it is the same
427
+ // event, not a second one.
428
+ const failurePending = entry.error !== undefined
429
+ const looking = isLooking(summary)
430
+ const completed = status?.completionUnread === true
431
+ const pendingKind = pendingKindOf(status)
432
+ const running = status?.running === true
433
+ const justFinished = before.running === true && running === false
434
+ const finishedNow = justFinished || (completed && before.completed !== true)
435
+ const completionEdge = finishedNow && !failurePending
436
+ const pendingEdge = pendingKind !== undefined && pendingKind !== before.pendingKind
437
+
438
+ if (running) {
439
+ if (!turnStartedAt.has(sessionId)) turnStartedAt.set(sessionId, Date.now())
440
+ }
441
+
442
+ const table = copy()
443
+ const name = sessionName(summary, preferences.maxBodyChars)
444
+
445
+ if (completionEdge) {
446
+ const started = turnStartedAt.get(sessionId)
447
+ turnStartedAt.delete(sessionId)
448
+ const duration = started === undefined ? "" : durationText(Date.now() - started)
449
+ const title = preferences.titleComplete || table.completeTitle
450
+ const body = bodyText(table.complete(name, duration), preferences.maxBodyChars)
451
+ if (looking) {
452
+ // Watching it finish is only worth a banner when the user opted in.
453
+ if (preferences.notifyViewed && preferences.notifyComplete) notify("complete", sessionId, title, body)
454
+ } else if (preferences.notifyComplete) {
455
+ notify("complete", sessionId, title, body)
456
+ }
457
+ }
458
+
459
+ if (looking) {
460
+ // Reading the session drops every fact for it, so the badge falls back.
461
+ attention.delete(sessionId)
462
+ } else {
463
+ if (preferences.notifyPending && pendingEdge) {
464
+ const tool = pendingKind === "approval" ? pendingToolName(status) : ""
465
+ const body = pendingKind === "approval"
466
+ ? table.approval(name, tool)
467
+ : pendingKind === "plan-review"
468
+ ? table.plan(name)
469
+ : table.question(name)
470
+ const title = preferences.titlePending || (pendingKind === "approval" ? table.approvalTitle : pendingKind === "plan-review" ? table.planTitle : table.questionTitle)
471
+ notify("pending", sessionId, title, bodyText(body, preferences.maxBodyChars))
472
+ }
473
+ setAttention(sessionId, {
474
+ // Sticky until you look at the session: the edge is the trigger,
475
+ // the unread fact is what keeps the red dot alive.
476
+ completed: completionEdge || completed || entry.completed === true ? true : undefined,
477
+ pendingKind,
478
+ // A new turn clears the failure recorded for the previous one.
479
+ error: running ? undefined : entry.error,
480
+ })
481
+ }
482
+
483
+ previous.set(sessionId, { running, completed, pendingKind })
484
+ }
485
+
486
+ for (const sessionId of [...attention.keys()]) if (!seen.has(sessionId)) attention.delete(sessionId)
487
+ for (const sessionId of [...previous.keys()]) if (!seen.has(sessionId)) {
488
+ previous.delete(sessionId)
489
+ turnStartedAt.delete(sessionId)
490
+ }
491
+ applyBadge()
492
+ }
493
+
494
+ /** A live agent failure: same banner + badge treatment as the three status facts. */
495
+ function onAgentError(sessionId, message) {
496
+ if (typeof sessionId !== "string" || sessionId === "") return
497
+ const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
498
+ const summary = summaries[sessionId]
499
+ if (preferences.excludeSubagents && isSubagent(summary)) return
500
+ if (isLooking(summary)) return
501
+ const table = copy()
502
+ const raw = typeof message === "string" ? message.trim() : ""
503
+ const text = bodyText(raw, preferences.maxBodyChars)
504
+ const before = attention.get(sessionId) ?? {}
505
+ turnStartedAt.delete(sessionId)
506
+ setAttention(sessionId, { error: text === "" ? "中断" : text })
507
+ if (preferences.notifyError && before.error !== text) {
508
+ const title = preferences.titleError || table.errorTitle
509
+ const body = bodyText(table.error(sessionName(summary, preferences.maxBodyChars), text), preferences.maxBodyChars)
510
+ notify("error", sessionId, title, body)
511
+ }
512
+ applyBadge()
513
+ }
514
+
515
+ ctx.effect(() => {
516
+ const offStatus = ctx.uiSession.sessionStatus.subscribe(sync)
517
+ const offList = ctx.sessions.list.subscribe(sync)
518
+ sync()
519
+ return () => {
520
+ offStatus()
521
+ offList()
522
+ }
523
+ }, "dsh-plugin-desktop-notify: status watch")
524
+
525
+ /*
526
+ * Focus and visibility are the half the host cannot see: a window in the
527
+ * background still shows a session in its main view, so `completionUnread`
528
+ * stays false. Re-evaluate on every focus edge so a banner appears when the
529
+ * app is in the background and the badge clears as soon as you look.
530
+ */
531
+ ctx.effect(() => {
532
+ const reevaluate = () => sync()
533
+ const bindings = []
534
+ const bind = (target, names) => {
535
+ try {
536
+ for (const name of names) target.addEventListener(name, reevaluate)
537
+ bindings.push([target, names])
538
+ } catch {
539
+ /* a target without listeners simply keeps the last known state */
540
+ }
541
+ }
542
+ bind(window, ["focus", "blur"])
543
+ bind(document, ["visibilitychange"])
544
+ return () => {
545
+ for (const [target, names] of bindings) {
546
+ try {
547
+ for (const name of names) target.removeEventListener(name, reevaluate)
548
+ } catch {
549
+ /* already detached */
550
+ }
551
+ }
552
+ }
553
+ }, "dsh-plugin-desktop-notify: focus watch")
554
+
555
+ ctx.effect(() => {
556
+ let remote
557
+ try {
558
+ remote = ctx.get === undefined ? ctx.remote : ctx.get("remote")
559
+ } catch {
560
+ remote = undefined
561
+ }
562
+ if (remote === undefined || typeof remote.$on !== "function") return
563
+ const off = remote.$on("api-session/error", onAgentError)
564
+ return () => {
565
+ if (typeof off === "function") off()
566
+ }
567
+ }, "dsh-plugin-desktop-notify: failure watch")
568
+
569
+ ctx.effect(() => () => {
570
+ try {
571
+ if (canBadge()) void navigator.clearAppBadge()
572
+ } catch {
573
+ /* nothing to clear */
574
+ }
575
+ }, "dsh-plugin-desktop-notify: badge release")
576
+
577
+ /*
578
+ * Diagnostic surface for DevTools (⌘⌥I):
579
+ * __DSH_DESKTOP_NOTIFY__.capabilities() — what this renderer supports
580
+ * __DSH_DESKTOP_NOTIFY__.state() — live attention + badge
581
+ * __DSH_DESKTOP_NOTIFY__.simulateComplete() — banner + badge, end to end
582
+ * __DSH_DESKTOP_NOTIFY__.reset() — undo the simulation
583
+ */
584
+ const diagnostics = {
585
+ version: VERSION,
586
+ capabilities() {
587
+ return {
588
+ platform: (() => {
589
+ try {
590
+ return navigator.userAgentData?.platform ?? navigator.platform ?? "unknown"
591
+ } catch {
592
+ return "unknown"
593
+ }
594
+ })(),
595
+ notification: typeof Notification === "undefined" ? "unavailable" : Notification.permission,
596
+ badge: canBadge(),
597
+ remote: (() => {
598
+ try {
599
+ return ctx.get === undefined ? typeof ctx.remote : typeof ctx.get("remote")
600
+ } catch {
601
+ return "unavailable"
602
+ }
603
+ })(),
604
+ workspace: (() => {
605
+ try {
606
+ if (typeof ctx.uiWorkspace?.openSession === "function") return "available"
607
+ return "missing"
608
+ } catch (error) {
609
+ return `unavailable: ${String(error)}`
610
+ }
611
+ })(),
612
+ preferences,
613
+ }
614
+ },
615
+ state() {
616
+ return {
617
+ badge: lastBadge ?? 0,
618
+ attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
619
+ tracked: [...previous.keys()],
620
+ lastNotify: lastNotify ?? null,
621
+ lastClick: lastClick ?? null,
622
+ }
623
+ },
624
+ simulateComplete(sessionId = "desktop-notify-simulation") {
625
+ const table = copy()
626
+ setAttention(sessionId, { completed: true })
627
+ notify("complete", sessionId, preferences.titleComplete || table.completeTitle, table.complete("", durationText(12000)))
628
+ applyBadge()
629
+ return this.state()
630
+ },
631
+ reset() {
632
+ attention.clear()
633
+ lastBadge = undefined
634
+ applyBadge()
635
+ return this.state()
636
+ },
637
+ testBadge(count = 3) {
638
+ if (!canBadge()) return "navigator.setAppBadge 不可用"
639
+ try {
640
+ void navigator.setAppBadge(count)
641
+ return `ok: ${count}`
642
+ } catch (error) {
643
+ return String(error)
644
+ }
645
+ },
646
+ testNotification() {
647
+ if (!canNotify()) {
648
+ return `Notification 不可用(${typeof Notification === "undefined" ? "undefined" : Notification.permission})`
649
+ }
650
+ try {
651
+ const probe = new Notification("DSH 通知自检", { body: "这条来自插件自检,不是任务通知" })
652
+ probe.onclick = () => window.focus()
653
+ return "ok"
654
+ } catch (error) {
655
+ return String(error)
656
+ }
657
+ },
658
+ /**
659
+ * One-line support report: version, capabilities, live state, and what the
660
+ * last banner click actually did. Copied to the clipboard when possible.
661
+ */
662
+ report() {
663
+ const payload = {
664
+ version: VERSION,
665
+ capabilities: this.capabilities(),
666
+ state: this.state(),
667
+ window: { focus: windowFocused(), visible: windowVisible(), visibility: (() => {
668
+ try {
669
+ return typeof document === "undefined" ? "unknown" : document.visibilityState ?? "unknown"
670
+ } catch {
671
+ return "unknown"
672
+ }
673
+ })() },
674
+ }
675
+ const text = JSON.stringify(payload, null, 2)
676
+ try {
677
+ navigator.clipboard?.writeText?.(text)?.catch?.(() => {})
678
+ } catch {
679
+ /* clipboard is optional */
680
+ }
681
+ return text
682
+ },
683
+ /** Try to open a session directly, the same way a banner click does. */
684
+ openSession(sessionId) {
685
+ const result = openSessionTarget(String(sessionId ?? ""))
686
+ try {
687
+ window.focus()
688
+ } catch {
689
+ /* focus is best effort */
690
+ }
691
+ return result
692
+ },
693
+ /** Fire the plugin's real delivery path (tag + silent), so its options can be compared. */
694
+ testNotifyPath() {
695
+ notify("probe", "desktop-notify-probe", "DSH 通知自检(插件路径)", "这条走插件真实的 notify(),带 tag/silent 参数")
696
+ return lastNotify ?? null
697
+ },
698
+ }
699
+ try {
700
+ window.__DSH_DESKTOP_NOTIFY__ = diagnostics
701
+ console.info(`[desktop-notify] loaded — ${JSON.stringify(diagnostics.capabilities())}`)
702
+ } catch {
703
+ /* a locked-down window simply leaves the diagnostics unset */
704
+ }
705
+ }
706
+
707
+ return { inject, apply }
708
+ },
709
+ })