♻️ refactor(client): 浏览器半拆成 src 模块,client.js 改由构建产出

This commit is contained in:
pyh
2026-10-04 19:01:22 +08:00
parent 3986aac863
commit 68ab60273e
30 changed files with 3506 additions and 1394 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* Delivery: choose exactly one channel for one alert, at the moment the alert
* is owed, and record what happened.
*
* @module @dsh-plugin/session-notify/client/core/delivery
*/
import { SETTLE_MS, THROTTLE_MS } from '../constants.js'
import { windowIsAway } from '../platform.js'
import { isOnScreen, stillWorth } from './session.js'
/**
* Build the delivery path.
* @param deps - the plugin store; the shared `live` cell the observer writes and
* this path reads at delivery time; the copy table; the popup stack; the
* system channel; and the replay queue.
* @returns the delivery operations, the settle runner, and the throttle reset a
* window transition needs.
*/
export function createDelivery({ store, copy, live, toasts, system, replay }) {
const pending = []
let settleTimer = 0
let lastDelivery = 0
let deliverySeq = 0
/**
* Record what actually happened to the newest alert.
*
* Everything on the delivery path used to fail invisibly: the alert simply
* 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, monitor) => {
deliverySeq += 1
const now = new Date()
const pad = (value) => String(value).padStart(2, '0')
const record = {
seq: deliverySeq,
outcome,
at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
title: copy.copyFor(candidate).title,
shown: monitor?.shown,
monitor,
}
store.set({ ...store.getSnapshot(), lastDelivery: record })
return record
}
/**
* Deliver one candidate through exactly one channel: the system
* notification while the window is away, the light popup while it is in
* front, and nothing at all for the conversation on screen.
*
* 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 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.
* @param candidate - the alert to deliver.
* @param ignoreThrottle - deliver immediately, bypassing the flood guard.
*/
const deliver = (candidate, ignoreThrottle) => {
if (ignoreThrottle !== true) {
const now = Date.now()
if (now - lastDelivery < THROTTLE_MS) return
lastDelivery = now
}
if (!windowIsAway()) {
if (isOnScreen(candidate.sessionId, live.list)) return
toasts.showToast(candidate)
recordDelivery('popup', candidate)
return
}
const result = system.notifySystem(candidate, candidate.test === true)
if (result.outcome === 'raised') {
recordDelivery('system', candidate, result)
if (candidate.test !== true) replay.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.
toasts.showToast(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 = () => {
settleTimer = 0
const candidate = pending.shift()
if (candidate !== undefined && stillWorth(candidate, live)) deliver(candidate, false)
if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
}
/** Hold one candidate for the settle tick, collapsing a burst onto one timer. */
const queue = (candidate) => {
pending.push(candidate)
if (settleTimer !== 0) return
settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
}
/**
* Clear the flood guard. A window that just lost or regained the foreground is
* exactly when the next alert matters most, so it must never be swallowed by a
* delivery from the other side of that transition.
*/
const resetThrottle = () => {
lastDelivery = 0
}
/** Stop the pending settle tick: the plugin is being disposed. */
const dispose = () => {
if (settleTimer !== 0) {
clearTimeout(settleTimer)
settleTimer = 0
}
}
return { deliver, recordDelivery, queue, flushSettle, resetThrottle, dispose }
}