dsh-notify-yimit 0.2.2 → 0.2.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 +146 -0
- package/lib/index.js +120 -14
- package/lib/toast-host.ps1 +19 -3
- package/lib/typert.host.js +28 -0
- package/package.json +4 -2
- package/scripts/check-prepublish.mjs +60 -0
package/lib/client.js
CHANGED
|
@@ -78,6 +78,17 @@ window.__ModuleLoader__.load({
|
|
|
78
78
|
'.dn-msg.ok{color:var(--dsw-alias-state-success-primary)}',
|
|
79
79
|
'.dn-msg.err{color:var(--dsw-alias-state-error-primary)}',
|
|
80
80
|
'.dn-divider{height:1px;background:var(--dsw-alias-border-l1)}',
|
|
81
|
+
// ④ 宿主故障横幅:让"通知不显示"从静默失败变成可见、可解释。
|
|
82
|
+
// 颜色走 --dsw-alias-state-* 令牌,深/浅色模式都能自适应。
|
|
83
|
+
'.dn-host{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
|
|
84
|
+
'.dn-host-line{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px}',
|
|
85
|
+
'.dn-host-icon{flex:none;font-size:13px;line-height:18px;color:var(--dsw-alias-state-error-primary)}',
|
|
86
|
+
'.dn-host-title{font-weight:600;color:var(--dsw-alias-state-error-primary)}',
|
|
87
|
+
'.dn-host-body{color:var(--dsw-alias-label-secondary);flex:1;min-width:0}',
|
|
88
|
+
'.dn-host-actions{display:flex;align-items:center;gap:8px;padding-left:21px}',
|
|
89
|
+
'.dn-host-detail{font-size:11px;line-height:16px;font-family:var(--dsw-font-mono);color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:6px 8px;margin-left:21px;max-height:88px;overflow:auto;white-space:pre-wrap;word-break:break-all}',
|
|
90
|
+
'.dn-host[data-tone="ok"]{border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}',
|
|
91
|
+
'.dn-host[data-tone="ok"] .dn-host-icon,.dn-host[data-tone="ok"] .dn-host-title{color:var(--dsw-alias-state-success-primary)}',
|
|
81
92
|
'.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}',
|
|
82
93
|
'.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}',
|
|
83
94
|
'@keyframes dn-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}',
|
|
@@ -131,6 +142,20 @@ window.__ModuleLoader__.load({
|
|
|
131
142
|
permissionDefault: '系统通知需要浏览器通知权限',
|
|
132
143
|
requestPermission: '申请通知权限',
|
|
133
144
|
triggerNote: '触发场景:任务完成、任务出错、运行中(实时内容)、等待审批、等待回答。通知标题为对话标题;点击通知或跳转按钮可打开对应会话。',
|
|
145
|
+
// ④ 宿主故障横幅
|
|
146
|
+
hostErrTitle: '通知宿主异常退出',
|
|
147
|
+
hostErrBody: '自定义通知当前无法显示{detail}。',
|
|
148
|
+
hostErrPending: ' —— 已积压 {n} 条通知',
|
|
149
|
+
hostErrRetryIn: ';正在退避,约 {s} 秒后自动重试',
|
|
150
|
+
hostErrRetryNow: '立即重试',
|
|
151
|
+
hostErrRetry: '重新拉起宿主',
|
|
152
|
+
hostErrDetail: '查看详情',
|
|
153
|
+
hostErrDetailHide: '收起详情',
|
|
154
|
+
hostErrDetailText: '退出码 {code},连续失败 {n} 次',
|
|
155
|
+
hostErrSignal: '退出码 {code}(信号 {signal}),连续失败 {n} 次',
|
|
156
|
+
hostErrOkTitle: '通知宿主已恢复',
|
|
157
|
+
hostErrOkBody: '自定义通知已恢复正常。',
|
|
158
|
+
hostErrOkBodyPending: '已补发 {n} 条积压通知。',
|
|
134
159
|
toastStyleTitle: '通知窗口样式',
|
|
135
160
|
toastGapLabel: '通知间距(px)',
|
|
136
161
|
enterEdit: '进入编辑模式',
|
|
@@ -177,6 +202,20 @@ window.__ModuleLoader__.load({
|
|
|
177
202
|
permissionDefault: 'System notifications need browser notification permission',
|
|
178
203
|
requestPermission: 'Request permission',
|
|
179
204
|
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.',
|
|
205
|
+
// ④ host-failure banner
|
|
206
|
+
hostErrTitle: 'Notification host exited unexpectedly',
|
|
207
|
+
hostErrBody: 'Custom notifications cannot be shown right now{detail}.',
|
|
208
|
+
hostErrPending: ' — {n} notification(s) queued',
|
|
209
|
+
hostErrRetryIn: '; backing off, retrying in about {s}s',
|
|
210
|
+
hostErrRetryNow: 'Retry now',
|
|
211
|
+
hostErrRetry: 'Restart host',
|
|
212
|
+
hostErrDetail: 'Show details',
|
|
213
|
+
hostErrDetailHide: 'Hide details',
|
|
214
|
+
hostErrDetailText: 'exit code {code}, {n} consecutive failure(s)',
|
|
215
|
+
hostErrSignal: 'exit code {code} (signal {signal}), {n} consecutive failure(s)',
|
|
216
|
+
hostErrOkTitle: 'Notification host recovered',
|
|
217
|
+
hostErrOkBody: 'Custom notifications are working again.',
|
|
218
|
+
hostErrOkBodyPending: '{n} queued notification(s) delivered.',
|
|
180
219
|
toastStyleTitle: 'Toast window style',
|
|
181
220
|
toastGapLabel: 'Toast gap (px)',
|
|
182
221
|
enterEdit: 'Enter edit mode',
|
|
@@ -356,10 +395,27 @@ window.__ModuleLoader__.load({
|
|
|
356
395
|
|
|
357
396
|
function parseState(v, path) {
|
|
358
397
|
if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path)
|
|
398
|
+
// ④ 宿主故障状态。旧宿主没有该字段 → null(不显示横幅)。
|
|
399
|
+
// 手写 codec 是"只取已知字段"的宽容解析,多余字段会被忽略,
|
|
400
|
+
// 但宿主侧 typert 的 z.object 会**丢弃**未声明字段,所以那边也必须声明。
|
|
401
|
+
let hostError = null
|
|
402
|
+
const he = v.hostError
|
|
403
|
+
if (he !== null && he !== undefined && typeof he === 'object' && !Array.isArray(he)) {
|
|
404
|
+
hostError = {
|
|
405
|
+
code: typeof he.code === 'number' ? he.code : null,
|
|
406
|
+
signal: typeof he.signal === 'string' ? he.signal : null,
|
|
407
|
+
at: typeof he.at === 'number' ? he.at : 0,
|
|
408
|
+
count: typeof he.count === 'number' ? he.count : 1,
|
|
409
|
+
retryAt: typeof he.retryAt === 'number' ? he.retryAt : 0,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
359
412
|
return {
|
|
360
413
|
config: parseConfig(v.config, path + '.config'),
|
|
361
414
|
// 宿主运行环境:true = 桌面端。缺省按 web 端处理(旧宿主无该字段)。
|
|
362
415
|
desktop: v.desktop === true,
|
|
416
|
+
hostError,
|
|
417
|
+
pendingCount: typeof v.pendingCount === 'number' && v.pendingCount >= 0 ? Math.floor(v
|
|
418
|
+
.pendingCount) : 0,
|
|
363
419
|
events: Array.isArray(v.events) ? v.events.map((e, i) => parseEvent(e, path + '.events[' +
|
|
364
420
|
i + ']')) : []
|
|
365
421
|
}
|
|
@@ -474,6 +530,16 @@ window.__ModuleLoader__.load({
|
|
|
474
530
|
},
|
|
475
531
|
parameters: [],
|
|
476
532
|
result: strictResult('dsh-notify-yimit#AckResult', ackCodec)
|
|
533
|
+
}, {
|
|
534
|
+
id: 'dsh-notify-yimit#notify/retryHost',
|
|
535
|
+
service: 'notify',
|
|
536
|
+
namespace: 'notify',
|
|
537
|
+
method: 'retryHost',
|
|
538
|
+
invocation: {
|
|
539
|
+
kind: 'direct'
|
|
540
|
+
},
|
|
541
|
+
parameters: [],
|
|
542
|
+
result: strictResult('dsh-notify-yimit#AckResult', ackCodec)
|
|
477
543
|
}]
|
|
478
544
|
}
|
|
479
545
|
|
|
@@ -714,6 +780,22 @@ window.__ModuleLoader__.load({
|
|
|
714
780
|
.permission : 'unsupported')
|
|
715
781
|
// 通知窗口编辑模式(样板窗拖拽/缩放)
|
|
716
782
|
const [editing, setEditing] = useState(false)
|
|
783
|
+
// ④ 横幅里的「查看详情」折叠状态
|
|
784
|
+
const [hostDetail, setHostDetail] = useState(false)
|
|
785
|
+
// ④ 宿主故障横幅:退避倒计时每秒重算一次(依赖的 retryAt 是稳定数字,
|
|
786
|
+
// 所以这个 effect 不会因为轮询重渲染而反复重建定时器)。
|
|
787
|
+
const hostError = state?.hostError ?? null
|
|
788
|
+
const pendingCount = state?.pendingCount ?? 0
|
|
789
|
+
const retryAt = hostError !== null && typeof hostError.retryAt === 'number' ? hostError.retryAt : 0
|
|
790
|
+
const [nowTick, setNowTick] = useState(0)
|
|
791
|
+
useEffect(() => {
|
|
792
|
+
if (retryAt <= 0) return undefined
|
|
793
|
+
const timer = setInterval(() => setNowTick((n) => n + 1), 1000)
|
|
794
|
+
return () => clearInterval(timer)
|
|
795
|
+
}, [retryAt])
|
|
796
|
+
// 倒计时秒数(取整);nowTick 参与依赖以触发重算
|
|
797
|
+
void nowTick
|
|
798
|
+
const retryInSec = retryAt > 0 ? Math.max(0, Math.round((retryAt - Date.now()) / 1000)) : 0
|
|
717
799
|
// 最新值镜像:卸载清理时读取(避免空依赖闭包拿到陈旧值)。
|
|
718
800
|
const editingRef = useRef(false)
|
|
719
801
|
editingRef.current = editing
|
|
@@ -1084,6 +1166,52 @@ window.__ModuleLoader__.load({
|
|
|
1084
1166
|
el('p', {
|
|
1085
1167
|
className: 'dn-note'
|
|
1086
1168
|
}, t('saveHint')),
|
|
1169
|
+
// ④ 宿主故障横幅:让"自定义通知不显示"从静默失败变成可见、可解释。
|
|
1170
|
+
// 仅在宿主确实异常退出时出现(hostError 由宿主侧置上)。
|
|
1171
|
+
hostError !== null ? el('div', {
|
|
1172
|
+
className: 'dn-host',
|
|
1173
|
+
'data-tone': 'err'
|
|
1174
|
+
},
|
|
1175
|
+
el('div', {
|
|
1176
|
+
className: 'dn-host-line'
|
|
1177
|
+
},
|
|
1178
|
+
el('span', {
|
|
1179
|
+
className: 'dn-host-icon'
|
|
1180
|
+
}, '✕'),
|
|
1181
|
+
el('span', {
|
|
1182
|
+
className: 'dn-host-body'
|
|
1183
|
+
},
|
|
1184
|
+
el('span', {
|
|
1185
|
+
className: 'dn-host-title'
|
|
1186
|
+
}, t('hostErrTitle')),
|
|
1187
|
+
t('hostErrBody', {
|
|
1188
|
+
detail: (pendingCount > 0 ? t('hostErrPending', {
|
|
1189
|
+
n: pendingCount
|
|
1190
|
+
}) : '') + (retryInSec > 0 ? t('hostErrRetryIn', {
|
|
1191
|
+
s: retryInSec
|
|
1192
|
+
}) : '')
|
|
1193
|
+
}))),
|
|
1194
|
+
el('div', {
|
|
1195
|
+
className: 'dn-host-actions'
|
|
1196
|
+
},
|
|
1197
|
+
el('button', {
|
|
1198
|
+
className: 'dn-btn primary',
|
|
1199
|
+
onClick: () => void api.retryHost()
|
|
1200
|
+
}, retryInSec > 0 ? t('hostErrRetryNow') : t('hostErrRetry')),
|
|
1201
|
+
el('button', {
|
|
1202
|
+
className: 'dn-btn',
|
|
1203
|
+
onClick: () => setHostDetail(!hostDetail)
|
|
1204
|
+
}, hostDetail ? t('hostErrDetailHide') : t('hostErrDetail'))),
|
|
1205
|
+
hostDetail ? el('div', {
|
|
1206
|
+
className: 'dn-host-detail'
|
|
1207
|
+
}, hostError.signal !== null && hostError.signal !== undefined ? t('hostErrSignal', {
|
|
1208
|
+
code: hostError.code === null ? '-' : hostError.code,
|
|
1209
|
+
signal: hostError.signal,
|
|
1210
|
+
n: hostError.count
|
|
1211
|
+
}) : t('hostErrDetailText', {
|
|
1212
|
+
code: hostError.code === null ? '-' : hostError.code,
|
|
1213
|
+
n: hostError.count
|
|
1214
|
+
})) : null) : null,
|
|
1087
1215
|
msg !== null ? el('div', {
|
|
1088
1216
|
className: 'dn-msg ' + msg.kind
|
|
1089
1217
|
}, msg.text) : null,
|
|
@@ -1111,6 +1239,8 @@ window.__ModuleLoader__.load({
|
|
|
1111
1239
|
const store = makeStore({
|
|
1112
1240
|
config: null,
|
|
1113
1241
|
desktop: false,
|
|
1242
|
+
hostError: null,
|
|
1243
|
+
pendingCount: 0,
|
|
1114
1244
|
events: [],
|
|
1115
1245
|
browsers: []
|
|
1116
1246
|
})
|
|
@@ -1125,6 +1255,8 @@ window.__ModuleLoader__.load({
|
|
|
1125
1255
|
store.set({
|
|
1126
1256
|
config: state.config,
|
|
1127
1257
|
desktop: state.desktop,
|
|
1258
|
+
hostError: state.hostError ?? null,
|
|
1259
|
+
pendingCount: state.pendingCount ?? 0,
|
|
1128
1260
|
events: state.events,
|
|
1129
1261
|
browsers: parsed.browsers
|
|
1130
1262
|
})
|
|
@@ -1162,6 +1294,9 @@ window.__ModuleLoader__.load({
|
|
|
1162
1294
|
store.set({
|
|
1163
1295
|
config: parseConfig(result.value, 'config'),
|
|
1164
1296
|
desktop: state.desktop,
|
|
1297
|
+
// ④ 保留宿主故障状态与积压数,否则改一次配置横幅就被抹掉
|
|
1298
|
+
hostError: state.hostError ?? null,
|
|
1299
|
+
pendingCount: state.pendingCount ?? 0,
|
|
1165
1300
|
events: state.events,
|
|
1166
1301
|
browsers: state.browsers
|
|
1167
1302
|
})
|
|
@@ -1186,6 +1321,17 @@ window.__ModuleLoader__.load({
|
|
|
1186
1321
|
} catch {
|
|
1187
1322
|
/* 静默 */ }
|
|
1188
1323
|
},
|
|
1324
|
+
// ④ 横幅上的「重新拉起宿主」:清退避并立刻重试,然后重新拉一次状态。
|
|
1325
|
+
retryHost: async () => {
|
|
1326
|
+
try {
|
|
1327
|
+
await notify.retryHost()
|
|
1328
|
+
} catch {
|
|
1329
|
+
/* 静默 */ }
|
|
1330
|
+
try {
|
|
1331
|
+
await reload()
|
|
1332
|
+
} catch {
|
|
1333
|
+
/* 静默 */ }
|
|
1334
|
+
},
|
|
1189
1335
|
}
|
|
1190
1336
|
|
|
1191
1337
|
// ===== 现在设置 __localeService 并使用 api =====
|
package/lib/index.js
CHANGED
|
@@ -519,10 +519,41 @@ export function apply(ctx) {
|
|
|
519
519
|
let toastSeq = 1
|
|
520
520
|
let host = null
|
|
521
521
|
let hostAlive = false
|
|
522
|
-
let hostRestartAttempts = 0
|
|
523
522
|
let hostRestartUntil = 0
|
|
523
|
+
// ② 早退退避:spawn 成功 ≠ 进程活着。旧代码在 spawn 后同步置 hostAlive = true,
|
|
524
|
+
// 于是 ensureHost() 里的 `if (!hostAlive)` 永不成立、退避计数恒被清零,退避形同虚设。
|
|
525
|
+
// 宿主秒退时(例如 BOM 丢失),每条通知都会重拉一个 powershell.exe
|
|
526
|
+
// (冷启动 200–300ms + WPF 初始化),而 running 是高频事件 → 进程风暴。
|
|
527
|
+
// 因此把"早退"记在 exit 回调里(那里有真实存活时长),按连续早退次数退避。
|
|
528
|
+
let hostEarlyExits = 0
|
|
529
|
+
// ③ 一个故障周期内只告警一次,避免 600 字符 stderr 刷屏把关键信息淹掉。
|
|
530
|
+
let hostWarned = false
|
|
531
|
+
const HOST_EARLY_EXIT_MS = 3000
|
|
532
|
+
// ④ 设置页可见的宿主故障状态。null = 正常;否则描述最近一次故障。
|
|
533
|
+
// 客户端把它渲染成设置页上的横幅 —— 否则宿主失败时用户只看到"没有通知",
|
|
534
|
+
// 完全不知道发生了什么(报告者的核心抱怨就是"没有任何报错")。
|
|
535
|
+
// 形状:{ code, signal, at, count, retryAt }
|
|
536
|
+
let hostError = null
|
|
524
537
|
|
|
525
538
|
function spawnHost() {
|
|
539
|
+
// ① 运行时自检:toast-host.ps1 必须是「UTF-8 with BOM」。
|
|
540
|
+
// 宿主是 Windows PowerShell 5.1 拉起的,它对无 BOM 的 .ps1 会按系统 ANSI
|
|
541
|
+
// 代码页解码;在中文 Windows(936/932/950)下中文会吃掉字符串收尾引号 →
|
|
542
|
+
// 整段脚本语法错误 → 宿主秒退 → 所有自定义通知不显示且无任何报错。
|
|
543
|
+
// prepublishOnly 里也有同样的断言,但那只在发布时拦得住;这里是给
|
|
544
|
+
// "开发期误丢 BOM 后本地重载插件"这条路径兜底。
|
|
545
|
+
try {
|
|
546
|
+
const head = fs.readFileSync(TOAST_HOST_SCRIPT)
|
|
547
|
+
if (!(head[0] === 0xEF && head[1] === 0xBB && head[2] === 0xBF)) {
|
|
548
|
+
console.warn(
|
|
549
|
+
'[dsh-notify-yimit] lib/toast-host.ps1 缺少 UTF-8 BOM!中文 Windows 下 ' +
|
|
550
|
+
'PowerShell 5.1 会按 ANSI 解码导致脚本语法错误、宿主秒退,自定义通知将全部不显示。' +
|
|
551
|
+
'请把该文件另存为「UTF-8 with BOM」。'
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
} catch (error) {
|
|
555
|
+
if (process.env.DSH_DEBUG) console.warn(`[dsh-notify-yimit] BOM 自检失败: ${String(error)}`)
|
|
556
|
+
}
|
|
526
557
|
try {
|
|
527
558
|
const spawnFn = typeof globalThis.__DSH_NOTIFY_TEST_SPAWN__ === 'function' ? globalThis
|
|
528
559
|
.__DSH_NOTIFY_TEST_SPAWN__ : spawn
|
|
@@ -537,6 +568,9 @@ export function apply(ctx) {
|
|
|
537
568
|
return
|
|
538
569
|
}
|
|
539
570
|
hostAlive = true
|
|
571
|
+
// ④ 新宿主已拉起:清掉上一次的故障状态(真正的恢复以"活过阈值"为准,
|
|
572
|
+
// 但拉起成功即可先收起横幅,若又秒退会立刻重新置上)。
|
|
573
|
+
hostError = null
|
|
540
574
|
host.stdin.on('error', (err) => {
|
|
541
575
|
if (process.env.DSH_DEBUG) console.warn(`[dsh-notify-yimit] stdin error: ${err}`)
|
|
542
576
|
})
|
|
@@ -546,6 +580,64 @@ export function apply(ctx) {
|
|
|
546
580
|
})
|
|
547
581
|
host.stdout.setEncoding('utf8')
|
|
548
582
|
|
|
583
|
+
// ── 宿主 stderr 与早退诊断 ──────────────────────────────────────────
|
|
584
|
+
// toast-host.ps1 是「UTF-8 with BOM」文件:Windows PowerShell 5.1 对无 BOM 的
|
|
585
|
+
// .ps1 会按系统 ANSI 代码页解码,在中文 Windows(936/932/950)下中文会吃掉
|
|
586
|
+
// 字符串引号 → 整段脚本语法错误 → 宿主**秒退**。
|
|
587
|
+
// 而发通知失败原本是"静默入队",用户既看不到浮窗、也拿不到任何报错。
|
|
588
|
+
// 所以这里把宿主的 stderr 留住,并在它启动后很快以非 0 退出时明确告警。
|
|
589
|
+
let hostStderr = ''
|
|
590
|
+
let spawnedAt = Date.now()
|
|
591
|
+
host.stderr.on('data', (d) => {
|
|
592
|
+
if (hostStderr.length < 8192) hostStderr += String(d)
|
|
593
|
+
})
|
|
594
|
+
host.stderr.on('error', () => {})
|
|
595
|
+
// exit 报告里带上"存活时长"与 stderr,便于直接定位。
|
|
596
|
+
// ③ 条件用 `code !== 0 || signal != null`:被信号杀死时 code 为 null、signal 才有值,
|
|
597
|
+
// 旧写法 `code !== 0 && code !== null` 会正好漏掉这种情况而不告警。
|
|
598
|
+
host.on('exit', (code, signal) => {
|
|
599
|
+
const livedMs = Date.now() - spawnedAt
|
|
600
|
+
hostAlive = false
|
|
601
|
+
activeToasts.clear()
|
|
602
|
+
toastOrder.length = 0
|
|
603
|
+
pendingClose.clear()
|
|
604
|
+
if (livedMs < HOST_EARLY_EXIT_MS && (code !== 0 || signal !== null)) {
|
|
605
|
+
// ② 记一次早退,据此退避,避免进程风暴
|
|
606
|
+
hostEarlyExits++
|
|
607
|
+
hostRestartUntil = Date.now() + Math.min(30000, 1000 * (2 ** hostEarlyExits))
|
|
608
|
+
// ④ 暴露给设置页(客户端据此显示横幅)
|
|
609
|
+
hostError = {
|
|
610
|
+
code: code === null ? null : Number(code),
|
|
611
|
+
signal: signal === null ? null : String(signal),
|
|
612
|
+
at: Date.now(),
|
|
613
|
+
count: hostEarlyExits,
|
|
614
|
+
retryAt: hostRestartUntil,
|
|
615
|
+
}
|
|
616
|
+
// ③ 一个故障周期内只告警一次
|
|
617
|
+
if (!hostWarned) {
|
|
618
|
+
hostWarned = true
|
|
619
|
+
const detail = hostStderr.trim().slice(0, 600)
|
|
620
|
+
console.warn(
|
|
621
|
+
`[dsh-notify-yimit] 通知宿主进程启动后 ${livedMs}ms 即退出` +
|
|
622
|
+
`(code=${code}${signal !== null ? `, signal=${signal}` : ''}),自定义通知将无法显示。` +
|
|
623
|
+
`最可能的原因:lib/toast-host.ps1 丢失了 UTF-8 BOM` +
|
|
624
|
+
`(中文 Windows 下 PS 5.1 会按 ANSI 解码导致语法错误)。` +
|
|
625
|
+
`已进入退避,${Math.round((hostRestartUntil - Date.now()) / 1000)}s 内不再重启。` +
|
|
626
|
+
(detail.length > 0 ? `\n--- 宿主 stderr ---\n${detail}` : '\n(宿主未输出 stderr)')
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
} else if (livedMs >= HOST_EARLY_EXIT_MS) {
|
|
630
|
+
// ② 活过阈值 = 健康,清零计数与告警状态(并可提示已恢复)。
|
|
631
|
+
if (hostEarlyExits > 0) {
|
|
632
|
+
console.warn('[dsh-notify-yimit] 通知宿主已恢复正常运行。')
|
|
633
|
+
}
|
|
634
|
+
hostEarlyExits = 0
|
|
635
|
+
hostRestartUntil = 0
|
|
636
|
+
hostWarned = false
|
|
637
|
+
// ④ 清除故障状态,设置页横幅随之收起
|
|
638
|
+
hostError = null
|
|
639
|
+
}
|
|
640
|
+
})
|
|
549
641
|
let buf = ''
|
|
550
642
|
host.stdout.on('data', (d) => {
|
|
551
643
|
buf += String(d)
|
|
@@ -617,13 +709,6 @@ export function apply(ctx) {
|
|
|
617
709
|
}
|
|
618
710
|
}
|
|
619
711
|
})
|
|
620
|
-
|
|
621
|
-
host.on('exit', () => {
|
|
622
|
-
hostAlive = false
|
|
623
|
-
activeToasts.clear()
|
|
624
|
-
toastOrder.length = 0
|
|
625
|
-
pendingClose.clear()
|
|
626
|
-
})
|
|
627
712
|
}
|
|
628
713
|
|
|
629
714
|
function sendCmd(payload) {
|
|
@@ -638,14 +723,11 @@ export function apply(ctx) {
|
|
|
638
723
|
|
|
639
724
|
function ensureHost() {
|
|
640
725
|
if (host !== null && hostAlive) return true
|
|
726
|
+
// ② 退避窗口内直接放弃:否则宿主秒退时每条通知都会重拉一个 powershell.exe。
|
|
727
|
+
// 退避时长由 exit 回调按"连续早退次数"设置(那里才有真实存活时长),
|
|
728
|
+
// 不能像旧代码那样在 spawn 之后同步判断 hostAlive —— 那是恒为 true 的。
|
|
641
729
|
if (Date.now() < hostRestartUntil) return false
|
|
642
730
|
spawnHost()
|
|
643
|
-
if (!hostAlive) {
|
|
644
|
-
hostRestartAttempts++
|
|
645
|
-
hostRestartUntil = Date.now() + Math.min(30000, 1000 * (2 ** hostRestartAttempts))
|
|
646
|
-
} else {
|
|
647
|
-
hostRestartAttempts = 0
|
|
648
|
-
}
|
|
649
731
|
return host !== null && hostAlive
|
|
650
732
|
}
|
|
651
733
|
|
|
@@ -1195,6 +1277,14 @@ export function apply(ctx) {
|
|
|
1195
1277
|
config: store.get(),
|
|
1196
1278
|
// 运行环境:桌面端(=Electron 宿主)还是 web 端。客户端据此隐藏只对浏览器有意义的设置项。
|
|
1197
1279
|
desktop: isDesktopRuntime === true,
|
|
1280
|
+
// ④ 宿主故障状态(null = 正常)。客户端在设置页渲染成横幅,让"通知不显示"
|
|
1281
|
+
// 这件事从"静默失败"变成可见、可解释。
|
|
1282
|
+
hostError: hostError === null ? null : {
|
|
1283
|
+
...hostError
|
|
1284
|
+
},
|
|
1285
|
+
// 宿主不可用时,通知会堆在插件侧的 queue 里(见 showDesktopToast 的失败分支)。
|
|
1286
|
+
// 给客户端一个条数,用于告诉用户"积压了多少条"。
|
|
1287
|
+
pendingCount: Math.max(0, queue.length - queueHead),
|
|
1198
1288
|
events: queue.slice(queueHead).map((item) => ({
|
|
1199
1289
|
...item
|
|
1200
1290
|
}))
|
|
@@ -1272,6 +1362,22 @@ export function apply(ctx) {
|
|
|
1272
1362
|
ok: true
|
|
1273
1363
|
}
|
|
1274
1364
|
},
|
|
1365
|
+
/**
|
|
1366
|
+
* ④ 立即重试拉起通知宿主(设置页横幅上的「重新拉起宿主」)。
|
|
1367
|
+
* 清掉退避窗口与故障状态后立刻 spawn;若又秒退,exit 回调会重新置上
|
|
1368
|
+
* hostError 并按新的连续早退次数退避。
|
|
1369
|
+
*/
|
|
1370
|
+
retryHost() {
|
|
1371
|
+
hostEarlyExits = 0
|
|
1372
|
+
hostRestartUntil = 0
|
|
1373
|
+
hostWarned = false
|
|
1374
|
+
hostError = null
|
|
1375
|
+
hostAlive = false
|
|
1376
|
+
spawnHost()
|
|
1377
|
+
return {
|
|
1378
|
+
ok: hostAlive
|
|
1379
|
+
}
|
|
1380
|
+
},
|
|
1275
1381
|
}
|
|
1276
1382
|
|
|
1277
1383
|
Object.defineProperty(service, 'typertRemote', {
|
package/lib/toast-host.ps1
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
<# dsh-notify-yimit 常驻浮窗宿主(Windows PowerShell + WPF)。
|
|
1
|
+
<# dsh-notify-yimit 常驻浮窗宿主(Windows PowerShell + WPF)。
|
|
2
|
+
|
|
3
|
+
★★★ 编码要求:本文件必须保存为「UTF-8 with BOM」(文件头 EF BB BF)。★★★
|
|
4
|
+
本文件含大量中文,而宿主是用 Windows PowerShell 5.1(`powershell.exe`)拉起的,
|
|
5
|
+
PS 5.1 对**没有 BOM** 的 .ps1 会按**系统 ANSI 代码页**解码。在中文 Windows
|
|
6
|
+
(ACP=936/GBK,以及 932/950)下,中文字节会被错误解码、吃掉字符串收尾引号,
|
|
7
|
+
导致整段脚本语法错误 —— 宿主进程秒退,**所有自定义通知全部不显示,且没有任何报错**。
|
|
8
|
+
表现特征:报错行往往是纯 ASCII 行(例如 `$q.Enqueue('{"cmd":"shutdown"}')`),
|
|
9
|
+
真正的病灶在它上面的中文行。
|
|
10
|
+
注意:ACP=65001(UTF-8)的机器上不会复现,PS 7(pwsh)也不会复现 —— 所以本地验证
|
|
11
|
+
必须按 936 解码后再交给解析器,或直接用带 BOM 的文件。
|
|
12
|
+
凡是重写/另存本文件后,请重新确认 BOM 仍在(编辑器与多数脚本化改写都会丢掉它)。
|
|
2
13
|
|
|
3
14
|
倒计时即进度(本版核心变化):
|
|
4
15
|
- 非粘性通知整卡抽象为倒计时:强调色 @12% "液面层"锚定左缘,ScaleX 1→0,
|
|
@@ -667,9 +678,14 @@ public class ToastWin32 {
|
|
|
667
678
|
$script:ToastWin32Ready = $false
|
|
668
679
|
}
|
|
669
680
|
|
|
670
|
-
function Write-ActLog([string]$msg) {
|
|
681
|
+
function Write-ActLog([string]$msg, [switch]$Always) {
|
|
671
682
|
# 诊断日志:激活这一步涉及"按 exe 路径找进程 + 枚举窗口"两处环境相关行为,
|
|
672
683
|
# 出问题时按钮只会静默无反应,没有日志无法定位。
|
|
684
|
+
#
|
|
685
|
+
# 写入策略(避免往用户安装目录里持续写日志):
|
|
686
|
+
# · -Always:仅启动探针使用,每次宿主加载写一行(极小,且是诊断秒退的关键线索);
|
|
687
|
+
# · 其余(逐次点击的明细)只有设了 DSH_DEBUG 才写 —— 与插件其它调试输出的约定一致。
|
|
688
|
+
if (-not $Always -and [string]::IsNullOrEmpty($env:DSH_DEBUG)) { return }
|
|
673
689
|
# 落点优先级:脚本所在目录(真实宿主 = lib/)→ DSH_HOME。
|
|
674
690
|
try {
|
|
675
691
|
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg`r`n"
|
|
@@ -693,7 +709,7 @@ try {
|
|
|
693
709
|
try { $probe += " selfExe='$((Get-Process -Id $PID).Path)'" } catch { $probe += " selfExe=<取不到>" }
|
|
694
710
|
try { $probe += " DSH_HOME='$env:DSH_HOME'" } catch { }
|
|
695
711
|
try { $probe += " PSScriptRoot='$PSScriptRoot'" } catch { }
|
|
696
|
-
Write-ActLog $probe
|
|
712
|
+
Write-ActLog $probe -Always
|
|
697
713
|
} catch { }
|
|
698
714
|
|
|
699
715
|
function Invoke-ToastActivateApp([string]$appPath) {
|
package/lib/typert.host.js
CHANGED
|
@@ -52,10 +52,27 @@ const notifyEventSchema = z.object({
|
|
|
52
52
|
at: z.number(),
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 宿主故障状态(④ 设置页横幅的数据源)。
|
|
57
|
+
* null = 正常。旧宿主不返回该字段,靠 .default(null) 兼容。
|
|
58
|
+
*/
|
|
59
|
+
const hostErrorSchema = z.object({
|
|
60
|
+
code: z.number().nullable(),
|
|
61
|
+
signal: z.string().nullable(),
|
|
62
|
+
at: z.number(),
|
|
63
|
+
count: z.number(),
|
|
64
|
+
retryAt: z.number(),
|
|
65
|
+
}).nullable().default(null)
|
|
66
|
+
|
|
55
67
|
const getStateSchema = z.object({
|
|
56
68
|
config: configSchema,
|
|
57
69
|
// 可选+默认 false:旧宿主(无该字段)仍能通过校验,按 web 端处理。
|
|
58
70
|
desktop: z.boolean().default(false),
|
|
71
|
+
// 可选+默认:旧宿主无这两个字段也能通过校验。
|
|
72
|
+
// 注意 zod 的 z.object 会**丢弃**未声明字段,所以新增字段必须在这里声明,
|
|
73
|
+
// 否则宿主返回了也会被 codec 吃掉(已实测确认)。
|
|
74
|
+
hostError: hostErrorSchema,
|
|
75
|
+
pendingCount: z.number().default(0),
|
|
59
76
|
events: z.array(notifyEventSchema),
|
|
60
77
|
})
|
|
61
78
|
|
|
@@ -179,6 +196,17 @@ export const TYPERT = {
|
|
|
179
196
|
parameters: [],
|
|
180
197
|
result: _ack$codec,
|
|
181
198
|
},
|
|
199
|
+
{
|
|
200
|
+
id: 'dsh-notify-yimit#notify/retryHost',
|
|
201
|
+
service: 'notify',
|
|
202
|
+
namespace: 'notify',
|
|
203
|
+
method: 'retryHost',
|
|
204
|
+
invocation: {
|
|
205
|
+
kind: 'direct'
|
|
206
|
+
},
|
|
207
|
+
parameters: [],
|
|
208
|
+
result: _ack$codec,
|
|
209
|
+
},
|
|
182
210
|
],
|
|
183
211
|
model: {
|
|
184
212
|
services: [{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-yimit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "DeepSeek Harness 通知插件:任务完成 / 任务出错 / 运行中(实时活动)/ 等待审批 / 等待回答 时,通过 Windows 系统通知或插件自定义样式通知提醒用户,支持点击跳转到对应会话;设置页集成在 DSH 设置界面。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -33,12 +33,14 @@
|
|
|
33
33
|
"lib/typert.host.js",
|
|
34
34
|
"lib/client.js",
|
|
35
35
|
"lib/toast-host.ps1",
|
|
36
|
+
"scripts/check-prepublish.mjs",
|
|
36
37
|
"cordis.patch.yml",
|
|
37
38
|
"README.md",
|
|
38
39
|
"README.en.md"
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
|
-
"prepublishOnly": "node -
|
|
42
|
+
"prepublishOnly": "node scripts/check-prepublish.mjs",
|
|
43
|
+
"check:prepublish": "node scripts/check-prepublish.mjs"
|
|
42
44
|
},
|
|
43
45
|
"repository": {
|
|
44
46
|
"type": "git",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发布前守卫(prepublishOnly 调用)。
|
|
3
|
+
*
|
|
4
|
+
* 目前只做两件事,都是"发布后才会暴露、且用户端极难自查"的机械检查:
|
|
5
|
+
*
|
|
6
|
+
* 1. host 包不能出现在 dependencies —— 它们必须留在 peerDependencies,
|
|
7
|
+
* 否则 pnpm 会把 @deepseek-ai/* 复制进 profile 的 node_modules,
|
|
8
|
+
* 顶掉宿主自带的同名插件并破坏 settings namespace 注册。
|
|
9
|
+
*
|
|
10
|
+
* 2. lib/toast-host.ps1 必须带 UTF-8 BOM。该文件含大量中文,而宿主是
|
|
11
|
+
* Windows PowerShell 5.1 拉起的:PS 5.1 对**没有 BOM** 的 .ps1 会按系统
|
|
12
|
+
* ANSI 代码页解码,在中文 Windows(936/932/950)下中文会吃掉字符串收尾引号,
|
|
13
|
+
* 导致整段脚本语法错误 → 宿主秒退 → **所有自定义通知都不显示,且没有任何报错**。
|
|
14
|
+
* 只有双字节代码页(DBCS)才会破坏语法,所以英文/UTF-8 ACP 的机器上
|
|
15
|
+
* 完全测不出来 —— 必须在发布前机械拦截。
|
|
16
|
+
*
|
|
17
|
+
* 失败一律以非 0 退出码结束,让 npm publish 中止。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
const failures = []
|
|
25
|
+
|
|
26
|
+
// ── 1. dependencies 里不允许出现 @deepseek-ai/* ──
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
29
|
+
const bad = Object.keys(pkg.dependencies ?? {}).filter((k) => k.startsWith('@deepseek-ai/'))
|
|
30
|
+
if (bad.length > 0) {
|
|
31
|
+
failures.push(`host 包必须放在 peerDependencies,却出现在 dependencies: ${bad.join(', ')}`)
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
failures.push(`读取 package.json 失败: ${String(error)}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── 2. lib/toast-host.ps1 必须带 UTF-8 BOM ──
|
|
38
|
+
const HOST_SCRIPT = 'lib/toast-host.ps1'
|
|
39
|
+
try {
|
|
40
|
+
const bytes = readFileSync(join(root, HOST_SCRIPT))
|
|
41
|
+
if (!(bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF)) {
|
|
42
|
+
const head = [...bytes.subarray(0, 3)].map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(' ')
|
|
43
|
+
failures.push(
|
|
44
|
+
`${HOST_SCRIPT} 缺少 UTF-8 BOM(文件头是 ${head},应为 EF BB BF)。\n` +
|
|
45
|
+
' 中文 Windows 下 PowerShell 5.1 会按 ANSI 解码 → 脚本语法错误 → 宿主秒退,\n' +
|
|
46
|
+
' 用户端表现为"所有自定义通知都不显示且无任何报错"。\n' +
|
|
47
|
+
' 请把该文件另存为「UTF-8 with BOM」后重试。'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
failures.push(`读取 ${HOST_SCRIPT} 失败: ${String(error)}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (failures.length > 0) {
|
|
55
|
+
console.error('\n[dsh-notify-yimit] 发布前检查未通过:\n')
|
|
56
|
+
for (const f of failures) console.error(` ✗ ${f}`)
|
|
57
|
+
console.error('')
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
console.log('[dsh-notify-yimit] 发布前检查通过 ✓')
|