🐛 fix(client): 后台提醒回到窗口补发轻弹窗,系统通知记录是否弹出
This commit is contained in:
@@ -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()`,任一表示不可见即走系统通知;观察器订阅的事件只是让重算更及时,不再决定通道。
|
||||||
|
|||||||
@@ -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 完全退出期间结束的会话不会再补发通知**。
|
||||||
|
|
||||||
## 平台
|
## 平台
|
||||||
|
|||||||
@@ -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
@@ -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": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",
|
||||||
|
|||||||
Reference in New Issue
Block a user