🐛 fix(client): 后台提醒回到窗口补发轻弹窗,系统通知记录是否弹出

This commit is contained in:
pyh
2026-09-30 19:13:59 +08:00
parent 080d873f9a
commit 3986aac863
4 changed files with 238 additions and 41 deletions
+11
View File
@@ -2,6 +2,17 @@
版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。 版本说明按倒序排列。提交信息遵循 [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 ## v1.0.5
- 修复:**应用在后台时不发系统通知**。前台/后台以前只靠 `window` 的 `focus`/`blur` 事件记住,桌面外壳里 `blur` 可能不到,插件就一直以为窗口在前台,于是走了轻弹窗分支(而用户没在看,表现就是完全没有通知)。现在每次投递都现读 `document.visibilityState` 与 `document.hasFocus()`,任一表示不可见即走系统通知;观察器订阅的事件只是让重算更及时,不再决定通道。 - 修复:**应用在后台时不发系统通知**。前台/后台以前只靠 `window` 的 `focus`/`blur` 事件记住,桌面外壳里 `blur` 可能不到,插件就一直以为窗口在前台,于是走了轻弹窗分支(而用户没在看,表现就是完全没有通知)。现在每次投递都现读 `document.visibilityState` 与 `document.hasFocus()`,任一表示不可见即走系统通知;观察器订阅的事件只是让重算更及时,不再决定通道。
+12 -3
View File
@@ -24,6 +24,14 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
| 窗口在**前台**,但不是你正在看的那个会话 | **应用内轻弹窗**:右上角浮出一张卡片,标题是提醒类型,下面一行是会话题目与细节 | | 窗口在**前台**,但不是你正在看的那个会话 | **应用内轻弹窗**:右上角浮出一张卡片,标题是提醒类型,下面一行是会话题目与细节 |
| 窗口在**前台**,且正是你**在看的**那个会话 | 不打扰(你就在看着,不需要弹) | | 窗口在**前台**,且正是你**在看的**那个会话 | 不打扰(你就在看着,不需要弹) |
系统通知的气泡只存在几秒,**人不在电脑前时它等于没送到**,所以:
- 凡是在窗口不在前台时投递的提醒,**窗口回到前台的那一刻会再用轻弹窗补发一次**(同一会话同一类只留最新一条,超过 30 分钟不再补发);
- 唯一的例外:系统确认气泡已经弹出来、而且你 20 秒内就回到窗口——那条气泡还在屏幕上,就不再重复;
- 补发不受「正在看的会话不打扰」限制——这条提醒发生的时候你并不在看;
- 点掉系统通知本身就会取消对应的补发;
- 插件页「最近一次投递」会写清系统到底有没有确认显示(`系统已确认弹出` / `系统没有回报` / `没弹出来`)。
轻弹窗的用法: 轻弹窗的用法:
- 点「查看」跳到该会话; - 点「查看」跳到该会话;
@@ -39,7 +47,7 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
| --- | --- | | --- | --- |
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 | | 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
| 系统通知权限 | 当前权限状态;尚未授权时点「允许通知」请求授权(浏览器要求必须由你手动点击),授权成功后会自动发一条测试提醒。权限被系统层关闭时,会按当前平台告诉你去哪里打开 | | 系统通知权限 | 当前权限状态;尚未授权时点「允许通知」请求授权(浏览器要求必须由你手动点击),授权成功后会自动发一条测试提醒。权限被系统层关闭时,会按当前平台告诉你去哪里打开 |
| 运行状态 | 窗口现在是前台还是后台(即会走哪条通道)、插件是否真的在读会话状态、以及**最近一次提醒走的通道与时间**;旁边「测试提醒」按钮发一条测试提醒 | | 运行状态 | 窗口现在是前台还是后台(即会走哪条通道)、插件是否真的在读会话状态、**最近一次提醒走的通道、系统是否确认弹出、时间**(本机时间),以及还有几条提醒在等你回到窗口时补发;旁边「测试提醒」按钮发一条测试提醒 |
- 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。 - 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。
- 开关只影响提醒,不改 DSH 本身的任何设置。 - 开关只影响提醒,不改 DSH 本身的任何设置。
@@ -51,10 +59,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
在 DSH 的 **插件 → 添加插件** 的上方输入框里填: 在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
```text ```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 秒内只投递一条,多个会话同时完成不会刷屏。 - **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏。
- **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。 - **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。 - **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。
- **窗口不在前台时投递过的提醒,回到前台会补发一次轻弹窗**(30 分钟内有效)。
- 提醒只在 DSH 运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。 - 提醒只在 DSH 运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。
## 平台 ## 平台
+214 -37
View File
@@ -49,6 +49,18 @@ window.__ModuleLoader__.load({
/** In-app popup lifetime, and how many may stack. */ /** In-app popup lifetime, and how many may stack. */
const TOAST_DURATION_MS = 6000 const TOAST_DURATION_MS = 6000
const TOAST_LIMIT = 3 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 = { const zh = {
'notification.completion': '会话已完成', 'notification.completion': '会话已完成',
@@ -98,9 +110,14 @@ window.__ModuleLoader__.load({
'config.diag.away': '不在前台(会走系统通知)', 'config.diag.away': '不在前台(会走系统通知)',
'config.diag.last': '最近一次投递', 'config.diag.last': '最近一次投递',
'config.diag.none': '还没有投递过', 'config.diag.none': '还没有投递过',
'config.diag.system': '系统通知', 'config.diag.systemShown': '系统通知(系统已确认弹出)',
'config.diag.systemUnconfirmed': '系统通知(系统没有回报,回到窗口时补发轻弹窗)',
'config.diag.systemFailed': '系统通知没弹出来,回到窗口时补发轻弹窗',
'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)',
'config.diag.popup': '应用内轻弹窗', 'config.diag.popup': '应用内轻弹窗',
'config.diag.refused': '系统通知被拒绝,改用了轻弹窗', 'config.diag.refused': '系统通知被拒绝,改用了轻弹窗',
'config.diag.replay': '等你回到窗口时补发:{count} 条',
'config.diag.replayNone': '没有待补发的提醒',
} }
const en = { const en = {
'notification.completion': 'Conversation finished', 'notification.completion': 'Conversation finished',
@@ -150,9 +167,14 @@ window.__ModuleLoader__.load({
'config.diag.away': 'not in front (system notification is used)', 'config.diag.away': 'not in front (system notification is used)',
'config.diag.last': 'Last delivery', 'config.diag.last': 'Last delivery',
'config.diag.none': 'nothing delivered yet', '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.popup': 'in-app popup',
'config.diag.refused': 'system channel refused, popup was used instead', '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, settleTimer: 0,
lastDelivery: 0, lastDelivery: 0,
toastSeq: 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: { live: {
list: undefined, list: undefined,
status: undefined, status: undefined,
@@ -677,14 +704,47 @@ window.__ModuleLoader__.load({
} }
/** /**
* Raise one system notification, tolerating every refusal a browser may give. * The system-notification channel's own answer about one notification.
* @returns the outcome the config page reports, so a refusal is never silent. *
* 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 systemNotify = (candidate, force) => {
const Ctor = globalThis.Notification const Ctor = globalThis.Notification
const permission = notificationPermission() const permission = notificationPermission()
if (permission === 'unsupported') return 'unsupported' if (permission === 'unsupported') return { outcome: 'unsupported' }
if (!force && permission !== 'granted') return 'permission' if (!force && permission !== 'granted') return { outcome: 'permission' }
try { try {
const notice = copyFor(candidate) const notice = copyFor(candidate)
const options = { const options = {
@@ -694,7 +754,20 @@ window.__ModuleLoader__.load({
} }
if (systemIcon !== undefined) options.icon = systemIcon if (systemIcon !== undefined) options.icon = systemIcon
const notification = new Ctor(notice.title, options) 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 = () => { notification.onclick = () => {
dropReplay(candidate)
focusWindow() focusWindow()
openSession(candidate.sessionId) openSession(candidate.sessionId)
try { try {
@@ -703,13 +776,70 @@ window.__ModuleLoader__.load({
/* close is best effort on every platform */ /* close is best effort on every platform */
} }
} }
return 'raised' return record
} catch (error) { } catch (error) {
notify(`system notification failed: ${text(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 // -------------------------------------------------------------- delivery
/** Whether the user is looking at this exact conversation right now. */ /** 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 * never appeared, with no way to tell a wrong channel decision from a
* refused constructor. The config page shows this record, so the next * refused constructor. The config page shows this record, so the next
* question ("did it even try?") has an answer. * 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) => { const recordDelivery = (outcome, candidate, monitor) => {
store.set({ state.deliverySeq += 1
...store.getSnapshot(), const now = new Date()
lastDelivery: { const pad = (value) => String(value).padStart(2, '0')
outcome, const record = {
at: new Date().toISOString().slice(11, 19), seq: state.deliverySeq,
title: copyFor(candidate).title, 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, * The channel decision reads the window state right here, at delivery time,
* instead of trusting the state the observer last remembered — that is what * 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) => { const deliver = (candidate, ignoreThrottle) => {
if (ignoreThrottle !== true) { if (ignoreThrottle !== true) {
@@ -779,26 +924,31 @@ window.__ModuleLoader__.load({
recordDelivery('popup', candidate) recordDelivery('popup', candidate)
return return
} }
const outcome = systemNotify(candidate, candidate.test === true) const result = systemNotify(candidate, candidate.test === true)
if (outcome === 'raised') { if (result.outcome === 'raised') {
recordDelivery('system', candidate) recordDelivery('system', candidate, result)
if (candidate.test !== true) holdForReplay(candidate, result)
return return
} }
// A test alert still has to reach the user, and so does a real one when // 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. // the system channel refuses: the popup is the channel that remains.
showToast(candidate) 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. * 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 = () => { const flushSettle = () => {
state.settleTimer = 0 state.settleTimer = 0
if (disposed) return
const candidate = state.pending.shift() const candidate = state.pending.shift()
if (disposed || candidate === undefined) return if (candidate !== undefined && stillWorth(candidate)) deliver(candidate, false)
if (!stillWorth(candidate)) return if (state.pending.length > 0) state.settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
deliver(candidate, false)
} }
/** Hold one candidate for the settle tick, collapsing a burst onto one timer. */ /** 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. */ /** Raise the test alert on the system channel specifically, whatever the focus is. */
const sendTestSystem = () => { const sendTestSystem = () => {
const candidate = testCandidate(t('config.testSystem')) const candidate = testCandidate(t('config.testSystem'))
const outcome = systemNotify(candidate, true) const result = systemNotify(candidate, true)
if (outcome === 'raised') { if (result.outcome === 'raised') {
recordDelivery('system', candidate) recordDelivery('system', candidate, result)
return return
} }
showToast(candidate) showToast(candidate)
recordDelivery(`system-refused-${outcome}`, candidate) recordDelivery(`system-refused-${result.outcome}`, candidate)
} }
/** Request notification permission inside a user gesture, then report the outcome. */ /** 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' : ''}`, className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`,
role: healthFailed ? 'alert' : 'status', role: healthFailed ? 'alert' : 'status',
}, healthText), }, healthText),
h('p', { className: 'dsn-status' }, deliveryText(tr))), h('p', { className: 'dsn-status' }, deliveryText(tr)),
h('p', { className: 'dsn-status' }, replayText(tr))),
h('button', { h('button', {
type: 'button', type: 'button',
className: 'dsn-button', className: 'dsn-button',
@@ -1174,28 +1325,52 @@ window.__ModuleLoader__.load({
const outcome = last.outcome.startsWith('system-refused') const outcome = last.outcome.startsWith('system-refused')
? tr('config.diag.refused') ? tr('config.diag.refused')
: last.outcome === 'system' : last.outcome === 'system'
? tr('config.diag.system') ? last.shown === true
: tr('config.diag.popup') ? 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}` 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 // ---------------------------------------------------------- registration
/** Keep the page's own focus events owned by this plugin's effect. */ /** Keep the page's own focus events owned by this plugin's effect. */
const installBridges = () => { const installBridges = () => {
const onFocusChange = () => { const onBlur = () => {
state.lastDelivery = 0 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 { try {
window.addEventListener('focus', onFocusChange) window.addEventListener('focus', onReturn)
window.addEventListener('blur', onFocusChange) window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibilityChange)
} catch { } catch {
/* an unreadable window still delivers through live focus reads */ /* an unreadable window still delivers through live focus reads */
} }
return () => { return () => {
try { try {
window.removeEventListener('focus', onFocusChange) window.removeEventListener('focus', onReturn)
window.removeEventListener('blur', onFocusChange) window.removeEventListener('blur', onBlur)
document.removeEventListener('visibilitychange', onVisibilityChange)
} catch { } catch {
/* nothing to detach */ /* nothing to detach */
} }
@@ -1205,6 +1380,8 @@ window.__ModuleLoader__.load({
} }
for (const timer of timers.values()) clearTimeout(timer) for (const timer of timers.values()) clearTimeout(timer)
timers.clear() timers.clear()
state.raised.clear()
state.replay = []
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dsh-plugin/session-notify", "name": "@dsh-plugin/session-notify",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。", "description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",