dsh-notify-yimit 0.1.2 → 0.1.3

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 CHANGED
@@ -1,790 +1,1128 @@
1
- /**
2
- * dsh-notify-yimit 浏览器端 bundle(单文件,经 __ModuleLoader__ 加载,零构建)。
3
- *
4
- * 提供两块界面:
5
- * - settings.section「通知」:插件总开关、通知方式三选一(system 系统通知 /
6
- * custom 自定义样式 / off 关闭,默认关闭)、自定义通知的最大同时显示数、显示
7
- * 时长、背景色与文字色,以及系统通知权限申请;
8
- * - shell.overlay:自定义样式通知浮层(右上角堆叠,含标题/内容/忽略/跳转会话
9
- * 按钮);系统通知经 Web Notification API 弹出,点击通知跳转到对应会话。
10
- *
11
- * 数据通道:
12
- * - remote.notify.getState / updateConfig / ackEvents(Typert RPC,250ms 轮询);
13
- * - ctx.sessions.open(id) 跳转会话。
14
- * 样式全部使用 --dsw-* 主题变量(自定义通知的背景/文字色为用户配置)。
15
- */
16
-
17
- window.__ModuleLoader__.load({
18
- id: 'dsh-notify-yimit',
19
- factory: (require) => {
20
- var module = { exports: {} }
21
- var exports = module.exports
22
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
23
-
24
- const React = require('react')
25
-
26
- // ── 样式 ────────────────────────────────────────────────────────────────
27
-
28
- const css = [
29
- '/* dsh-notify-yimit: 设置页与自定义通知浮层 */',
30
- // 设置页
31
- '.dn-root{display:flex;flex-direction:column;gap:20px;padding:4px 2px 24px;font-size:13px;color:var(--dsw-alias-label-primary)}',
32
- '.dn-block{display:flex;flex-direction:column;gap:10px}',
33
- '.dn-title{font-size:13px;font-weight:600;margin:0}',
34
- '.dn-note{font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary);margin:0}',
35
- '.dn-row{display:flex;align-items:center;gap:10px}',
36
- '.dn-label{font-size:13px;color:var(--dsw-alias-label-secondary);flex:none}',
37
- // 总开关:关闭态把手用次要文字色,深浅主题下都与轨道保持清晰对比
38
- '.dn-switch{position:relative;width:38px;height:22px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;padding:0;flex:none;transition:background .15s ease,border-color .15s ease}',
39
- '.dn-switch[data-on="true"]{background:var(--dsw-alias-state-business-primary);border-color:transparent}',
40
- '.dn-switch::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-secondary);transition:transform .15s ease,background .15s ease}',
41
- '.dn-switch[data-on="true"]::after{transform:translateX(16px);background:var(--dsw-alias-label-primary-inverted)}',
42
- // 分段控制器(通知方式三选一)
43
- '.dn-seg{display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);max-width:520px}',
44
- '.dn-seg-btn{flex:1;font:inherit;font-size:13px;color:var(--dsw-alias-label-secondary);border:0;border-radius:8px;padding:6px 10px;cursor:pointer;background:transparent;transition:background .15s ease,color .15s ease;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
45
- '.dn-seg-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
46
- '.dn-seg-btn[data-on="true"]{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv1)}',
47
- '.dn-seg-btn:disabled{opacity:.45;cursor:not-allowed}',
48
- '.dn-seg-btn:disabled:hover{background:transparent;color:var(--dsw-alias-label-secondary)}',
49
- // 折叠区块(自定义通知设置显隐过渡动画)
50
- '.dn-collapse{display:grid;grid-template-rows:1fr;opacity:1;transition:grid-template-rows .24s ease,opacity .2s ease}',
51
- '.dn-collapse[data-open="false"]{grid-template-rows:0fr;opacity:0}',
52
- '.dn-collapse>.dn-collapse-body{overflow:hidden;min-height:0}',
53
- '.dn-collapse>.dn-collapse-body>.dn-block{min-width:0}',
54
- // 每种通知类型的颜色配置列表
55
- '.dn-kind-list{display:flex;flex-direction:column;gap:8px}',
56
- '.dn-kind-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}',
57
- '.dn-kind-swatch{flex:none;width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)}',
58
- '.dn-kind-name{flex:1;font-size:13px;font-weight:600;min-width:0}',
59
- '.dn-kind-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}',
60
- '.dn-input{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;outline:none;max-width:120px}',
61
- '.dn-input:focus{border-color:var(--dsw-alias-state-business-primary)}',
62
- '.dn-input[type=color]{padding:2px 4px;width:48px;height:28px;cursor:pointer;max-width:none}',
63
- '.dn-input:disabled{opacity:.45;cursor:not-allowed}',
64
- '.dn-select{max-width:260px;cursor:pointer}',
65
- '.dn-color-preview{font-size:12px;color:var(--dsw-alias-label-tertiary);font-family:var(--dsw-font-mono)}',
66
- '.dn-btn{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-button-elevated-fill);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 14px;cursor:pointer}',
67
- '.dn-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
68
- '.dn-btn.primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted)}',
69
- '.dn-btn.primary:hover{opacity:0.88}',
70
- '.dn-btn:disabled{opacity:.5;cursor:not-allowed}',
71
- '.dn-msg{font-size:12px;line-height:18px;padding:8px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}',
72
- '.dn-msg.ok{color:var(--dsw-alias-state-success-primary)}',
73
- '.dn-msg.err{color:var(--dsw-alias-state-error-primary)}',
74
- '.dn-divider{height:1px;background:var(--dsw-alias-border-l1)}',
75
- // 自定义通知浮层(背景/文字色来自配置,经 --dn-bg / --dn-fg 注入)
76
- '.dn-layer{position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;align-items:flex-end;gap:10px;pointer-events:none}',
77
- '.dn-card{pointer-events:auto;width:320px;max-width:calc(100vw - 32px);border-radius:12px;box-shadow:var(--dsw-shadow-lv3);border:1px solid var(--dsw-alias-border-l2);background:var(--dn-bg,#20272f);color:var(--dn-fg,#e6edf3);overflow:hidden;animation:dn-in .18s ease}',
78
- '@keyframes dn-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}',
79
- '.dn-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px 6px}',
80
- '.dn-card-title{font-size:13px;font-weight:600;line-height:20px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
81
- '.dn-card-kind{flex:none;font-size:11px;line-height:18px;border-radius:999px;padding:0 8px;border:1px solid currentColor;opacity:.85}',
82
- '.dn-card-body{padding:0 12px 8px;font-size:13px;line-height:20px;word-break:break-word;max-height:120px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical}',
83
- '.dn-card-actions{display:flex;align-items:center;gap:6px;padding:6px 12px 10px}',
84
- '.dn-card-btn{font:inherit;font-size:12px;line-height:18px;border-radius:6px;padding:4px 10px;cursor:pointer;border:1px solid color-mix(in srgb,currentColor 35%,transparent);background:transparent;color:inherit}',
85
- '.dn-card-btn:hover{background:color-mix(in srgb,currentColor 12%,transparent)}',
86
- '.dn-card-btn.primary{background:color-mix(in srgb,currentColor 18%,transparent)}',
87
- '.dn-card-bar{height:2px;background:color-mix(in srgb,currentColor 25%,transparent);transform-origin:left}',
88
- ].join('\n')
89
- const cssTagId = 'dsh-notify-yimit/client.css'
90
- if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(cssTagId) + ']') === null) {
91
- const tag = document.createElement('style')
92
- tag.dataset.plugin = 'dsh-notify-yimit'
93
- tag.dataset.pluginCss = cssTagId
94
- tag.textContent = css
95
- document.head.appendChild(tag)
96
- }
97
-
98
- // ── 多语言(中/英) ──────────────────────────────────────────────────────
99
-
100
- const MESSAGES = {
101
- zh: {
102
- sectionLabel: '通知',
103
- enabledLabel: '启用通知插件',
104
- enabledNote: '关闭后不产生任何通知(设置仍会保存)。',
105
- modeLabel: '通知方式',
106
- modeSystem: 'Windows 系统通知',
107
- modeCustom: '插件自定义样式通知',
108
- modeOff: '关闭',
109
- modeNote: '系统通知由浏览器弹出、可在系统通知中心查看;自定义通知为桌面右下角浮窗(不依赖浏览器页面)。',
110
- browserLabel: '跳转会话浏览器',
111
- browserDefault: '默认浏览器(系统)',
112
- browserNote: '「跳转会话」始终打开浏览器并定位到该会话;可选指定浏览器打开(默认=系统默认浏览器)。',
113
- customTitle: '自定义通知设置',
114
- colorsTitle: '每种通知的样式',
115
- colorBg: '背景',
116
- colorFg: '文字',
117
- maxVisibleLabel: '同时最多显示数量',
118
- durationLabel: '通知显示时长(秒)',
119
- bgColorLabel: '背景颜色',
120
- textColorLabel: '文字颜色',
121
- systemTitle: '系统通知',
122
- permissionGranted: '已获得系统通知权限',
123
- permissionDenied: '系统通知权限被拒绝,请在浏览器设置中允许本页面通知',
124
- permissionDefault: '系统通知需要浏览器通知权限',
125
- requestPermission: '申请通知权限',
126
- triggerNote: '触发场景:任务完成、任务出错、运行中(实时内容)、等待审批、等待回答。通知标题为对话标题;点击通知或跳转按钮可打开对应会话。',
127
- saveHint: '所有设置修改后自动保存。',
128
- saved: '已保存',
129
- saveFailed: '保存失败:{message}',
130
- loading: '正在读取配置…',
131
- rpcFailed: '读取配置失败:{message}',
132
- // 通知卡片
133
- kindCompleted: '已完成',
134
- kindError: '出错',
135
- kindRunning: '运行中',
136
- kindApproval: '待审批',
137
- kindQuestion: '待回答',
138
- ignore: '忽略',
139
- jump: '跳转会话',
140
- },
141
- en: {
142
- sectionLabel: 'Notifications',
143
- enabledLabel: 'Enable notification plugin',
144
- enabledNote: 'When disabled, no notifications are produced (settings are still saved).',
145
- modeLabel: 'Notification style',
146
- modeSystem: 'Windows system notifications',
147
- modeCustom: 'Custom in-app notifications',
148
- modeOff: 'Off',
149
- modeNote: 'System notifications are shown by the browser and live in the system notification center; custom notifications are desktop toasts at the bottom-right (independent of the browser page).',
150
- browserLabel: 'Browser for jumping to a session',
151
- browserDefault: 'Default browser (system)',
152
- browserNote: '"Jump to session" always opens the browser and navigates to the session; optionally pick a specific browser (default = system default).',
153
- customTitle: 'Custom notification settings',
154
- colorsTitle: 'Style per notification type',
155
- colorBg: 'BG',
156
- colorFg: 'FG',
157
- maxVisibleLabel: 'Max simultaneously visible',
158
- durationLabel: 'Display duration (seconds)',
159
- bgColorLabel: 'Background color',
160
- textColorLabel: 'Text color',
161
- systemTitle: 'System notifications',
162
- permissionGranted: 'System notification permission granted',
163
- permissionDenied: 'System notification permission denied — allow notifications for this site in your browser settings',
164
- permissionDefault: 'System notifications need browser notification permission',
165
- requestPermission: 'Request permission',
166
- triggerNote: 'Triggers: task completed, task failed, running (live activity), awaiting approval, awaiting answer. The title is the conversation title; clicking the notification or the jump button opens the session.',
167
- saveHint: 'All settings changes are auto-saved.',
168
- saved: 'Saved',
169
- saveFailed: 'Save failed: {message}',
170
- loading: 'Loading config…',
171
- rpcFailed: 'Failed to load config: {message}',
172
- kindCompleted: 'Done',
173
- kindError: 'Failed',
174
- kindRunning: 'Running',
175
- kindApproval: 'Approval',
176
- kindQuestion: 'Question',
177
- ignore: 'Ignore',
178
- jump: 'Open',
179
- },
180
- }
181
-
182
- function detectBrowserLocale() {
183
- const lang = typeof navigator !== 'undefined' && typeof navigator.language === 'string' ? navigator.language : ''
184
- return lang.toLowerCase().startsWith('zh') ? 'zh' : 'en'
185
- }
186
- function makeT(locale) {
187
- const dict = locale === 'zh' ? MESSAGES.zh : MESSAGES.en
188
- return (key, vars) => {
189
- let text = dict[key] ?? MESSAGES.en[key] ?? key
190
- if (vars) for (const name of Object.keys(vars)) text = text.split('{' + name + '}').join(String(vars[name]))
191
- return text
192
- }
193
- }
194
-
195
- // ── 线路数据校验(与服务端 zod 清单对应,宽松校验) ──────────────────────
196
-
197
- function fail(path) {
198
- throw new Error('dsh-notify-yimit: 服务端数据非法 (' + path + ')')
199
- }
200
- function needStr(v, path) {
201
- if (typeof v !== 'string') fail(path)
202
- return v
203
- }
204
- function needNum(v, path) {
205
- if (typeof v !== 'number' || !Number.isFinite(v)) fail(path)
206
- return v
207
- }
208
- function needBool(v, path) {
209
- if (typeof v !== 'boolean') fail(path)
210
- return v
211
- }
212
- function parseConfig(v, path) {
213
- if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
214
- const kindColors = (key, fallbackBg, fallbackFg) => {
215
- const entry = v.colors !== null && typeof v.colors === 'object' && !Array.isArray(v.colors) ? v.colors[key] : undefined
216
- return {
217
- bg: typeof entry?.bg === 'string' ? entry.bg : fallbackBg,
218
- fg: typeof entry?.fg === 'string' ? entry.fg : fallbackFg,
219
- }
220
- }
221
- const topBg = typeof v.bgColor === 'string' ? v.bgColor : '#20272f'
222
- const topFg = typeof v.textColor === 'string' ? v.textColor : '#e6edf3'
223
- return {
224
- enabled: v.enabled === true,
225
- mode: v.mode === 'system' || v.mode === 'custom' || v.mode === 'off' ? v.mode : 'off',
226
- maxVisible: typeof v.maxVisible === 'number' ? v.maxVisible : 3,
227
- durationMs: typeof v.durationMs === 'number' ? v.durationMs : 8000,
228
- browser: typeof v.browser === 'string' ? v.browser : '',
229
- bgColor: topBg,
230
- textColor: topFg,
231
- colors: {
232
- completed: kindColors('completed', topBg, topFg),
233
- error: kindColors('error', topBg, topFg),
234
- running: kindColors('running', topBg, topFg),
235
- approval: kindColors('approval', topBg, topFg),
236
- question: kindColors('question', topBg, topFg),
237
- },
238
- }
239
- }
240
- function parseEvent(v, path) {
241
- if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
242
- return {
243
- id: needStr(v.id, path + '.id'),
244
- sessionId: needStr(v.sessionId, path + '.sessionId'),
245
- title: typeof v.title === 'string' ? v.title : '',
246
- kind: v.kind === 'completed' || v.kind === 'error' || v.kind === 'running' || v.kind === 'approval' || v.kind === 'question' ? v.kind : 'running',
247
- text: needStr(v.text, path + '.text'),
248
- activity: typeof v.activity === 'string' ? v.activity : '',
249
- at: needNum(v.at, path + '.at'),
250
- }
251
- }
252
- function parseState(v, path) {
253
- if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
254
- return {
255
- config: parseConfig(v.config, path + '.config'),
256
- events: Array.isArray(v.events) ? v.events.map((e, i) => parseEvent(e, path + '.events[' + i + ']')) : [],
257
- }
258
- }
259
- const codecOf = (parse) => ({ parse })
260
- const stateCodec = codecOf((v) => parseState(v, 'state'))
261
- const configCodec = codecOf((v) => parseConfig(v, 'config'))
262
- const ackCodec = codecOf((v) => {
263
- if (v === null || typeof v !== 'object') fail('ack')
264
- return { ok: v.ok === true }
265
- })
266
- const patchCodec = codecOf((v) => {
267
- if (v === null || typeof v !== 'object' || Array.isArray(v)) fail('patch')
268
- return v
269
- })
270
- const browsersCodec = codecOf((v) => {
271
- if (v === null || typeof v !== 'object' || !Array.isArray(v.browsers)) fail('browsers')
272
- return {
273
- browsers: v.browsers.map((b, i) => ({
274
- id: typeof b?.id === 'string' ? b.id : String(i),
275
- name: typeof b?.name === 'string' ? b.name : '',
276
- path: typeof b?.path === 'string' ? b.path : '',
277
- })),
278
- }
279
- })
280
-
281
- // ── RPC 贡献(与服务端 ./typert 清单一一对应) ───────────────────────────
282
-
283
- const CONTRIBUTION = {
284
- package: 'dsh-notify-yimit',
285
- descriptors: [
286
- {
287
- id: 'dsh-notify-yimit#notify/getState', service: 'notify', namespace: 'notify', method: 'getState',
288
- invocation: { kind: 'direct' }, parameters: [],
289
- result: { mode: 'strict', typeSymbol: 'dsh-notify-yimit#NotifyState', schema: stateCodec },
290
- },
291
- {
292
- id: 'dsh-notify-yimit#notify/updateConfig', service: 'notify', namespace: 'notify', method: 'updateConfig',
293
- invocation: { kind: 'direct' },
294
- parameters: [{ name: 'patch', wire: 'patch', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-notify-yimit#NotifyConfigPatch', schema: patchCodec } }],
295
- result: { mode: 'strict', typeSymbol: 'dsh-notify-yimit#NotifyConfig', schema: configCodec },
296
- },
297
- {
298
- id: 'dsh-notify-yimit#notify/ackEvents', service: 'notify', namespace: 'notify', method: 'ackEvents',
299
- invocation: { kind: 'direct' },
300
- parameters: [{ name: 'ids', wire: 'ids', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-notify-yimit#EventIds', schema: codecOf((v) => Array.isArray(v) ? v.map((x) => String(x)) : []) } }],
301
- result: { mode: 'strict', typeSymbol: 'dsh-notify-yimit#AckResult', schema: ackCodec },
302
- },
303
- {
304
- id: 'dsh-notify-yimit#notify/listBrowsers', service: 'notify', namespace: 'notify', method: 'listBrowsers',
305
- invocation: { kind: 'direct' }, parameters: [],
306
- result: { mode: 'strict', typeSymbol: 'dsh-notify-yimit#BrowserList', schema: browsersCodec },
307
- },
308
- ],
309
- }
310
-
311
- // ── 客户端状态存储 ──────────────────────────────────────────────────────
312
-
313
- function makeStore(initial) {
314
- let snapshot = initial
315
- const listeners = new Set()
316
- return {
317
- getSnapshot: () => snapshot,
318
- subscribe: (fn) => {
319
- listeners.add(fn)
320
- return () => { listeners.delete(fn) }
321
- },
322
- set: (next) => {
323
- if (next === snapshot) return
324
- snapshot = next
325
- for (const fn of [...listeners]) fn()
326
- },
327
- }
328
- }
329
-
330
- const { createElement: el, Fragment, useState, useEffect, useRef, useCallback } = React
331
-
332
- // ── 通知卡片(kind 徽标文案) ────────────────────────────────────────────
333
-
334
- function kindLabel(kind, t) {
335
- switch (kind) {
336
- case 'completed': return t('kindCompleted')
337
- case 'error': return t('kindError')
338
- case 'approval': return t('kindApproval')
339
- case 'question': return t('kindQuestion')
340
- default: return t('kindRunning')
341
- }
342
- }
343
-
344
- // ── 通知调度器(shell.overlay) ────────────────────────────────────────
345
-
346
- /**
347
- * 事件调度组件:消费 store 中的 events 并回执。
348
- * - system 模式:Web Notification(系统通知中心,点击跳转会话);
349
- * - custom 模式:通知由宿主插件以桌面浮窗显示(不依赖浏览器页面),
350
- * 此处仅消费回执,不渲染任何页面内浮层;
351
- * - off / 未启用:直接消费。
352
- * 组件始终返回 null(无页面内 UI)。
353
- */
354
- function NotifyOverlay(props) {
355
- const notifyStore = props.useNotify ? props.useNotify((s) => s) : undefined
356
- const api = props.api
357
- const sessions = props.sessions
358
- const seenRef = useRef(new Set())
359
-
360
- const config = notifyStore?.config
361
- const events = notifyStore?.events
362
-
363
- const openSession = useCallback((sessionId) => {
364
- try {
365
- if (typeof sessions?.open === 'function') sessions.open(sessionId)
366
- } catch { /* 跳转失败静默 */ }
367
- }, [sessions])
368
-
369
- // 事件分发:新事件 → 系统通知 / 直接消费
370
- useEffect(() => {
371
- if (!config || !Array.isArray(events)) return
372
- const fresh = events.filter((e) => !seenRef.current.has(e.id))
373
- if (fresh.length === 0) return
374
- for (const e of fresh) seenRef.current.add(e.id)
375
- // 消费即回执(任何模式下都避免积压)。
376
- const ack = () => {
377
- try { api?.ackEvents(fresh.map((e) => e.id)) } catch { /* 静默 */ }
378
- }
379
- if (config.enabled !== true || config.mode === 'off' || config.mode === 'custom') {
380
- // custom:宿主桌面浮窗已显示(host 直接消费,队列通常为空);此处防御性回执。
381
- ack()
382
- return
383
- }
384
- // system 模式:浏览器原生通知。
385
- if (typeof Notification === 'undefined' || Notification.permission === 'denied') { ack(); return }
386
- const request = Notification.permission === 'granted'
387
- ? Promise.resolve('granted')
388
- : Notification.requestPermission()
389
- request.then((permission) => {
390
- if (permission !== 'granted') { ack(); return }
391
- for (const e of fresh) {
392
- let n
393
- try {
394
- n = new Notification(e.title && e.title.length > 0 ? e.title : e.sessionId, {
395
- body: e.text,
396
- tag: `${e.sessionId}:${e.kind}`,
397
- silent: false,
398
- })
399
- } catch {
400
- continue
401
- }
402
- n.onclick = () => {
403
- try {
404
- window.focus()
405
- openSession(e.sessionId)
406
- n.close()
407
- } catch { /* 静默 */ }
408
- }
409
- }
410
- ack()
411
- }).catch(() => ack())
412
- }, [events, config, api, openSession])
413
-
414
- return null
415
- }
416
-
417
- // ── 设置页「通知」 ─────────────────────────────────────────────────────
418
-
419
- /** 分段控制器:通知方式三选一(符合 DSH 主题的分段样式)。 */
420
- function Segmented(props) {
421
- const { value, options, onChange, disabled } = props
422
- return el('div', { className: 'dn-seg', role: 'tablist' },
423
- options.map((entry) => el('button', {
424
- key: entry[0],
425
- type: 'button',
426
- role: 'tab',
427
- 'aria-selected': String(value === entry[0]),
428
- className: 'dn-seg-btn',
429
- 'data-on': String(value === entry[0]),
430
- disabled: disabled === true,
431
- onClick: () => { if (value !== entry[0]) onChange(entry[0]) },
432
- }, entry[1])))
433
- }
434
-
435
- /** 通知类型顺序(颜色列表行序)。 */
436
- const KIND_ORDER = ['completed', 'error', 'running', 'approval', 'question']
437
-
438
- function NotifySection(props) {
439
- const notifyStore = props.useNotify ? props.useNotify((s) => s) : undefined
440
- const api = props.api
441
- const t = makeT(detectBrowserLocale())
442
- const state = notifyStore
443
- const config = state?.config
444
- const [msg, setMsg] = useState(null)
445
- const [saving, setSaving] = useState(false)
446
- const [permission, setPermission] = useState(
447
- typeof Notification !== 'undefined' ? Notification.permission : 'unsupported',
448
- )
449
-
450
- const save = useCallback(async (patch) => {
451
- if (!api?.updateConfig) return
452
- setSaving(true)
453
- setMsg(null)
454
- try {
455
- await api.updateConfig(patch)
456
- setMsg({ kind: 'ok', text: t('saved') })
457
- } catch (error) {
458
- setMsg({ kind: 'err', text: t('saveFailed', { message: error?.message ?? String(error) }) })
459
- } finally {
460
- setSaving(false)
461
- }
462
- }, [api, t])
463
-
464
- const requestPerm = useCallback(async () => {
465
- if (typeof Notification === 'undefined' || Notification.permission === 'granted') {
466
- setPermission(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported')
467
- return
468
- }
469
- try {
470
- const result = await Notification.requestPermission()
471
- setPermission(result)
472
- } catch {
473
- setPermission('denied')
474
- }
475
- }, [])
476
-
477
- if (!config) {
478
- return el('div', { className: 'dn-root' }, el('div', { className: 'dn-note' }, t('loading')))
479
- }
480
-
481
- const locale = detectBrowserLocale()
482
- const tt = makeT(locale)
483
- // 关闭状态下,除总开关外的所有控件禁用。
484
- const controlsDisabled = !config.enabled
485
-
486
- // 每种通知类型一行:色块 + 名称 + 背景/文字取色器(colors 深合并保存)。
487
- const kindRows = KIND_ORDER.map((kind) => {
488
- const entry = config.colors?.[kind] ?? {}
489
- const bg = entry.bg || config.bgColor || '#20272f'
490
- const fg = entry.fg || config.textColor || '#e6edf3'
491
- return el('div', { key: kind, className: 'dn-kind-row' },
492
- el('span', { className: 'dn-kind-swatch', style: { background: bg } }),
493
- el('span', { className: 'dn-kind-name' }, kindLabel(kind, t)),
494
- el('label', { className: 'dn-kind-field' },
495
- el('input', {
496
- type: 'color',
497
- className: 'dn-input',
498
- title: tt('colorBg'),
499
- value: /^#[0-9a-fA-F]{6}$/.test(bg) ? bg : '#20272f',
500
- disabled: controlsDisabled,
501
- onChange: (e) => save({ colors: { [kind]: { ...entry, bg: e.target.value } } }),
502
- }),
503
- tt('colorBg')),
504
- el('label', { className: 'dn-kind-field' },
505
- el('input', {
506
- type: 'color',
507
- className: 'dn-input',
508
- title: tt('colorFg'),
509
- value: /^#[0-9a-fA-F]{6}$/.test(fg) ? fg : '#e6edf3',
510
- disabled: controlsDisabled,
511
- onChange: (e) => save({ colors: { [kind]: { ...entry, fg: e.target.value } } }),
512
- }),
513
- tt('colorFg')))
514
- })
515
-
516
- // 自定义通知设置区块:始终渲染以支持显隐过渡动画(折叠/淡出)。
517
- const customOpen = config.mode === 'custom'
518
-
519
- return el('div', { className: 'dn-root' },
520
- // 总开关
521
- el('div', { className: 'dn-block' },
522
- el('div', { className: 'dn-row' },
523
- el('button', {
524
- type: 'button',
525
- role: 'switch',
526
- 'aria-checked': config.enabled,
527
- className: 'dn-switch',
528
- 'data-on': String(config.enabled),
529
- onClick: () => save({ enabled: !config.enabled }),
530
- }),
531
- el('span', { className: 'dn-label' }, t('enabledLabel'))),
532
- el('p', { className: 'dn-note' }, t('enabledNote'))),
533
-
534
- el('div', { className: 'dn-divider' }),
535
-
536
- // 通知方式(分段控制器)
537
- el('div', { className: 'dn-block' },
538
- el('p', { className: 'dn-title' }, t('modeLabel')),
539
- el(Segmented, {
540
- value: config.mode,
541
- disabled: controlsDisabled,
542
- onChange: (mode) => save({ mode }),
543
- options: [
544
- ['system', t('modeSystem')],
545
- ['custom', t('modeCustom')],
546
- ['off', t('modeOff')],
547
- ],
548
- }),
549
- el('p', { className: 'dn-note' }, t('modeNote'))),
550
-
551
- // 跳转会话浏览器选择器(检测本机已安装浏览器;空 = 系统默认)。
552
- // 「跳转会话」始终打开浏览器(功能恒开启),故不再提供开关;选择器在通知方式
553
- // 开启时(system / custom)显示,插件总开关关闭时禁用。
554
- config.mode === 'system' || config.mode === 'custom'
555
- ? el('div', { className: 'dn-block' },
556
- el('div', { className: 'dn-row' },
557
- el('span', { className: 'dn-label' }, t('browserLabel')),
558
- el('select', {
559
- className: 'dn-input dn-select',
560
- value: config.browser || '',
561
- disabled: controlsDisabled,
562
- onChange: (e) => save({ browser: e.target.value }),
563
- },
564
- el('option', { value: '' }, t('browserDefault')),
565
- (state?.browsers ?? []).map((b) => el('option', { key: b.id, value: b.path }, b.name)))),
566
- el('p', { className: 'dn-note' }, t('browserNote')))
567
- : null,
568
-
569
- el('div', { className: 'dn-divider' }),
570
-
571
- // 系统通知:权限
572
- config.mode === 'system'
573
- ? el('div', { className: 'dn-block' },
574
- el('p', { className: 'dn-title' }, t('systemTitle')),
575
- permission === 'granted'
576
- ? el('div', { className: 'dn-msg ok' }, t('permissionGranted'))
577
- : permission === 'denied'
578
- ? el('div', { className: 'dn-msg err' }, t('permissionDenied'))
579
- : el(Fragment, null,
580
- el('p', { className: 'dn-note' }, t('permissionDefault')),
581
- el('button', {
582
- className: 'dn-btn primary',
583
- disabled: controlsDisabled,
584
- onClick: requestPerm,
585
- }, t('requestPermission'))))
586
- : null,
587
-
588
- // 自定义通知设置(折叠 + 淡出过渡;关闭时可见但不可操作)
589
- el('div', {
590
- className: 'dn-collapse',
591
- 'data-open': String(customOpen),
592
- },
593
- el('div', { className: 'dn-collapse-body' },
594
- el('div', { className: 'dn-block' },
595
- el('p', { className: 'dn-title' }, t('customTitle')),
596
- el('div', { className: 'dn-row' },
597
- el('span', { className: 'dn-label' }, t('maxVisibleLabel')),
598
- el('input', {
599
- type: 'number',
600
- min: 1,
601
- max: 20,
602
- className: 'dn-input',
603
- value: config.maxVisible,
604
- disabled: controlsDisabled,
605
- onChange: (e) => {
606
- const n = Math.max(1, Math.min(20, Math.floor(Number(e.target.value) || 1)))
607
- save({ maxVisible: n })
608
- },
609
- })),
610
- el('div', { className: 'dn-row' },
611
- el('span', { className: 'dn-label' }, t('durationLabel')),
612
- el('input', {
613
- type: 'number',
614
- min: 1,
615
- max: 300,
616
- className: 'dn-input',
617
- value: Math.round(config.durationMs / 1000),
618
- disabled: controlsDisabled,
619
- onChange: (e) => {
620
- const s = Math.max(1, Math.min(300, Math.floor(Number(e.target.value) || 1)))
621
- save({ durationMs: s * 1000 })
622
- },
623
- })),
624
- el('p', { className: 'dn-title' }, t('colorsTitle')),
625
- el('div', { className: 'dn-kind-list' }, kindRows)))),
626
-
627
- el('div', { className: 'dn-divider' }),
628
- el('p', { className: 'dn-note' }, t('triggerNote')),
629
- el('p', { className: 'dn-note' }, t('saveHint')),
630
- msg !== null ? el('div', { className: 'dn-msg ' + msg.kind }, msg.text) : null,
631
- saving ? el('div', { className: 'dn-note' }, '…') : null,
632
- )
633
- }
634
-
635
- // ── 插件主体 ────────────────────────────────────────────────────────────
636
-
637
- const inject = ['remote']
638
-
639
- async function apply(ctx) {
640
- const remote = ctx.remote
641
- if (remote === undefined || typeof remote.$mount !== 'function') return
642
- const unmount = await remote.$mount(CONTRIBUTION)
643
- ctx.effect(() => () => { unmount() }, 'dsh-notify-yimit: remote contribution')
644
- const notify = ctx.get('remote.notify')
645
- if (notify === undefined) return
646
-
647
- const store = makeStore({ config: null, events: [], browsers: [] })
648
-
649
- // 探测本机浏览器(设置页"跳转会话浏览器"选择器数据源)。
650
- const loadBrowsers = async () => {
651
- try {
652
- const result = await notify.listBrowsers()
653
- if (result !== null && typeof result === 'object' && result.ok === true) {
654
- const parsed = browsersCodec.parse(result.value)
655
- const state = store.getSnapshot()
656
- store.set({ config: state.config, events: state.events, browsers: parsed.browsers })
657
- }
658
- } catch {
659
- // 探测失败:选择器仅剩"默认浏览器"。
660
- }
661
- }
662
-
663
- let reloading = false
664
- const reload = async () => {
665
- if (reloading) return
666
- reloading = true
667
- try {
668
- const result = await notify.getState()
669
- if (result !== null && typeof result === 'object' && result.ok === true) {
670
- // 保留 browsers(浏览器列表来自独立 listBrowsers 调用,轮询不得覆盖)。
671
- const prev = store.getSnapshot()
672
- store.set({ ...parseState(result.value, 'state'), browsers: prev.browsers })
673
- }
674
- } catch {
675
- // 传输失败保持旧快照;下次轮询重试。
676
- } finally {
677
- reloading = false
678
- }
679
- }
680
-
681
- const api = {
682
- reload,
683
- updateConfig: async (patch) => {
684
- const result = await notify.updateConfig(patch)
685
- if (result !== null && typeof result === 'object' && result.ok === true) {
686
- // 保留 browsers(浏览器列表来自独立 listBrowsers 调用,配置更新不得覆盖),
687
- // 否则选择器选项会塌缩成只剩"默认浏览器"(改一次选择后消失的 bug)。
688
- const state = store.getSnapshot()
689
- store.set({ config: parseConfig(result.value, 'config'), events: state.events, browsers: state.browsers })
690
- }
691
- return result?.value
692
- },
693
- ackEvents: async (ids) => {
694
- try {
695
- await notify.ackEvents(ids)
696
- } catch {
697
- // 回执失败:事件保留,下次轮询会重新看到(seen 去重避免重复展示)。
698
- }
699
- },
700
- }
701
-
702
- void reload()
703
- void loadBrowsers()
704
- ctx.effect(() => ctx.on('connection/reset', () => { void reload(); void loadBrowsers() }), 'dsh-notify-yimit: reconnect reload')
705
- // system 模式走浏览器通知:250ms 轮询把平均延迟从 ~500ms 压到 ~125ms,感知为"即时"。
706
- const pollTimer = setInterval(() => { void reload() }, 250)
707
- ctx.effect(() => () => { clearInterval(pollTimer) }, 'dsh-notify-yimit: poll timer')
708
- const onVisible = () => { if (document.visibilityState === 'visible') void reload() }
709
- document.addEventListener('visibilitychange', onVisible)
710
- ctx.effect(() => () => { document.removeEventListener('visibilitychange', onVisible) }, 'dsh-notify-yimit: visibility reload')
711
-
712
- const sessions = ctx.get('sessions')
713
- const slots = ctx.get('slots')
714
- if (slots === undefined) return
715
-
716
- // 会话深链接(桌面浮窗"跳转会话"通道):监听 #dsh-notify-yimit/session=<id>。
717
- const openFromHash = () => {
718
- try {
719
- const m = /#dsh-notify-yimit\/session=([^&]+)/.exec(window.location.hash)
720
- if (m !== null && typeof sessions?.open === 'function') {
721
- sessions.open(decodeURIComponent(m[1]))
722
- const clean = window.location.href.split('#')[0]
723
- window.history.replaceState(null, '', clean)
724
- }
725
- } catch { /* 静默 */ }
726
- }
727
- window.addEventListener('hashchange', openFromHash)
728
- openFromHash()
729
- ctx.effect(() => () => window.removeEventListener('hashchange', openFromHash), 'dsh-notify-yimit: hash listener')
730
-
731
- const injected = () => ({ hooks: { notify: store }, api, sessions })
732
- const sectionInjected = () => ({ hooks: { notify: store }, api })
733
-
734
- // 设置页「通知」分节
735
- const sectionActive = { gen: 0, dispose: null }
736
- const registerSection = () => {
737
- if (sectionActive.dispose !== null) { sectionActive.dispose(); sectionActive.dispose = null }
738
- sectionActive.gen += 1
739
- const gen = sectionActive.gen
740
- slots.inject('settings.section', () => {
741
- if (sectionActive.gen !== gen) return
742
- const dispose = slots.register({
743
- name: 'settings.section',
744
- id: 'dsh-notify-yimit',
745
- order: 60,
746
- label: () => makeT(detectBrowserLocale())('sectionLabel'),
747
- inject: sectionInjected,
748
- }, NotifySection)
749
- if (sectionActive.gen !== gen) { dispose(); return }
750
- sectionActive.dispose = dispose
751
- return () => {
752
- if (sectionActive.dispose === dispose) sectionActive.dispose = null
753
- dispose()
754
- }
755
- })
756
- }
757
- registerSection()
758
-
759
- // 全局浮层(自定义通知)
760
- const overlayActive = { gen: 0, dispose: null }
761
- const registerOverlay = () => {
762
- if (overlayActive.dispose !== null) { overlayActive.dispose(); overlayActive.dispose = null }
763
- overlayActive.gen += 1
764
- const gen = overlayActive.gen
765
- slots.inject('shell.overlay', () => {
766
- if (overlayActive.gen !== gen) return
767
- const dispose = slots.register({
768
- name: 'shell.overlay',
769
- id: 'dsh-notify-yimit',
770
- order: 10,
771
- inject: injected,
772
- }, NotifyOverlay)
773
- if (overlayActive.gen !== gen) { dispose(); return }
774
- overlayActive.dispose = dispose
775
- return () => {
776
- if (overlayActive.dispose === dispose) overlayActive.dispose = null
777
- dispose()
778
- }
779
- })
780
- }
781
- registerOverlay()
782
-
783
- return () => {}
784
- }
785
-
786
- exports.apply = apply
787
- exports.inject = inject
788
- return module.exports
789
- },
1
+ /**
2
+ * dsh-notify-yimit 浏览器端 bundle。
3
+ * 语言跟随:接入 `locale` 服务(由 dsh 框架注入),完全对齐 dsh-market 的标准用法;
4
+ * 旧宿主无该服务时降级到 navigator/localStorage 轮询,绝不阻塞启动。
5
+ */
6
+ window.__ModuleLoader__.load({
7
+ id: 'dsh-notify-yimit',
8
+ factory: (require) => {
9
+ var module = {
10
+ exports: {}
11
+ }
12
+ var exports = module.exports
13
+ Object.defineProperty(exports, Symbol.toStringTag, {
14
+ value: 'Module'
15
+ })
16
+
17
+ const React = require('react')
18
+ const {
19
+ createElement: el,
20
+ Fragment,
21
+ useState,
22
+ useEffect,
23
+ useRef,
24
+ useCallback,
25
+ useSyncExternalStore
26
+ } = React
27
+
28
+ const css = [
29
+ '/* dsh-notify-yimit: 设置页与自定义通知浮层 */',
30
+ '.dn-root{display:flex;flex-direction:column;gap:20px;padding:4px 2px 24px;font-size:13px;color:var(--dsw-alias-label-primary)}',
31
+ '.dn-block{display:flex;flex-direction:column;gap:10px}',
32
+ '.dn-title{font-size:13px;font-weight:600;margin:0}',
33
+ '.dn-note{font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary);margin:0}',
34
+ '.dn-row{display:flex;align-items:center;gap:10px}',
35
+ '.dn-label{font-size:13px;color:var(--dsw-alias-label-secondary);flex:none}',
36
+ '.dn-switch{position:relative;width:38px;height:22px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;padding:0;flex:none;transition:background .15s ease,border-color .15s ease}',
37
+ '.dn-switch[data-on="true"]{background:var(--dsw-alias-state-business-primary);border-color:transparent}',
38
+ '.dn-switch::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-secondary);transition:transform .15s ease,background .15s ease}',
39
+ '.dn-switch[data-on="true"]::after{transform:translateX(16px);background:var(--dsw-alias-label-primary-inverted)}',
40
+ '.dn-seg{display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);max-width:520px}',
41
+ '.dn-seg-btn{flex:1;font:inherit;font-size:13px;color:var(--dsw-alias-label-secondary);border:0;border-radius:8px;padding:6px 10px;cursor:pointer;background:transparent;transition:background .15s ease,color .15s ease;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
42
+ '.dn-seg-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
43
+ '.dn-seg-btn[data-on="true"]{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv1)}',
44
+ '.dn-seg-btn:disabled{opacity:.45;cursor:not-allowed}',
45
+ '.dn-seg-btn:disabled:hover{background:transparent;color:var(--dsw-alias-label-secondary)}',
46
+ '.dn-collapse{display:grid;grid-template-rows:1fr;opacity:1;transition:grid-template-rows .24s ease,opacity .2s ease}',
47
+ '.dn-collapse[data-open="false"]{grid-template-rows:0fr;opacity:0}',
48
+ '.dn-collapse>.dn-collapse-body{overflow:hidden;min-height:0}',
49
+ '.dn-collapse>.dn-collapse-body>.dn-block{min-width:0}',
50
+ '.dn-kind-list{display:flex;flex-direction:column;gap:8px}',
51
+ '.dn-kind-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}',
52
+ '.dn-kind-swatch{flex:none;width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)}',
53
+ '.dn-kind-name{flex:1;font-size:13px;font-weight:600;min-width:0}',
54
+ '.dn-kind-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}',
55
+ '.dn-input{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;outline:none;max-width:120px}',
56
+ '.dn-input:focus{border-color:var(--dsw-alias-state-business-primary)}',
57
+ '.dn-input[type=color]{padding:2px 4px;width:48px;height:28px;cursor:pointer;max-width:none}',
58
+ '.dn-input:disabled{opacity:.45;cursor:not-allowed}',
59
+ '.dn-select{max-width:260px;cursor:pointer}',
60
+ '.dn-color-preview{font-size:12px;color:var(--dsw-alias-label-tertiary);font-family:var(--dsw-font-mono)}',
61
+ '.dn-btn{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-button-elevated-fill);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 14px;cursor:pointer}',
62
+ '.dn-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
63
+ '.dn-btn.primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted)}',
64
+ '.dn-btn.primary:hover{opacity:0.88}',
65
+ '.dn-btn:disabled{opacity:.5;cursor:not-allowed}',
66
+ '.dn-msg{font-size:12px;line-height:18px;padding:8px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}',
67
+ '.dn-msg.ok{color:var(--dsw-alias-state-success-primary)}',
68
+ '.dn-msg.err{color:var(--dsw-alias-state-error-primary)}',
69
+ '.dn-divider{height:1px;background:var(--dsw-alias-border-l1)}',
70
+ '.dn-layer{position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;align-items:flex-end;gap:10px;pointer-events:none}',
71
+ '.dn-card{pointer-events:auto;width:320px;max-width:calc(100vw - 32px);border-radius:12px;box-shadow:var(--dsw-shadow-lv3);border:1px solid var(--dsw-alias-border-l2);background:var(--dn-bg,#20272f);color:var(--dn-fg,#e6edf3);overflow:hidden;animation:dn-in .18s ease}',
72
+ '@keyframes dn-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}',
73
+ '.dn-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px 6px}',
74
+ '.dn-card-title{font-size:13px;font-weight:600;line-height:20px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
75
+ '.dn-card-kind{flex:none;font-size:11px;line-height:18px;border-radius:999px;padding:0 8px;border:1px solid currentColor;opacity:.85}',
76
+ '.dn-card-body{padding:0 12px 8px;font-size:13px;line-height:20px;word-break:break-word;max-height:120px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical}',
77
+ '.dn-card-actions{display:flex;align-items:center;gap:6px;padding:6px 12px 10px}',
78
+ '.dn-card-btn{font:inherit;font-size:12px;line-height:18px;border-radius:6px;padding:4px 10px;cursor:pointer;border:1px solid rgba(128,128,128,0.35);border:1px solid color-mix(in srgb,currentColor 35%,transparent);background:transparent;background:color-mix(in srgb,currentColor 12%,transparent);color:inherit}',
79
+ '.dn-card-btn:hover{background:rgba(128,128,128,0.12);background:color-mix(in srgb,currentColor 12%,transparent)}',
80
+ '.dn-card-btn.primary{background:rgba(128,128,128,0.18);background:color-mix(in srgb,currentColor 18%,transparent)}',
81
+ '.dn-card-bar{height:2px;background:rgba(128,128,128,0.25);background:color-mix(in srgb,currentColor 25%,transparent);transform-origin:left}',
82
+ '@media (prefers-reduced-motion: reduce){.dn-card{animation:none}}',
83
+ ].join('\n')
84
+
85
+ const cssTagId = 'dsh-notify-yimit/client.css'
86
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON
87
+ .stringify(cssTagId) + ']') === null) {
88
+ const tag = document.createElement('style')
89
+ tag.dataset.plugin = 'dsh-notify-yimit'
90
+ tag.dataset.pluginCss = cssTagId
91
+ tag.textContent = css
92
+ document.head.appendChild(tag)
93
+ }
94
+
95
+ const NS = 'dsh-notify-yimit'
96
+
97
+ const zh = {
98
+ sectionLabel: '通知',
99
+ enabledLabel: '启用通知插件',
100
+ enabledNote: '关闭后不产生任何通知(设置仍会保存)。',
101
+ modeLabel: '通知方式',
102
+ modeSystem: 'Windows 系统通知',
103
+ modeCustom: '插件自定义样式通知',
104
+ modeOff: '关闭',
105
+ modeNote: '系统通知由浏览器弹出、可在系统通知中心查看;自定义通知为桌面右下角浮窗(不依赖浏览器页面)。',
106
+ browserLabel: '跳转会话浏览器',
107
+ browserDefault: '默认浏览器(系统)',
108
+ browserNote: '「跳转会话」始终打开浏览器并定位到该会话;可选指定浏览器打开(默认=系统默认浏览器)。',
109
+ customTitle: '自定义通知设置',
110
+ colorsTitle: '每种通知的样式',
111
+ colorBg: '背景',
112
+ colorFg: '文字',
113
+ maxVisibleLabel: '同时最多显示数量',
114
+ durationLabel: '通知显示时长(秒)',
115
+ bgColorLabel: '背景颜色',
116
+ textColorLabel: '文字颜色',
117
+ systemTitle: '系统通知',
118
+ permissionGranted: '已获得系统通知权限',
119
+ permissionDenied: '系统通知权限被拒绝,请在浏览器设置中允许本页面通知',
120
+ permissionDefault: '系统通知需要浏览器通知权限',
121
+ requestPermission: '申请通知权限',
122
+ triggerNote: '触发场景:任务完成、任务出错、运行中(实时内容)、等待审批、等待回答。通知标题为对话标题;点击通知或跳转按钮可打开对应会话。',
123
+ saveHint: '所有设置修改后自动保存。',
124
+ saved: '已保存',
125
+ saveFailed: '保存失败:{message}',
126
+ loading: '正在读取配置…',
127
+ rpcFailed: '读取配置失败:{message}',
128
+ kindCompleted: '已完成',
129
+ kindError: '出错',
130
+ kindRunning: '运行中',
131
+ kindApproval: '待审批',
132
+ kindQuestion: '待回答',
133
+ ignore: '忽略',
134
+ jump: '跳转会话',
135
+ }
136
+
137
+ const en = {
138
+ sectionLabel: 'Notifications',
139
+ enabledLabel: 'Enable notification plugin',
140
+ enabledNote: 'When disabled, no notifications are produced (settings are still saved).',
141
+ modeLabel: 'Notification style',
142
+ modeSystem: 'Windows system notifications',
143
+ modeCustom: 'Custom in-app notifications',
144
+ modeOff: 'Off',
145
+ modeNote: 'System notifications are shown by the browser and live in the system notification center; custom notifications are desktop toasts at the bottom-right (independent of the browser page).',
146
+ browserLabel: 'Browser for jumping to a session',
147
+ browserDefault: 'Default browser (system)',
148
+ browserNote: '"Jump to session" always opens the browser and navigates to the session; optionally pick a specific browser (default = system default).',
149
+ customTitle: 'Custom notification settings',
150
+ colorsTitle: 'Style per notification type',
151
+ colorBg: 'BG',
152
+ colorFg: 'FG',
153
+ maxVisibleLabel: 'Max simultaneously visible',
154
+ durationLabel: 'Display duration (seconds)',
155
+ bgColorLabel: 'Background color',
156
+ textColorLabel: 'Text color',
157
+ systemTitle: 'System notifications',
158
+ permissionGranted: 'System notification permission granted',
159
+ permissionDenied: 'System notification permission denied — allow notifications for this site in your browser settings',
160
+ permissionDefault: 'System notifications need browser notification permission',
161
+ requestPermission: 'Request permission',
162
+ triggerNote: 'Triggers: task completed, task failed, running (live activity), awaiting approval, awaiting answer. The title is the conversation title; clicking the notification or the jump button opens the session.',
163
+ saveHint: 'All settings changes are auto-saved.',
164
+ saved: 'Saved',
165
+ saveFailed: 'Save failed: {message}',
166
+ loading: 'Loading config…',
167
+ rpcFailed: 'Failed to load config: {message}',
168
+ kindCompleted: 'Done',
169
+ kindError: 'Failed',
170
+ kindRunning: 'Running',
171
+ kindApproval: 'Approval',
172
+ kindQuestion: 'Question',
173
+ ignore: 'Ignore',
174
+ jump: 'Open',
175
+ }
176
+
177
+ // ===== 兼容层:ctx.locale 可用时直接用,不可用时降级为外部 store =====
178
+ let __localeService = null
179
+ let __fallbackUnsub = null
180
+ const __fallbackListeners = new Set()
181
+ let __fallbackLocale = 'en'
182
+
183
+ function detectLocaleId() {
184
+ try {
185
+ if (typeof localStorage !== 'undefined') {
186
+ const sl = localStorage.getItem('dsh-language') || localStorage.getItem('language') ||
187
+ localStorage.getItem('i18nextLng') || localStorage.getItem('locale')
188
+ if (sl) return String(sl).toLowerCase().startsWith('zh') ? 'zh' : 'en'
189
+ }
190
+ } catch {}
191
+ if (typeof document !== 'undefined' && document.documentElement && typeof document
192
+ .documentElement.lang === 'string' && document.documentElement.lang) {
193
+ return document.documentElement.lang.toLowerCase().startsWith('zh') ? 'zh' : 'en'
194
+ }
195
+ const lang = typeof navigator !== 'undefined' && typeof navigator.language === 'string' ?
196
+ navigator.language : ''
197
+ return lang.toLowerCase().startsWith('zh') ? 'zh' : 'en'
198
+ }
199
+
200
+ // 外部 store:供 useSyncExternalStore 使用,统一走 ctx.locale 或降级源
201
+ const localeStore = {
202
+ subscribe(cb) {
203
+ if (__localeService && typeof __localeService.subscribe === 'function') {
204
+ return __localeService.subscribe(cb)
205
+ }
206
+ __fallbackListeners.add(cb)
207
+ if (__fallbackUnsub === null) {
208
+ __fallbackLocale = detectLocaleId()
209
+ __fallbackUnsub = setInterval(() => {
210
+ const next = detectLocaleId()
211
+ if (next !== __fallbackLocale) {
212
+ __fallbackLocale = next
213
+ for (const fn of [...__fallbackListeners]) fn()
214
+ }
215
+ }, 500)
216
+ }
217
+ return () => {
218
+ __fallbackListeners.delete(cb)
219
+ if (__fallbackListeners.size === 0 && __fallbackUnsub !== null) {
220
+ clearInterval(__fallbackUnsub);
221
+ __fallbackUnsub = null
222
+ }
223
+ }
224
+ },
225
+ getSnapshot() {
226
+ if (__localeService && typeof __localeService.getSnapshot === 'function') {
227
+ return __localeService.getSnapshot()
228
+ }
229
+ return {
230
+ active: __fallbackLocale,
231
+ locales: ['zh', 'en'],
232
+ revision: 0
233
+ }
234
+ }
235
+ }
236
+
237
+ function currentLangId() {
238
+ try {
239
+ const snap = localeStore.getSnapshot()
240
+ const active = String(snap?.active ?? snap?.locale ?? snap?.language ?? __fallbackLocale ??
241
+ 'en')
242
+ return active.toLowerCase().startsWith('zh') ? 'zh' : 'en'
243
+ } catch {
244
+ return 'en'
245
+ }
246
+ }
247
+
248
+ function makeT() {
249
+ const dict = () => currentLangId() === 'zh' ? zh : en
250
+ return (key, vars) => {
251
+ const d = dict()
252
+ let text = d[key] ?? en[key] ?? key
253
+ if (vars)
254
+ for (const name of Object.keys(vars)) text = text.split('{' + name + '}').join(
255
+ String(vars[name]))
256
+ return text
257
+ }
258
+ }
259
+
260
+ function fail(path) {
261
+ throw new Error('dsh-notify-yimit: Invalid server data (' + path + ')')
262
+ }
263
+
264
+ function needStr(v, path) {
265
+ if (typeof v !== 'string') fail(path);
266
+ return v
267
+ }
268
+
269
+ function needNum(v, path) {
270
+ if (typeof v !== 'number' || !Number.isFinite(v)) fail(path);
271
+ return v
272
+ }
273
+
274
+ function needBool(v, path) {
275
+ if (typeof v !== 'boolean') fail(path);
276
+ return v
277
+ }
278
+
279
+ function parseConfig(v, path) {
280
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
281
+ const kindColors = (key, fallbackBg, fallbackFg) => {
282
+ const entry = v.colors !== null && typeof v.colors === 'object' && !Array.isArray(v
283
+ .colors) ? v.colors[key] : undefined
284
+ return {
285
+ bg: typeof entry?.bg === 'string' ? entry.bg : fallbackBg,
286
+ fg: typeof entry?.fg === 'string' ? entry.fg : fallbackFg
287
+ }
288
+ }
289
+ const topBg = typeof v.bgColor === 'string' ? v.bgColor : '#20272f'
290
+ const topFg = typeof v.textColor === 'string' ? v.textColor : '#e6edf3'
291
+ return {
292
+ enabled: v.enabled === true,
293
+ mode: v.mode === 'system' || v.mode === 'custom' || v.mode === 'off' ? v.mode : 'off',
294
+ maxVisible: typeof v.maxVisible === 'number' ? v.maxVisible : 3,
295
+ durationMs: typeof v.durationMs === 'number' ? v.durationMs : 8000,
296
+ browser: typeof v.browser === 'string' ? v.browser : '',
297
+ bgColor: topBg,
298
+ textColor: topFg,
299
+ locale: v.locale === 'zh' || v.locale === 'en' ? v.locale : 'en',
300
+ colors: {
301
+ completed: kindColors('completed', topBg, topFg),
302
+ error: kindColors('error', topBg, topFg),
303
+ running: kindColors('running', topBg, topFg),
304
+ approval: kindColors('approval', topBg, topFg),
305
+ question: kindColors('question', topBg, topFg),
306
+ },
307
+ }
308
+ }
309
+
310
+ function parseEvent(v, path) {
311
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
312
+ return {
313
+ id: needStr(v.id, path + '.id'),
314
+ sessionId: needStr(v.sessionId, path + '.sessionId'),
315
+ title: typeof v.title === 'string' ? v.title : '',
316
+ kind: v.kind === 'completed' || v.kind === 'error' || v.kind === 'running' || v.kind ===
317
+ 'approval' || v.kind === 'question' ? v.kind : 'running',
318
+ text: needStr(v.text, path + '.text'),
319
+ activity: typeof v.activity === 'string' ? v.activity : '',
320
+ at: needNum(v.at, path + '.at'),
321
+ }
322
+ }
323
+
324
+ function parseState(v, path) {
325
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
326
+ return {
327
+ config: parseConfig(v.config, path + '.config'),
328
+ events: Array.isArray(v.events) ? v.events.map((e, i) => parseEvent(e, path + '.events[' +
329
+ i + ']')) : []
330
+ }
331
+ }
332
+
333
+ const codecOf = (parse) => ({
334
+ parse
335
+ })
336
+ const stateCodec = codecOf((v) => parseState(v, 'state'))
337
+ const configCodec = codecOf((v) => parseConfig(v, 'config'))
338
+ const ackCodec = codecOf((v) => {
339
+ if (v === null || typeof v !== 'object') fail('ack');
340
+ return {
341
+ ok: v.ok === true
342
+ }
343
+ })
344
+ const patchCodec = codecOf((v) => {
345
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail('patch');
346
+ return v
347
+ })
348
+ const browsersCodec = codecOf((v) => {
349
+ if (v === null || typeof v !== 'object' || !Array.isArray(v.browsers)) fail('browsers')
350
+ return {
351
+ browsers: v.browsers.map((b, i) => ({
352
+ id: typeof b?.id === 'string' ? b.id : String(i),
353
+ name: typeof b?.name === 'string' ? b.name : '',
354
+ path: typeof b?.path === 'string' ? b.path : ''
355
+ }))
356
+ }
357
+ })
358
+
359
+ const CONTRIBUTION = {
360
+ package: 'dsh-notify-yimit',
361
+ descriptors: [{
362
+ id: 'dsh-notify-yimit#notify/getState',
363
+ service: 'notify',
364
+ namespace: 'notify',
365
+ method: 'getState',
366
+ invocation: {
367
+ kind: 'direct'
368
+ },
369
+ parameters: [],
370
+ result: {
371
+ mode: 'strict',
372
+ typeSymbol: 'dsh-notify-yimit#NotifyState',
373
+ schema: stateCodec
374
+ }
375
+ }, {
376
+ id: 'dsh-notify-yimit#notify/updateConfig',
377
+ service: 'notify',
378
+ namespace: 'notify',
379
+ method: 'updateConfig',
380
+ invocation: {
381
+ kind: 'direct'
382
+ },
383
+ parameters: [{
384
+ name: 'patch',
385
+ wire: 'patch',
386
+ source: 'json',
387
+ codec: {
388
+ mode: 'strict',
389
+ typeSymbol: 'dsh-notify-yimit#NotifyConfigPatch',
390
+ schema: patchCodec
391
+ }
392
+ }],
393
+ result: {
394
+ mode: 'strict',
395
+ typeSymbol: 'dsh-notify-yimit#NotifyConfig',
396
+ schema: configCodec
397
+ }
398
+ }, {
399
+ id: 'dsh-notify-yimit#notify/ackEvents',
400
+ service: 'notify',
401
+ namespace: 'notify',
402
+ method: 'ackEvents',
403
+ invocation: {
404
+ kind: 'direct'
405
+ },
406
+ parameters: [{
407
+ name: 'ids',
408
+ wire: 'ids',
409
+ source: 'json',
410
+ codec: {
411
+ mode: 'strict',
412
+ typeSymbol: 'dsh-notify-yimit#EventIds',
413
+ schema: codecOf((v) => Array.isArray(v) ? v.map((x) => String(
414
+ x)) : [])
415
+ }
416
+ }],
417
+ result: {
418
+ mode: 'strict',
419
+ typeSymbol: 'dsh-notify-yimit#AckResult',
420
+ schema: ackCodec
421
+ }
422
+ }, {
423
+ id: 'dsh-notify-yimit#notify/listBrowsers',
424
+ service: 'notify',
425
+ namespace: 'notify',
426
+ method: 'listBrowsers',
427
+ invocation: {
428
+ kind: 'direct'
429
+ },
430
+ parameters: [],
431
+ result: {
432
+ mode: 'strict',
433
+ typeSymbol: 'dsh-notify-yimit#BrowserList',
434
+ schema: browsersCodec
435
+ }
436
+ }]
437
+ }
438
+
439
+ function makeStore(initial) {
440
+ let snapshot = initial
441
+ const listeners = new Set()
442
+ return {
443
+ getSnapshot: () => snapshot,
444
+ subscribe: (fn) => {
445
+ listeners.add(fn);
446
+ return () => {
447
+ listeners.delete(fn)
448
+ }
449
+ },
450
+ set: (next) => {
451
+ if (next === snapshot) return;
452
+ snapshot = next;
453
+ for (const fn of [...listeners]) fn()
454
+ },
455
+ }
456
+ }
457
+
458
+ function kindLabel(kind, t) {
459
+ switch (kind) {
460
+ case 'completed':
461
+ return t('kindCompleted')
462
+ case 'error':
463
+ return t('kindError')
464
+ case 'approval':
465
+ return t('kindApproval')
466
+ case 'question':
467
+ return t('kindQuestion')
468
+ default:
469
+ return t('kindRunning')
470
+ }
471
+ }
472
+
473
+ function NotifyOverlay(props) {
474
+ const notifyStore = props.useNotify ? props.useNotify((s) => s) : undefined
475
+ const api = props.api
476
+ const sessions = props.sessions
477
+ const seenRef = useRef(new Set())
478
+ const config = notifyStore?.config
479
+ const events = notifyStore?.events
480
+ const openSession = useCallback((sessionId) => {
481
+ try {
482
+ if (typeof sessions?.open === 'function') sessions.open(sessionId)
483
+ } catch {
484
+ /* 静默 */ }
485
+ }, [sessions])
486
+
487
+ useEffect(() => {
488
+ const cleanup = setInterval(() => {
489
+ if (seenRef.current.size > 1000) seenRef.current.clear()
490
+ }, 60000)
491
+ return () => clearInterval(cleanup)
492
+ }, [])
493
+
494
+ useEffect(() => {
495
+ if (!config || !Array.isArray(events)) return
496
+ const fresh = events.filter((e) => !seenRef.current.has(e.id))
497
+ if (fresh.length === 0) return
498
+ for (const e of fresh) seenRef.current.add(e.id)
499
+
500
+ const ack = () => {
501
+ try {
502
+ api?.ackEvents(fresh.map((e) => e.id))
503
+ } catch {
504
+ /* 静默 */ }
505
+ }
506
+
507
+ if (config.enabled !== true || config.mode === 'off') {
508
+ ack();
509
+ return
510
+ }
511
+
512
+ if (config.mode === 'custom') {
513
+ if (typeof Notification === 'undefined' || Notification.permission !==
514
+ 'granted') {
515
+ ack();
516
+ return
517
+ }
518
+ } else if (config.mode === 'system') {
519
+ if (typeof Notification === 'undefined' || Notification.permission ===
520
+ 'denied') {
521
+ ack();
522
+ return
523
+ }
524
+ }
525
+
526
+ const request = Notification.permission === 'granted' ? Promise.resolve('granted') :
527
+ Notification.requestPermission()
528
+ request.then((permission) => {
529
+ if (permission !== 'granted') {
530
+ ack();
531
+ return
532
+ }
533
+ for (const e of fresh) {
534
+ let n
535
+ try {
536
+ n = new Notification(e.title && e.title.length > 0 ? e.title : e
537
+ .sessionId, {
538
+ body: e.text,
539
+ tag: e.kind === 'running' ?
540
+ `${e.sessionId}:${e.kind}` :
541
+ `${e.sessionId}:${e.kind}:${e.at}`,
542
+ silent: false,
543
+ })
544
+ } catch {
545
+ continue
546
+ }
547
+ n.onclick = () => {
548
+ try {
549
+ window.focus();
550
+ openSession(e.sessionId);
551
+ n.close()
552
+ } catch {
553
+ /* 静默 */ }
554
+ }
555
+ }
556
+ ack()
557
+ }).catch(() => ack())
558
+ }, [events, config, api, openSession])
559
+
560
+ return null
561
+ }
562
+
563
+ function Segmented(props) {
564
+ const {
565
+ value,
566
+ options,
567
+ onChange,
568
+ disabled
569
+ } = props
570
+ return el('div', {
571
+ className: 'dn-seg',
572
+ role: 'tablist'
573
+ },
574
+ options.map((entry) => el('button', {
575
+ key: entry[0],
576
+ type: 'button',
577
+ role: 'tab',
578
+ 'aria-selected': String(value === entry[0]),
579
+ className: 'dn-seg-btn',
580
+ 'data-on': String(value === entry[0]),
581
+ disabled: disabled === true,
582
+ onClick: () => {
583
+ if (value !== entry[0]) onChange(entry[0])
584
+ },
585
+ }, entry[1])))
586
+ }
587
+
588
+ const KIND_ORDER = ['completed', 'error', 'running', 'approval', 'question']
589
+
590
+ function NotifySection(props) {
591
+ const notifyStore = props.useNotify ? props.useNotify((s) => s) : undefined
592
+ const api = props.api
593
+ // 关键:订阅 locale 变化,语言切换时本组件立即重渲染
594
+ useSyncExternalStore(localeStore.subscribe, localeStore.getSnapshot)
595
+ const t = makeT()
596
+ const state = notifyStore
597
+ const config = state?.config
598
+ const [msg, setMsg] = useState(null)
599
+ const [saving, setSaving] = useState(false)
600
+ const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification
601
+ .permission : 'unsupported')
602
+
603
+ const saveTimerRef = useRef(null)
604
+ const save = useCallback((patch) => {
605
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
606
+ saveTimerRef.current = setTimeout(async () => {
607
+ if (!api?.updateConfig) return
608
+ setSaving(true);
609
+ setMsg(null)
610
+ try {
611
+ await api.updateConfig(patch)
612
+ setMsg({
613
+ kind: 'ok',
614
+ text: t('saved')
615
+ })
616
+ } catch (error) {
617
+ setMsg({
618
+ kind: 'err',
619
+ text: t('saveFailed', {
620
+ message: error?.message ?? String(error)
621
+ })
622
+ })
623
+ } finally {
624
+ setSaving(false)
625
+ }
626
+ }, 300)
627
+ }, [api, t])
628
+
629
+ const requestPerm = useCallback(async () => {
630
+ if (typeof Notification === 'undefined' || Notification.permission ===
631
+ 'granted') {
632
+ setPermission(typeof Notification !== 'undefined' ? Notification
633
+ .permission : 'unsupported')
634
+ return
635
+ }
636
+ try {
637
+ const result = await Notification.requestPermission();
638
+ setPermission(result)
639
+ } catch {
640
+ setPermission('denied')
641
+ }
642
+ }, [])
643
+
644
+ if (!config) return el('div', {
645
+ className: 'dn-root'
646
+ }, el('div', {
647
+ className: 'dn-note'
648
+ }, t('loading')))
649
+
650
+ const controlsDisabled = !config.enabled
651
+
652
+ const kindRows = KIND_ORDER.map((kind) => {
653
+ const entry = config.colors?. [kind] ?? {}
654
+ const bg = entry.bg || config.bgColor || '#20272f'
655
+ const fg = entry.fg || config.textColor || '#e6edf3'
656
+ return el('div', {
657
+ key: kind,
658
+ className: 'dn-kind-row'
659
+ },
660
+ el('span', {
661
+ className: 'dn-kind-swatch',
662
+ style: {
663
+ background: bg
664
+ }
665
+ }),
666
+ el('span', {
667
+ className: 'dn-kind-name'
668
+ }, kindLabel(kind, t)),
669
+ el('label', {
670
+ className: 'dn-kind-field'
671
+ },
672
+ el('input', {
673
+ type: 'color',
674
+ className: 'dn-input',
675
+ title: t('colorBg'),
676
+ value: /^#[0-9a-fA-F]{6}$/.test(bg) ? bg : '#20272f',
677
+ disabled: controlsDisabled,
678
+ onChange: (e) => save({
679
+ colors: {
680
+ [kind]: {
681
+ ...entry,
682
+ bg: e.target.value
683
+ }
684
+ }
685
+ })
686
+ }), t('colorBg')),
687
+ el('label', {
688
+ className: 'dn-kind-field'
689
+ },
690
+ el('input', {
691
+ type: 'color',
692
+ className: 'dn-input',
693
+ title: t('colorFg'),
694
+ value: /^#[0-9a-fA-F]{6}$/.test(fg) ? fg : '#e6edf3',
695
+ disabled: controlsDisabled,
696
+ onChange: (e) => save({
697
+ colors: {
698
+ [kind]: {
699
+ ...entry,
700
+ fg: e.target.value
701
+ }
702
+ }
703
+ })
704
+ }), t('colorFg')))
705
+ })
706
+
707
+ const customOpen = config.mode === 'custom'
708
+
709
+ return el('div', {
710
+ className: 'dn-root'
711
+ },
712
+ el('div', {
713
+ className: 'dn-block'
714
+ },
715
+ el('div', {
716
+ className: 'dn-row'
717
+ },
718
+ el('button', {
719
+ type: 'button',
720
+ role: 'switch',
721
+ 'aria-checked': config.enabled,
722
+ className: 'dn-switch',
723
+ 'data-on': String(config.enabled),
724
+ onClick: () => save({
725
+ enabled: !config.enabled
726
+ })
727
+ }),
728
+ el('span', {
729
+ className: 'dn-label'
730
+ }, t('enabledLabel'))),
731
+ el('p', {
732
+ className: 'dn-note'
733
+ }, t('enabledNote'))),
734
+ el('div', {
735
+ className: 'dn-divider'
736
+ }),
737
+ el('div', {
738
+ className: 'dn-block'
739
+ },
740
+ el('p', {
741
+ className: 'dn-title'
742
+ }, t('modeLabel')),
743
+ el(Segmented, {
744
+ value: config.mode,
745
+ disabled: controlsDisabled,
746
+ onChange: (mode) => save({
747
+ mode
748
+ }),
749
+ options: [
750
+ ['system', t('modeSystem')],
751
+ ['custom', t('modeCustom')],
752
+ ['off', t('modeOff')]
753
+ ]
754
+ }),
755
+ el('p', {
756
+ className: 'dn-note'
757
+ }, t('modeNote'))),
758
+ (config.mode === 'system' || config.mode === 'custom') ? el('div', {
759
+ className: 'dn-block'
760
+ },
761
+ el('div', {
762
+ className: 'dn-row'
763
+ },
764
+ el('span', {
765
+ className: 'dn-label'
766
+ }, t('browserLabel')),
767
+ el('select', {
768
+ className: 'dn-input dn-select',
769
+ value: config.browser || '',
770
+ disabled: controlsDisabled,
771
+ onChange: (e) => save({
772
+ browser: e.target.value
773
+ })
774
+ },
775
+ el('option', {
776
+ value: ''
777
+ }, t('browserDefault')),
778
+ (state?.browsers ?? []).map((b) => el('option', {
779
+ key: b.id,
780
+ value: b.path
781
+ }, b.name)))),
782
+ el('p', {
783
+ className: 'dn-note'
784
+ }, t('browserNote'))
785
+ ) : null,
786
+ el('div', {
787
+ className: 'dn-divider'
788
+ }),
789
+ config.mode === 'system' ? el('div', {
790
+ className: 'dn-block'
791
+ },
792
+ el('p', {
793
+ className: 'dn-title'
794
+ }, t('systemTitle')),
795
+ permission === 'granted' ? el('div', {
796
+ className: 'dn-msg ok'
797
+ }, t('permissionGranted')) :
798
+ permission === 'denied' ? el('div', {
799
+ className: 'dn-msg err'
800
+ }, t('permissionDenied')) :
801
+ el(Fragment, null,
802
+ el('p', {
803
+ className: 'dn-note'
804
+ }, t('permissionDefault')),
805
+ el('button', {
806
+ className: 'dn-btn primary',
807
+ disabled: controlsDisabled,
808
+ onClick: requestPerm
809
+ }, t('requestPermission')))
810
+ ) : null,
811
+ el('div', {
812
+ className: 'dn-collapse',
813
+ 'data-open': String(customOpen)
814
+ },
815
+ el('div', {
816
+ className: 'dn-collapse-body'
817
+ },
818
+ el('div', {
819
+ className: 'dn-block'
820
+ },
821
+ el('p', {
822
+ className: 'dn-title'
823
+ }, t('customTitle')),
824
+ el('div', {
825
+ className: 'dn-row'
826
+ },
827
+ el('span', {
828
+ className: 'dn-label'
829
+ }, t('maxVisibleLabel')),
830
+ el('input', {
831
+ type: 'number',
832
+ min: 1,
833
+ max: 20,
834
+ className: 'dn-input',
835
+ value: config.maxVisible,
836
+ disabled: controlsDisabled,
837
+ onChange: (e) => {
838
+ const n = Math.max(1, Math.min(20, Math.floor(Number(e
839
+ .target.value) || 1)))
840
+ save({
841
+ maxVisible: n
842
+ })
843
+ }
844
+ })),
845
+ el('div', {
846
+ className: 'dn-row'
847
+ },
848
+ el('span', {
849
+ className: 'dn-label'
850
+ }, t('durationLabel')),
851
+ el('input', {
852
+ type: 'number',
853
+ min: 1,
854
+ max: 300,
855
+ className: 'dn-input',
856
+ value: Math.round(config.durationMs / 1000),
857
+ disabled: controlsDisabled,
858
+ onChange: (e) => {
859
+ const s = Math.max(1, Math.min(300, Math.floor(Number(e
860
+ .target.value) || 1)))
861
+ save({
862
+ durationMs: s * 1000
863
+ })
864
+ }
865
+ })),
866
+ el('p', {
867
+ className: 'dn-title'
868
+ }, t('colorsTitle')),
869
+ el('div', {
870
+ className: 'dn-kind-list'
871
+ }, kindRows))
872
+ )),
873
+ el('div', {
874
+ className: 'dn-divider'
875
+ }),
876
+ el('p', {
877
+ className: 'dn-note'
878
+ }, t('triggerNote')),
879
+ el('p', {
880
+ className: 'dn-note'
881
+ }, t('saveHint')),
882
+ msg !== null ? el('div', {
883
+ className: 'dn-msg ' + msg.kind
884
+ }, msg.text) : null,
885
+ saving ? el('div', {
886
+ className: 'dn-note'
887
+ }, '…') : null,
888
+ )
889
+ }
890
+
891
+ // ===== 修改点:inject 数组改为 ['remote', 'locale'] =====
892
+ const inject = ['remote', 'locale']
893
+
894
+ async function apply(ctx) {
895
+ const remote = ctx.remote
896
+ if (remote === undefined || typeof remote.$mount !== 'function') return
897
+ const unmount = await remote.$mount(CONTRIBUTION)
898
+ ctx.effect(() => () => {
899
+ unmount()
900
+ }, 'dsh-notify-yimit: remote contribution')
901
+
902
+ const notify = ctx.get('remote.notify')
903
+ if (notify === undefined) return
904
+
905
+ const store = makeStore({
906
+ config: null,
907
+ events: [],
908
+ browsers: []
909
+ })
910
+
911
+ // 定义 loadBrowsers 和 reload(不依赖 api)
912
+ const loadBrowsers = async () => {
913
+ try {
914
+ const result = await notify.listBrowsers()
915
+ if (result !== null && typeof result === 'object' && result.ok === true) {
916
+ const parsed = browsersCodec.parse(result.value)
917
+ const state = store.getSnapshot()
918
+ store.set({
919
+ config: state.config,
920
+ events: state.events,
921
+ browsers: parsed.browsers
922
+ })
923
+ }
924
+ } catch {
925
+ /* 探测失败 */ }
926
+ }
927
+
928
+ let reloading = false
929
+ const reload = async () => {
930
+ if (reloading) return
931
+ reloading = true
932
+ try {
933
+ const result = await notify.getState()
934
+ if (result !== null && typeof result === 'object' && result.ok === true) {
935
+ const prev = store.getSnapshot()
936
+ store.set({
937
+ ...parseState(result.value, 'state'),
938
+ browsers: prev.browsers
939
+ })
940
+ }
941
+ } catch {
942
+ /* 传输失败 */ } finally {
943
+ reloading = false
944
+ }
945
+ }
946
+
947
+ // ===== 先定义 api(包含 updateConfig),供后续语言同步使用 =====
948
+ const api = {
949
+ reload,
950
+ updateConfig: async (patch) => {
951
+ const result = await notify.updateConfig(patch)
952
+ if (result !== null && typeof result === 'object' && result.ok === true) {
953
+ const state = store.getSnapshot()
954
+ store.set({
955
+ config: parseConfig(result.value, 'config'),
956
+ events: state.events,
957
+ browsers: state.browsers
958
+ })
959
+ }
960
+ return result?.value
961
+ },
962
+ ackEvents: async (ids) => {
963
+ try {
964
+ await notify.ackEvents(ids)
965
+ } catch {
966
+ /* 静默 */ }
967
+ },
968
+ }
969
+
970
+ // ===== 现在设置 __localeService 并使用 api =====
971
+ __localeService = ctx.locale
972
+
973
+ if (__localeService && typeof __localeService.register === 'function') {
974
+ ctx.effect(() => __localeService.register(NS, {
975
+ zh,
976
+ en
977
+ }), 'dsh-notify-yimit: dictionaries')
978
+ }
979
+
980
+ if (__localeService && typeof __localeService.subscribe === 'function') {
981
+ const syncToHost = () => {
982
+ const newLang = currentLangId()
983
+ if (api && typeof api.updateConfig === 'function') {
984
+ void api.updateConfig({
985
+ locale: newLang
986
+ })
987
+ }
988
+ }
989
+ const unsub = __localeService.subscribe(syncToHost)
990
+ ctx.effect(() => unsub, 'dsh-notify-yimit: locale sync')
991
+ void syncToHost()
992
+ }
993
+
994
+ // 初始加载配置和浏览器列表
995
+ void reload()
996
+ void loadBrowsers()
997
+
998
+ ctx.effect(() => ctx.on('connection/reset', () => {
999
+ void reload();
1000
+ void loadBrowsers()
1001
+ }), 'dsh-notify-yimit: reconnect reload')
1002
+
1003
+ const pollTimer = setInterval(() => {
1004
+ if (document.visibilityState === 'hidden') return
1005
+ void reload()
1006
+ }, 250)
1007
+ ctx.effect(() => () => {
1008
+ clearInterval(pollTimer)
1009
+ }, 'dsh-notify-yimit: poll timer')
1010
+
1011
+ const onVisible = () => {
1012
+ if (document.visibilityState === 'visible') void reload()
1013
+ }
1014
+ document.addEventListener('visibilitychange', onVisible)
1015
+ ctx.effect(() => () => {
1016
+ document.removeEventListener('visibilitychange', onVisible)
1017
+ }, 'dsh-notify-yimit: visibility reload')
1018
+
1019
+ const sessions = ctx.get('sessions')
1020
+ const slots = ctx.get('slots')
1021
+ if (slots === undefined) return
1022
+
1023
+ const openFromHash = () => {
1024
+ try {
1025
+ const m = /#dsh-notify-yimit\/session=([^&]+)/.exec(window.location.hash)
1026
+ if (m !== null && typeof sessions?.open === 'function') {
1027
+ sessions.open(decodeURIComponent(m[1]))
1028
+ const clean = window.location.href.split('#')[0]
1029
+ window.history.replaceState(null, '', clean)
1030
+ }
1031
+ } catch {
1032
+ /* 静默 */ }
1033
+ }
1034
+ window.addEventListener('hashchange', openFromHash)
1035
+ openFromHash()
1036
+ ctx.effect(() => () => window.removeEventListener('hashchange', openFromHash),
1037
+ 'dsh-notify-yimit: hash listener')
1038
+
1039
+ const injected = () => ({
1040
+ hooks: {
1041
+ notify: store
1042
+ },
1043
+ api,
1044
+ sessions
1045
+ })
1046
+ const sectionInjected = () => ({
1047
+ hooks: {
1048
+ notify: store
1049
+ },
1050
+ api
1051
+ })
1052
+
1053
+ const sectionActive = {
1054
+ gen: 0,
1055
+ dispose: null
1056
+ }
1057
+ const registerSection = () => {
1058
+ if (sectionActive.dispose !== null) {
1059
+ sectionActive.dispose();
1060
+ sectionActive.dispose = null
1061
+ }
1062
+ sectionActive.gen += 1
1063
+ const gen = sectionActive.gen
1064
+ slots.inject('settings.section', () => {
1065
+ if (sectionActive.gen !== gen) return
1066
+ const dispose = slots.register({
1067
+ name: 'settings.section',
1068
+ id: 'dsh-notify-yimit',
1069
+ order: 60,
1070
+ label: () => makeT()('sectionLabel'),
1071
+ locale: NS,
1072
+ inject: sectionInjected
1073
+ }, NotifySection)
1074
+ if (sectionActive.gen !== gen) {
1075
+ dispose();
1076
+ return
1077
+ }
1078
+ sectionActive.dispose = dispose
1079
+ return () => {
1080
+ if (sectionActive.dispose === dispose) sectionActive.dispose =
1081
+ null
1082
+ dispose()
1083
+ }
1084
+ })
1085
+ }
1086
+ registerSection()
1087
+
1088
+ const overlayActive = {
1089
+ gen: 0,
1090
+ dispose: null
1091
+ }
1092
+ const registerOverlay = () => {
1093
+ if (overlayActive.dispose !== null) {
1094
+ overlayActive.dispose();
1095
+ overlayActive.dispose = null
1096
+ }
1097
+ overlayActive.gen += 1
1098
+ const gen = overlayActive.gen
1099
+ slots.inject('shell.overlay', () => {
1100
+ if (overlayActive.gen !== gen) return
1101
+ const dispose = slots.register({
1102
+ name: 'shell.overlay',
1103
+ id: 'dsh-notify-yimit',
1104
+ order: 10,
1105
+ inject: injected
1106
+ }, NotifyOverlay)
1107
+ if (overlayActive.gen !== gen) {
1108
+ dispose();
1109
+ return
1110
+ }
1111
+ overlayActive.dispose = dispose
1112
+ return () => {
1113
+ if (overlayActive.dispose === dispose) overlayActive.dispose =
1114
+ null
1115
+ dispose()
1116
+ }
1117
+ })
1118
+ }
1119
+ registerOverlay()
1120
+
1121
+ return () => {}
1122
+ }
1123
+
1124
+ exports.apply = apply
1125
+ exports.inject = inject
1126
+ return module.exports
1127
+ },
790
1128
  })