dsh-notify-yimit 0.1.0
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/README.en.md +97 -0
- package/README.md +95 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +790 -0
- package/lib/index.js +716 -0
- package/lib/toast-host.ps1 +393 -0
- package/lib/typert.host.js +158 -0
- package/package.json +51 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify 浏览器端 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',
|
|
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: 设置页与自定义通知浮层 */',
|
|
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/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'
|
|
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: 服务端数据非法 (' + 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',
|
|
285
|
+
descriptors: [
|
|
286
|
+
{
|
|
287
|
+
id: 'dsh-notify#notify/getState', service: 'notify', namespace: 'notify', method: 'getState',
|
|
288
|
+
invocation: { kind: 'direct' }, parameters: [],
|
|
289
|
+
result: { mode: 'strict', typeSymbol: 'dsh-notify#NotifyState', schema: stateCodec },
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: 'dsh-notify#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#NotifyConfigPatch', schema: patchCodec } }],
|
|
295
|
+
result: { mode: 'strict', typeSymbol: 'dsh-notify#NotifyConfig', schema: configCodec },
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: 'dsh-notify#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#EventIds', schema: codecOf((v) => Array.isArray(v) ? v.map((x) => String(x)) : []) } }],
|
|
301
|
+
result: { mode: 'strict', typeSymbol: 'dsh-notify#AckResult', schema: ackCodec },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: 'dsh-notify#notify/listBrowsers', service: 'notify', namespace: 'notify', method: 'listBrowsers',
|
|
305
|
+
invocation: { kind: 'direct' }, parameters: [],
|
|
306
|
+
result: { mode: 'strict', typeSymbol: 'dsh-notify#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: 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: reconnect reload')
|
|
705
|
+
// system 模式走浏览器通知:250ms 轮询把平均延迟从 ~500ms 压到 ~125ms,感知为"即时"。
|
|
706
|
+
const pollTimer = setInterval(() => { void reload() }, 250)
|
|
707
|
+
ctx.effect(() => () => { clearInterval(pollTimer) }, 'dsh-notify: 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: visibility reload')
|
|
711
|
+
|
|
712
|
+
const sessions = ctx.get('sessions')
|
|
713
|
+
const slots = ctx.get('slots')
|
|
714
|
+
if (slots === undefined) return
|
|
715
|
+
|
|
716
|
+
// 会话深链接(桌面浮窗"跳转会话"通道):监听 #dsh-notify/session=<id>。
|
|
717
|
+
const openFromHash = () => {
|
|
718
|
+
try {
|
|
719
|
+
const m = /#dsh-notify\/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: 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',
|
|
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',
|
|
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
|
+
},
|
|
790
|
+
})
|