diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cb30a..820c6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。 +## v1.0.6 + +- 修复:**窗口不在前台时,提醒有可能一次都看不到**。排查证据:一次真实投递(会话 17:52:29 结束)在插件页留下 `09:52:30 · 系统通知`——`09:52:30` 是 UTC,本机时间是 17:52:30;而 Windows 的通知平台记录 `LastNotificationAddedTime` 停在 70 分钟前。也就是说 `new Notification()` 没抛错,但**构造函数成功不等于通知真的弹出来了**,加上系统通知的气泡只存在几秒,人不在电脑前时它等于没送达。这一版不再把"发出去"当成"收到了": + - 每条系统通知都订阅 `show` / `error`:系统是否确认显示会写进插件页的「最近一次投递」,不再只报"系统通知"三个字; + - **凡是在窗口不在前台时投递的提醒,都会在窗口回到前台的那一刻用应用内轻弹窗补发一次**。同一会话同一类只保留最新一条,超过 30 分钟不再补发;"正在看的会话不打扰"这条规则不适用于补发,因为它发生的时候你并不在看。唯一的例外是系统确认气泡已弹出、且你在 20 秒内就回到窗口(气泡还在屏幕上,不再重复); + - 点掉系统通知本身(`click`)会取消对应的补发,不重复打扰。 +- 修复「最近一次投递」的时间显示成 UTC(比本地时间早 8 小时,17:52 显示成 09:52)的问题,现在显示本机时间。 +- 修复确认窗口的队列会把提醒永久卡住的问题:一次确认窗口只投递一条,剩下没轮到的会自己再排一次(以前排完之后队列里再没人触发,后面的提醒就被吞了)。 +- 系统通知对象改为有界持有引用:防御性改动,避免显示还没落定时对象被回收(实测中保留引用与否都能送达,所以这不是那次丢失的原因)。 +- 插件页「运行状态」新增一行:还有几条提醒在等你回到窗口时补发。 + ## v1.0.5 - 修复:**应用在后台时不发系统通知**。前台/后台以前只靠 `window` 的 `focus`/`blur` 事件记住,桌面外壳里 `blur` 可能不到,插件就一直以为窗口在前台,于是走了轻弹窗分支(而用户没在看,表现就是完全没有通知)。现在每次投递都现读 `document.visibilityState` 与 `document.hasFocus()`,任一表示不可见即走系统通知;观察器订阅的事件只是让重算更及时,不再决定通道。 diff --git a/README.md b/README.md index e13c271..8691680 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗 | 窗口在**前台**,但不是你正在看的那个会话 | **应用内轻弹窗**:右上角浮出一张卡片,标题是提醒类型,下面一行是会话题目与细节 | | 窗口在**前台**,且正是你**在看的**那个会话 | 不打扰(你就在看着,不需要弹) | +系统通知的气泡只存在几秒,**人不在电脑前时它等于没送到**,所以: + +- 凡是在窗口不在前台时投递的提醒,**窗口回到前台的那一刻会再用轻弹窗补发一次**(同一会话同一类只留最新一条,超过 30 分钟不再补发); +- 唯一的例外:系统确认气泡已经弹出来、而且你 20 秒内就回到窗口——那条气泡还在屏幕上,就不再重复; +- 补发不受「正在看的会话不打扰」限制——这条提醒发生的时候你并不在看; +- 点掉系统通知本身就会取消对应的补发; +- 插件页「最近一次投递」会写清系统到底有没有确认显示(`系统已确认弹出` / `系统没有回报` / `没弹出来`)。 + 轻弹窗的用法: - 点「查看」跳到该会话; @@ -39,7 +47,7 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗 | --- | --- | | 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 | | 系统通知权限 | 当前权限状态;尚未授权时点「允许通知」请求授权(浏览器要求必须由你手动点击),授权成功后会自动发一条测试提醒。权限被系统层关闭时,会按当前平台告诉你去哪里打开 | -| 运行状态 | 窗口现在是前台还是后台(即会走哪条通道)、插件是否真的在读会话状态、以及**最近一次提醒走的通道与时间**;旁边「测试提醒」按钮发一条测试提醒 | +| 运行状态 | 窗口现在是前台还是后台(即会走哪条通道)、插件是否真的在读会话状态、**最近一次提醒走的通道、系统是否确认弹出、时间**(本机时间),以及还有几条提醒在等你回到窗口时补发;旁边「测试提醒」按钮发一条测试提醒 | - 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。 - 开关只影响提醒,不改 DSH 本身的任何设置。 @@ -51,10 +59,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗 在 DSH 的 **插件 → 添加插件** 的上方输入框里填: ```text -https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.5 +https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.6 ``` -`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.5`。仓库是公开的,不需要凭据,也不用改「安装源」。 +`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.6`。仓库是公开的,不需要凭据,也不用改「安装源」。 ### 本地路径安装 @@ -79,6 +87,7 @@ D:\DeepSeek Harness Plugins\dsh-session-notify - **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏。 - **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。 - **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。 +- **窗口不在前台时投递过的提醒,回到前台会补发一次轻弹窗**(30 分钟内有效)。 - 提醒只在 DSH 运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。 ## 平台 diff --git a/client.js b/client.js index d9e6c5a..e1d5391 100644 --- a/client.js +++ b/client.js @@ -49,6 +49,18 @@ window.__ModuleLoader__.load({ /** In-app popup lifetime, and how many may stack. */ const TOAST_DURATION_MS = 6000 const TOAST_LIMIT = 3 + /** Notifications kept referenced, so a collection can never cancel a pending display. */ + const RAISED_LIMIT = 8 + /** Alerts carried by the system channel and still worth replaying in-app on return. */ + const REPLAY_LIMIT = 8 + /** How long a system notification outlives the moment the user comes back. */ + const REPLAY_TTL_MS = 30 * 60 * 1000 + /** + * A confirmed banner that the user came back to within this window is treated as + * seen: the desktop notification is still on screen at that moment, so replaying + * it in-app would be a duplicate rather than a reminder. + */ + const REPLAY_QUIET_MS = 20 * 1000 const zh = { 'notification.completion': '会话已完成', @@ -98,9 +110,14 @@ window.__ModuleLoader__.load({ 'config.diag.away': '不在前台(会走系统通知)', 'config.diag.last': '最近一次投递', 'config.diag.none': '还没有投递过', - 'config.diag.system': '系统通知', + 'config.diag.systemShown': '系统通知(系统已确认弹出)', + 'config.diag.systemUnconfirmed': '系统通知(系统没有回报,回到窗口时补发轻弹窗)', + 'config.diag.systemFailed': '系统通知没弹出来,回到窗口时补发轻弹窗', + 'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)', 'config.diag.popup': '应用内轻弹窗', 'config.diag.refused': '系统通知被拒绝,改用了轻弹窗', + 'config.diag.replay': '等你回到窗口时补发:{count} 条', + 'config.diag.replayNone': '没有待补发的提醒', } const en = { 'notification.completion': 'Conversation finished', @@ -150,9 +167,14 @@ window.__ModuleLoader__.load({ 'config.diag.away': 'not in front (system notification is used)', 'config.diag.last': 'Last delivery', 'config.diag.none': 'nothing delivered yet', - 'config.diag.system': 'system notification', + 'config.diag.systemShown': 'system notification (confirmed on screen)', + 'config.diag.systemUnconfirmed': 'system notification (no confirmation, replayed in-app on return)', + 'config.diag.systemFailed': 'system notification never appeared, replayed in-app on return', + 'config.diag.replayDelivery': 'in-app popup (replayed when you came back)', 'config.diag.popup': 'in-app popup', 'config.diag.refused': 'system channel refused, popup was used instead', + 'config.diag.replay': 'waiting for you to come back: {count}', + 'config.diag.replayNone': 'nothing waiting to be replayed', } /** @@ -563,6 +585,11 @@ window.__ModuleLoader__.load({ settleTimer: 0, lastDelivery: 0, toastSeq: 0, + deliverySeq: 0, + /** System notifications this page still holds open, so nothing collects them mid-display. */ + raised: new Set(), + /** Alerts the user has not been able to see yet, replayed in-app when the window returns. */ + replay: [], live: { list: undefined, status: undefined, @@ -677,14 +704,47 @@ window.__ModuleLoader__.load({ } /** - * Raise one system notification, tolerating every refusal a browser may give. - * @returns the outcome the config page reports, so a refusal is never silent. + * The system-notification channel's own answer about one notification. + * + * A browser reports the platform's verdict asynchronously: `show` means the + * notification reached the desktop, `error` means the platform refused it, and + * silence means neither answer arrived. That third case is the one that used to + * lose alerts silently, so it is recorded rather than assumed to be a success. + * @param monitor - the record returned by {@link systemNotify} for this notification. + * @param shown - `true` when the platform displayed it, `false` when it refused. + */ + const confirmDelivery = (monitor, shown) => { + if (monitor === null || monitor === undefined || typeof monitor !== 'object') return + monitor.shown = shown + const snapshot = store.getSnapshot() + const last = snapshot.lastDelivery + if (last === null || last === undefined || last.monitor !== monitor || last.shown === shown) return + store.set({ ...snapshot, lastDelivery: { ...last, shown } }) + } + + /** Hold one raised notification open, bounded, so it cannot be collected while pending. */ + const keepRaised = (notification) => { + state.raised.add(notification) + while (state.raised.size > RAISED_LIMIT) { + const oldest = state.raised.values().next().value + if (oldest === undefined) break + state.raised.delete(oldest) + } + } + + /** + * Raise one system notification, tolerating every refusal a browser may give, + * and report what the platform said about it. + * @returns a record whose `outcome` is the channel's verdict (`raised`, + * `permission`, `unsupported`, or `threw`) and whose `shown` is filled in + * later by {@link confirmDelivery} — `true` displayed, `false` refused, + * `undefined` while the platform has said nothing. */ const systemNotify = (candidate, force) => { const Ctor = globalThis.Notification const permission = notificationPermission() - if (permission === 'unsupported') return 'unsupported' - if (!force && permission !== 'granted') return 'permission' + if (permission === 'unsupported') return { outcome: 'unsupported' } + if (!force && permission !== 'granted') return { outcome: 'permission' } try { const notice = copyFor(candidate) const options = { @@ -694,7 +754,20 @@ window.__ModuleLoader__.load({ } if (systemIcon !== undefined) options.icon = systemIcon const notification = new Ctor(notice.title, options) + const record = { outcome: 'raised', notice, shown: undefined } + keepRaised(notification) + notification.onshow = () => { + confirmDelivery(record, true) + } + notification.onerror = (event) => { + confirmDelivery(record, false) + notify(`system notification was refused: ${String(event?.message ?? 'unknown')}`) + } + notification.onclose = () => { + state.raised.delete(notification) + } notification.onclick = () => { + dropReplay(candidate) focusWindow() openSession(candidate.sessionId) try { @@ -703,13 +776,70 @@ window.__ModuleLoader__.load({ /* close is best effort on every platform */ } } - return 'raised' + return record } catch (error) { notify(`system notification failed: ${text(error)}`) - return 'threw' + return { outcome: 'threw' } } } + // ------------------------------------------------------------ replay + + /** Forget one queued replay: the user has just answered that alert in the system channel. */ + const dropReplay = (candidate) => { + const next = state.replay.filter((entry) => ( + entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind + )) + if (next.length === state.replay.length) return + state.replay = next + publish() + } + + /** + * Keep one away-channel alert for an in-app replay. + * + * A desktop notification raised while nobody is looking at the machine is + * easy to miss: its banner lives a few seconds, its history lives in the + * system's own notification centre, which the user may never open, and a + * refused display leaves no trace at all on the page. The alert is therefore + * replayed as a light in-app popup as soon as the window is back in the + * foreground, except when the platform confirmed the banner and the user was + * back within {@link REPLAY_QUIET_MS} — then the banner is still on screen and + * a second surface would only be noise. Entries older than + * {@link REPLAY_TTL_MS} are dropped instead of waiting for a user who has + * moved on. + * @param candidate - the alert to keep for a replay. + * @param monitor - the system channel's own record, so its confirmation can be read later. + */ + const holdForReplay = (candidate, monitor) => { + const now = Date.now() + state.replay = [ + ...state.replay.filter((entry) => ( + entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind + )), + { candidate, at: now, monitor }, + ].slice(-REPLAY_LIMIT) + publish() + } + + /** Show every away-channel alert the user has not had a chance to see yet. */ + const flushReplay = () => { + if (state.replay.length === 0) return + const now = Date.now() + const waiting = state.replay + state.replay = [] + const due = waiting.filter((entry) => { + if (now - entry.at > REPLAY_TTL_MS) return false + // A banner that the platform confirmed and that is still inside its own + // visible window has already told the user; do not say it twice. + return !(entry.monitor?.shown === true && now - entry.at <= REPLAY_QUIET_MS) + }) + const shown = due.slice(-TOAST_LIMIT) + for (const entry of shown) showToast(entry.candidate) + if (shown.length > 0) recordDelivery('replay', shown[shown.length - 1].candidate) + else publish() + } + // -------------------------------------------------------------- delivery /** Whether the user is looking at this exact conversation right now. */ @@ -729,16 +859,28 @@ window.__ModuleLoader__.load({ * never appeared, with no way to tell a wrong channel decision from a * refused constructor. The config page shows this record, so the next * question ("did it even try?") has an answer. + * + * The time is the user's own clock, not UTC: a log line the reader has to + * translate by eight hours is worse than no log line. + * @param outcome - which channel carried the alert, or why it fell back. + * @param candidate - the alert being recorded. + * @param monitor - the system channel's own record, when that is the channel. + * @returns the stored record, so a later platform answer can update it. */ - const recordDelivery = (outcome, candidate) => { - store.set({ - ...store.getSnapshot(), - lastDelivery: { - outcome, - at: new Date().toISOString().slice(11, 19), - title: copyFor(candidate).title, - }, - }) + const recordDelivery = (outcome, candidate, monitor) => { + state.deliverySeq += 1 + const now = new Date() + const pad = (value) => String(value).padStart(2, '0') + const record = { + seq: state.deliverySeq, + outcome, + at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, + title: copyFor(candidate).title, + shown: monitor?.shown, + monitor, + } + store.set({ ...store.getSnapshot(), lastDelivery: record }) + return record } /** @@ -765,7 +907,10 @@ window.__ModuleLoader__.load({ * * The channel decision reads the window state right here, at delivery time, * instead of trusting the state the observer last remembered — that is what - * an unreported blur used to defeat. + * an unreported blur used to defeat. An alert that leaves through the system + * channel is additionally held for an in-app replay, because a desktop banner + * raised while nobody is looking at the machine is a notification the user + * never actually receives. */ const deliver = (candidate, ignoreThrottle) => { if (ignoreThrottle !== true) { @@ -779,26 +924,31 @@ window.__ModuleLoader__.load({ recordDelivery('popup', candidate) return } - const outcome = systemNotify(candidate, candidate.test === true) - if (outcome === 'raised') { - recordDelivery('system', candidate) + const result = systemNotify(candidate, candidate.test === true) + if (result.outcome === 'raised') { + recordDelivery('system', candidate, result) + if (candidate.test !== true) holdForReplay(candidate, result) return } // A test alert still has to reach the user, and so does a real one when // the system channel refuses: the popup is the channel that remains. showToast(candidate) - recordDelivery(`system-refused-${outcome}`, candidate) + recordDelivery(`system-refused-${result.outcome}`, candidate) } /** * Take the delivery decision on a later tick, from the freshest state. + * + * One candidate per tick keeps the throttle meaningful, and anything still + * queued arms its own follow-up tick — a burst used to leave every candidate + * after the first stranded in the queue with no timer to flush it. */ const flushSettle = () => { state.settleTimer = 0 + if (disposed) return const candidate = state.pending.shift() - if (disposed || candidate === undefined) return - if (!stillWorth(candidate)) return - deliver(candidate, false) + if (candidate !== undefined && stillWorth(candidate)) deliver(candidate, false) + if (state.pending.length > 0) state.settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) } /** Hold one candidate for the settle tick, collapsing a burst onto one timer. */ @@ -903,13 +1053,13 @@ window.__ModuleLoader__.load({ /** Raise the test alert on the system channel specifically, whatever the focus is. */ const sendTestSystem = () => { const candidate = testCandidate(t('config.testSystem')) - const outcome = systemNotify(candidate, true) - if (outcome === 'raised') { - recordDelivery('system', candidate) + const result = systemNotify(candidate, true) + if (result.outcome === 'raised') { + recordDelivery('system', candidate, result) return } showToast(candidate) - recordDelivery(`system-refused-${outcome}`, candidate) + recordDelivery(`system-refused-${result.outcome}`, candidate) } /** Request notification permission inside a user gesture, then report the outcome. */ @@ -1159,7 +1309,8 @@ window.__ModuleLoader__.load({ className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`, role: healthFailed ? 'alert' : 'status', }, healthText), - h('p', { className: 'dsn-status' }, deliveryText(tr))), + h('p', { className: 'dsn-status' }, deliveryText(tr)), + h('p', { className: 'dsn-status' }, replayText(tr))), h('button', { type: 'button', className: 'dsn-button', @@ -1174,28 +1325,52 @@ window.__ModuleLoader__.load({ const outcome = last.outcome.startsWith('system-refused') ? tr('config.diag.refused') : last.outcome === 'system' - ? tr('config.diag.system') - : tr('config.diag.popup') + ? last.shown === true + ? tr('config.diag.systemShown') + : last.shown === false + ? tr('config.diag.systemFailed') + : tr('config.diag.systemUnconfirmed') + : last.outcome === 'replay' + ? tr('config.diag.replayDelivery') + : tr('config.diag.popup') return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}` } + /** Say how many away-channel alerts are still waiting to be replayed in-app. */ + function replayText(tr) { + const pending = state.replay.length + return pending === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count: pending }) + } + // ---------------------------------------------------------- registration /** Keep the page's own focus events owned by this plugin's effect. */ const installBridges = () => { - const onFocusChange = () => { + const onBlur = () => { state.lastDelivery = 0 } + // Coming back is the moment the user can finally be told about whatever the + // system channel carried while the window was away, and it also clears the + // throttle so the first alert after returning is never swallowed. + const onReturn = () => { + state.lastDelivery = 0 + flushReplay() + } + const onVisibilityChange = () => { + if (!windowIsAway()) onReturn() + } try { - window.addEventListener('focus', onFocusChange) - window.addEventListener('blur', onFocusChange) + window.addEventListener('focus', onReturn) + window.addEventListener('blur', onBlur) + document.addEventListener('visibilitychange', onVisibilityChange) } catch { /* an unreadable window still delivers through live focus reads */ } return () => { try { - window.removeEventListener('focus', onFocusChange) - window.removeEventListener('blur', onFocusChange) + window.removeEventListener('focus', onReturn) + window.removeEventListener('blur', onBlur) + document.removeEventListener('visibilitychange', onVisibilityChange) } catch { /* nothing to detach */ } @@ -1205,6 +1380,8 @@ window.__ModuleLoader__.load({ } for (const timer of timers.values()) clearTimeout(timer) timers.clear() + state.raised.clear() + state.replay = [] } } diff --git a/package.json b/package.json index a8e8ca6..862ff48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dsh-plugin/session-notify", - "version": "1.0.5", + "version": "1.0.6", "private": true, "type": "module", "description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",