dsh-usage-badge 0.1.6 → 0.1.7
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/README.md +2 -2
- package/docs/design-notes.md +8 -4
- package/lib/client.js +32 -5
- package/lib/index.js +124 -19
- package/lib/sync.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ dsh plugin --profile web remove dsh-usage-badge
|
|
|
85
85
|
- **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
|
|
86
86
|
- **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
|
|
87
87
|
- **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
|
|
88
|
-
- **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 ——
|
|
88
|
+
- **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后**三条自动触发**都会工作:**打开 DSH 后 8 秒自动同步一次**、**关闭 DSH 前自动同步一次**(最多等 3.5 秒,不会拖着不退出)、**改完价格表几秒后自动推送**;前两条在页签里各有一个开关。服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。见 [docs/design-notes.md](docs/design-notes.md) 的「云同步(WebDAV + Digest)—— 设计」一节。
|
|
89
89
|
- 每台机器有自己的**设备名**,它只决定「这台机器的新数据写进哪个分区」,**不是**会话身份;远端所有设备的历史都会合并,所以**改设备名不会让用量累增**。但设备名在服务器上必须**唯一**:两台机器用同一个名字会互相覆盖分片,插件会拒绝发布并在面板里说明。
|
|
90
90
|
- **删除不会跨设备传播**:本机删掉日志后,新机器不会再拉到那段历史,但**已经拉过它的机器**库里那份仍然在(远端从不因为本机的改动而删东西)。
|
|
91
91
|
- **会话是按日志目录识别的**:把某个会话目录改名,同一份用量会被当成两个会话、各算一份(实测 ¥1.5 → ¥3.0)。删日志、日志变短、还原旧备份这三种情况都不会丢历史。**这不包括「设备」字段** —— 那只是这台机器往哪个分区写,改设备名不会让用量累增。
|
|
@@ -95,7 +95,7 @@ dsh plugin --profile web remove dsh-usage-badge
|
|
|
95
95
|
|
|
96
96
|
```sh
|
|
97
97
|
npm install # 只为测试装 react / react-dom
|
|
98
|
-
npm test # 11 个套件
|
|
98
|
+
npm test # 11 个套件 865 条断言:包契约、定价、节假日、官方定价解析、缓存存储、同步分片、同步编排、宿主同步接线、自动触发、WebDAV(Digest)、浏览器半渲染
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
云同步的前五步已经在真实的 `bytemark/webdav` 容器上核对通过。要自己核对一遍:
|
package/docs/design-notes.md
CHANGED
|
@@ -410,13 +410,17 @@ npm test
|
|
|
410
410
|
- **`/usage-badge/sync` 路由**:`GET` 状态与配置(凭据只回 6 位前缀 + `hasCredential`)、`PUT|POST` 写配置(明文密码**当场**在宿主半里算成 HA1,也可以直接粘贴 HA1)、`POST /sync/test` 只做连接自检(连不上会**失败但不抛**)、`POST /sync/run` 跑一次。三个写入口都要求 loopback。
|
|
411
411
|
- **面板第三个页签「同步」**:地址 / 用户名 / 密码 / 设备(带远端设备下拉 —— 那就是「重装后并入已有设备」的入口)/ 首次拉取年数 / 自签名证书开关,加「保存」「测试连接」「立即同步」,以及一段状态(上次结果、已发布月份、远端设备与各自的月份数、冲突文件提示、本机设备名与凭据状态)。**凭据字段永远回空**:页面能知道「配了」,永远不知道「是什么」。
|
|
412
412
|
- **`HOST_API_VERSION` 6 → 7**,`REQUIRED_HOST_API` 同步跟上(`client.render.mjs` 有一条断言钉住两半相等)。
|
|
413
|
-
-
|
|
413
|
+
- 插件**挂载时不创建任何东西**,**打开页签只看一眼也不创建**:`sync.json` 与 `host.lock` 都是第一次**真正决定了什么**(保存 / 测试连接 / 立即同步)时才出现(拿锁 = 这个进程可以写)。两条都有断言钉着 —— 否则每个从不用同步的安装会多出两个文件,而「我只是点开看一眼,它就写了两个文件」是同一件事的第二种形态。只读状态下的 `writable` 仍如实回答:`readLock` **读**一次锁文件(不写),并且**完全镜像 `acquireLock` 的判定**(任何活着的锁都算被占用,包括自己 pid 持有的),所以面板不会承诺一次引擎随后会拒绝的写入。
|
|
414
414
|
|
|
415
|
-
**自动触发**(`lib/index.js
|
|
415
|
+
**自动触发**(`lib/index.js`):一共三条,都可以在面板里开关(前两条)或由配置控制。
|
|
416
416
|
|
|
417
|
-
- **启动后延迟一次**(`STARTUP_SYNC_DELAY_MS`,默认 8
|
|
417
|
+
- **启动后延迟一次**(`STARTUP_SYNC_DELAY_MS`,默认 8 秒,开关 `onStart`):只在**同步已经配置且开启**时才排期,所以从没开过同步的 home 不会因此多出 `sync.json`、锁或网络请求;延迟是为了让冷折叠与首屏先走完 —— 一上来就同步会和「让徽标先显示东西」抢时间;锁不在本实例手上时**跳过并说明**,而不是把锁从别人手里抢过来。
|
|
418
|
+
- **退出前同步一次**(`EXIT_SYNC_BUDGET_MS`,默认 3.5 秒,开关 `onExit`):这是**真的被 await 的**,不是「发了就希望它跑完」。依据是查清了 DSH 的关闭链条(见下):桌面端给宿主子进程发 `{type:'shutdown'}`,宿主 `await application.shutdown.shutdown(0)`,而 `createProcessShutdown` 是 `Promise.resolve().then(dispose).then(...)`,cordis 的 `dispose()` 又会收集各 effect disposer 的返回值、是 thenable 就 `task = result` 串下去 —— 所以**本插件的 disposer 返回一个 promise 就会被等到**(上限 `PROCESS_SHUTDOWN_TIMEOUT_MS = 5000`,之后 `process.exit`;桌面端另外最多等 10 秒子进程退出)。
|
|
419
|
+
- 顺序因此必须是:**先同步**(此时 store 还开着、锁还在手上)→ 再交还锁 → 最后 `aggregator.close()`。
|
|
420
|
+
- 预算到了就 `engine.abort()`:运行在**下一个阶段边界**停下,不会留下半张清单(上传走临时名 + 最后写清单)。预算为 0 表示「没时间做这件事」,测试与调试用它关掉这条。
|
|
421
|
+
- 配置从**磁盘**读:这一轮会话可能从没碰过同步(上次配好、这次没打开面板就关掉),那种情况下退出同步要自己把 system 建起来 —— 否则「配好了但没打开面板」就等于永远不同步。
|
|
418
422
|
- **价格表变更后推一次**(`PRICING_PUSH_DELAY_MS`,默认 3 秒,去抖):改完一行再改下一行是**一个**意图,所以等一小会儿;推的是 `pushPricing()` —— **只动价格表,不跑整轮**(价格编辑与分片无关,整轮会把整个远端清单拖进来)。它是 fire-and-forget:**连不上服务器绝不能让本地编辑失败**,留下的状态由面板报告。
|
|
419
|
-
-
|
|
423
|
+
- 三个时间都可以用环境变量覆盖(`DSH_USAGE_BADGE_STARTUP_SYNC_MS` / `DSH_USAGE_BADGE_EXIT_SYNC_MS` / `DSH_USAGE_BADGE_PRICING_PUSH_MS`),这是 `test/sync-triggers.verify.mjs` 不必真等几秒的办法。**启动延迟在 `apply()` 里同步读取**,不在 warm 的回调里读 —— warm 是异步的,等它跑完再读,拿到的会是「那之后」的值(测试设的覆盖值那时已经被撤掉,于是它又变回 8 秒,测试只能干等)。
|
|
420
424
|
- 卸载时两个待触发的定时器都会被清掉,**锁也会交还**:宿主正在退出还去推一次,会为一个已经不存在的进程写状态;而留着锁会让下一次挂载(热重载、或同一进程里再挂一次)被自己的锁拒绝。
|
|
421
425
|
|
|
422
426
|
### 第六轮:一轮对抗性审查找到并修掉的问题
|
package/lib/client.js
CHANGED
|
@@ -2151,9 +2151,13 @@ window.__ModuleLoader__.load({
|
|
|
2151
2151
|
fill('realm', config.realm)
|
|
2152
2152
|
fill('deviceId', config.deviceId)
|
|
2153
2153
|
if (config.windowYears !== undefined && config.windowYears !== null) fill('windowYears', String(config.windowYears))
|
|
2154
|
-
// A checkbox has no "empty" to leave alone: it is
|
|
2155
|
-
//
|
|
2154
|
+
// A checkbox has no "empty" to leave alone: it is proposed from the saved configuration, and the
|
|
2155
|
+
// component applies the patch once per configuration. `onStart`/`onExit` default to **on**, so anything
|
|
2156
|
+
// other than an explicit `false` means on — an older `sync.json` has no such field and must not read as
|
|
2157
|
+
// "off".
|
|
2156
2158
|
if (config.allowInsecureTls) patch.allowInsecureTls = true
|
|
2159
|
+
if (config.onStart !== false) patch.onStart = true
|
|
2160
|
+
if (config.onExit !== false) patch.onExit = true
|
|
2157
2161
|
return patch
|
|
2158
2162
|
}
|
|
2159
2163
|
|
|
@@ -2181,6 +2185,9 @@ window.__ModuleLoader__.load({
|
|
|
2181
2185
|
// "fill only what is empty" would then leave a saved 0 or 30 looking like the default forever.
|
|
2182
2186
|
const [windowYears, setWindowYears] = React.useState(String(initialSync?.config?.windowYears ?? ''))
|
|
2183
2187
|
const [allowInsecureTls, setAllowInsecureTls] = React.useState(Boolean(initialSync?.config?.allowInsecureTls))
|
|
2188
|
+
// The two automatic triggers. Both default to on, so a configuration that predates them reads as on.
|
|
2189
|
+
const [onStart, setOnStart] = React.useState(initialSync?.config?.onStart !== false)
|
|
2190
|
+
const [onExit, setOnExit] = React.useState(initialSync?.config?.onExit !== false)
|
|
2184
2191
|
const [state, setState] = React.useState('idle')
|
|
2185
2192
|
const [note, setNote] = React.useState('')
|
|
2186
2193
|
const [devices, setDevices] = React.useState(null)
|
|
@@ -2238,7 +2245,7 @@ window.__ModuleLoader__.load({
|
|
|
2238
2245
|
// The checkbox belongs in the signature too: it is the one field whose saved value can change from
|
|
2239
2246
|
// `true` to `false`, and without this the signature would look unchanged and the box would keep
|
|
2240
2247
|
// showing the old answer.
|
|
2241
|
-
const signature = `${saved.url ?? ''}\u0000${saved.username ?? ''}\u0000${saved.realm ?? ''}\u0000${saved.deviceId ?? ''}\u0000${saved.windowYears ?? ''}\u0000${Boolean(saved.allowInsecureTls)}`
|
|
2248
|
+
const signature = `${saved.url ?? ''}\u0000${saved.username ?? ''}\u0000${saved.realm ?? ''}\u0000${saved.deviceId ?? ''}\u0000${saved.windowYears ?? ''}\u0000${Boolean(saved.allowInsecureTls)}\u0000${saved.onStart !== false}\u0000${saved.onExit !== false}`
|
|
2242
2249
|
if (filledFrom.current === signature) return
|
|
2243
2250
|
filledFrom.current = signature
|
|
2244
2251
|
const patch = fillSyncForm(status, { url, username, realm, deviceId, windowYears })
|
|
@@ -2248,8 +2255,10 @@ window.__ModuleLoader__.load({
|
|
|
2248
2255
|
if (patch.deviceId !== undefined) setDeviceId(patch.deviceId)
|
|
2249
2256
|
if (patch.windowYears !== undefined) setWindowYears(patch.windowYears)
|
|
2250
2257
|
// Two-way, unlike the text fields: a checkbox has no empty state to protect, and the saved answer is
|
|
2251
|
-
// the answer. `formDirty` still stops this from fighting someone who has just clicked
|
|
2258
|
+
// the answer. `formDirty` still stops this from fighting someone who has just clicked one.
|
|
2252
2259
|
setAllowInsecureTls(Boolean(saved.allowInsecureTls))
|
|
2260
|
+
setOnStart(saved.onStart !== false)
|
|
2261
|
+
setOnExit(saved.onExit !== false)
|
|
2253
2262
|
}, [status, url, username, realm, deviceId, windowYears])
|
|
2254
2263
|
|
|
2255
2264
|
// While a run is in flight the panel shows what the host is doing, so it asks again. The condition
|
|
@@ -2269,7 +2278,7 @@ window.__ModuleLoader__.load({
|
|
|
2269
2278
|
setFailNote('')
|
|
2270
2279
|
try {
|
|
2271
2280
|
const sentPassword = password !== ''
|
|
2272
|
-
const payload = { url, username, realm, deviceId, windowYears: Number(windowYears), allowInsecureTls }
|
|
2281
|
+
const payload = { url, username, realm, deviceId, windowYears: Number(windowYears), allowInsecureTls, onStart, onExit }
|
|
2273
2282
|
if (sentPassword) payload.password = password
|
|
2274
2283
|
const response = await fetch('/usage-badge/sync', {
|
|
2275
2284
|
method: 'POST',
|
|
@@ -2478,6 +2487,24 @@ window.__ModuleLoader__.load({
|
|
|
2478
2487
|
}),
|
|
2479
2488
|
'HTTPS 但证书不受信任时才勾',
|
|
2480
2489
|
),
|
|
2490
|
+
row(
|
|
2491
|
+
'打开时同步',
|
|
2492
|
+
h('input', {
|
|
2493
|
+
type: 'checkbox',
|
|
2494
|
+
checked: onStart,
|
|
2495
|
+
onChange: (event) => { formDirty.current = true; setOnStart(event.target.checked) },
|
|
2496
|
+
}),
|
|
2497
|
+
'DSH 启动 8 秒后自动同步一次(等冷折叠与首屏先走完)',
|
|
2498
|
+
),
|
|
2499
|
+
row(
|
|
2500
|
+
'退出时同步',
|
|
2501
|
+
h('input', {
|
|
2502
|
+
type: 'checkbox',
|
|
2503
|
+
checked: onExit,
|
|
2504
|
+
onChange: (event) => { formDirty.current = true; setOnExit(event.target.checked) },
|
|
2505
|
+
}),
|
|
2506
|
+
'关闭 DSH 前自动同步一次,最多等 3.5 秒(客户端整体只给 5 秒)',
|
|
2507
|
+
),
|
|
2481
2508
|
h(
|
|
2482
2509
|
'div',
|
|
2483
2510
|
{ className: 'dub-form-actions' },
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { join } from 'node:path'
|
|
|
22
22
|
|
|
23
23
|
import { createCacheStore } from './cache-store.js'
|
|
24
24
|
import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
|
|
25
|
-
import { SyncEngine, acquireLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
|
|
25
|
+
import { SyncEngine, acquireLock, lockIsStale, readLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
|
|
26
26
|
import { normalizeWebdavUrl } from './webdav.js'
|
|
27
27
|
import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
|
|
28
28
|
import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
|
|
@@ -90,6 +90,16 @@ export const PRICING_PUSH_DELAY_MS = 3000
|
|
|
90
90
|
/** How long after startup the first automatic sync runs, when sync is enabled. */
|
|
91
91
|
export const STARTUP_SYNC_DELAY_MS = 8000
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* How long a sync on the way out may take before it is told to stop.
|
|
95
|
+
*
|
|
96
|
+
* The application's whole teardown gets 5 s (`PROCESS_SHUTDOWN_TIMEOUT_MS`), and this plugin is one of
|
|
97
|
+
* several things being disposed — so it takes most of that and leaves the rest alone. Long enough for a
|
|
98
|
+
* real sync against a reachable server (a few hundred milliseconds for a month of changes), short enough
|
|
99
|
+
* that quitting never feels stuck.
|
|
100
|
+
*/
|
|
101
|
+
export const EXIT_SYNC_BUDGET_MS = 3500
|
|
102
|
+
|
|
93
103
|
/**
|
|
94
104
|
* How long the panel waits for a status read before it is told the answer is not ready.
|
|
95
105
|
*
|
|
@@ -677,6 +687,10 @@ export function apply(ctx) {
|
|
|
677
687
|
deviceLabel: String(config.deviceLabel ?? ''),
|
|
678
688
|
windowYears: Number(config.windowYears) || 0,
|
|
679
689
|
instanceId: String(config.instanceId ?? ''),
|
|
690
|
+
// Defaults are `true`, so an existing `sync.json` that predates these fields reads as "both on" —
|
|
691
|
+
// which is what the design promises: sync, once switched on, keeps itself current.
|
|
692
|
+
onStart: config.onStart !== false,
|
|
693
|
+
onExit: config.onExit !== false,
|
|
680
694
|
hasCredential: Boolean(ha1),
|
|
681
695
|
// Only a real HA1 counts. `password` and `passwordHash` are fields an older build could leave behind,
|
|
682
696
|
// and `connect()` cannot use either — claiming a credential exists would enable 立即同步 and then
|
|
@@ -734,27 +748,44 @@ export function apply(ctx) {
|
|
|
734
748
|
}
|
|
735
749
|
|
|
736
750
|
async function syncStatus() {
|
|
737
|
-
// Reading the status must not create anything
|
|
738
|
-
//
|
|
751
|
+
// Reading the status must not create anything: someone opening the tab to see what it says has not
|
|
752
|
+
// decided to use sync yet. So the system (and with it the lock and `sync.json`) is only created by an
|
|
753
|
+
// action that actually decides something — save, test, run — and this branch answers from the files on
|
|
754
|
+
// disk plus a **read** of the lock.
|
|
739
755
|
if (!system) {
|
|
740
756
|
const document = readSyncDocument(dataDir, log)
|
|
757
|
+
const state = document.state ?? {}
|
|
758
|
+
const pulled = {}
|
|
759
|
+
for (const context of Object.keys(state.held ?? {})) {
|
|
760
|
+
const device = context.split('/')[0]
|
|
761
|
+
pulled[device] = (pulled[device] ?? 0) + 1
|
|
762
|
+
}
|
|
763
|
+
// Whose lock is it? Answering "may this instance write" takes no writing: `readLock` looks, and this
|
|
764
|
+
// mirrors exactly what `acquireLock` would decide — **any** live lock blocks, including one this same
|
|
765
|
+
// pid holds, because that is the answer `acquireLock` gives and the panel must not promise a write the
|
|
766
|
+
// engine would then refuse. A stale lock is not a block: it would be taken over.
|
|
767
|
+
const owner = readLock(dataDir)
|
|
768
|
+
const blocking = owner && !lockIsStale(owner) ? owner : null
|
|
741
769
|
return {
|
|
742
|
-
config: safeSyncConfig(
|
|
743
|
-
writable:
|
|
744
|
-
lock: null,
|
|
770
|
+
config: safeSyncConfig(document),
|
|
771
|
+
writable: !blocking,
|
|
772
|
+
lock: blocking ? { reason: `this data directory is open by another DSH instance (pid ${blocking.pid} on ${blocking.hostname || 'this host'})`, owner: blocking, pid: process.pid } : null,
|
|
745
773
|
running: false,
|
|
746
774
|
last: {
|
|
747
|
-
result:
|
|
748
|
-
message:
|
|
749
|
-
at: Number(
|
|
775
|
+
result: state.lastResult || 'never',
|
|
776
|
+
message: state.message ?? '',
|
|
777
|
+
at: Number(state.lastSyncAt) || 0,
|
|
750
778
|
},
|
|
751
|
-
collision:
|
|
752
|
-
|
|
779
|
+
collision: state.collision ?? null,
|
|
780
|
+
// From the persisted state, so a home that has published before still shows what it published —
|
|
781
|
+
// none of this needs the lock.
|
|
782
|
+
remoteDevices: pulled,
|
|
753
783
|
// `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
|
|
754
784
|
// are no other devices".
|
|
755
785
|
remoteKnown: false,
|
|
756
786
|
remoteError: null,
|
|
757
|
-
|
|
787
|
+
remoteDevicesOnServer: null,
|
|
788
|
+
published: state.published ?? {},
|
|
758
789
|
conflictFiles: conflictFiles(),
|
|
759
790
|
paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
|
|
760
791
|
}
|
|
@@ -934,6 +965,10 @@ export function apply(ctx) {
|
|
|
934
965
|
function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
|
|
935
966
|
const configured = readSyncDocument(dataDir, log)
|
|
936
967
|
if (!configured.enabled || !configured.url || !configured.ha1) return
|
|
968
|
+
if (configured.onStart === false) {
|
|
969
|
+
log('startup sync is switched off')
|
|
970
|
+
return
|
|
971
|
+
}
|
|
937
972
|
const timer = setTimeout(() => {
|
|
938
973
|
startupSyncTimer = null
|
|
939
974
|
const instance = syncSystem()
|
|
@@ -948,6 +983,56 @@ export function apply(ctx) {
|
|
|
948
983
|
}
|
|
949
984
|
let startupSyncTimer = null
|
|
950
985
|
|
|
986
|
+
/**
|
|
987
|
+
* A last sync on the way out, awaited — but only for as long as there is time.
|
|
988
|
+
*
|
|
989
|
+
* DSH gives the whole application teardown **5 seconds** (`PROCESS_SHUTDOWN_TIMEOUT_MS` in the runtime)
|
|
990
|
+
* before it exits the host process outright, and the desktop then waits up to 10 more for the child to go.
|
|
991
|
+
* So the chain that reaches this function is: desktop asks the host to shut down → the host awaits its own
|
|
992
|
+
* disposal → cordis disposes this plugin's effects, awaiting what they return. Returning a promise here is
|
|
993
|
+
* what makes the sync happen at all instead of being cut off mid-request.
|
|
994
|
+
*
|
|
995
|
+
* The budget is what keeps that promise from becoming a slow quit: past it the run is told to stop at its
|
|
996
|
+
* next step boundary (nothing half-written — uploads go through a temporary name and the manifest is
|
|
997
|
+
* written last) and the disposal returns. A sync that did not finish is reported as such, not pretended.
|
|
998
|
+
*/
|
|
999
|
+
async function syncOnExit() {
|
|
1000
|
+
// Read the configuration from disk when this session never touched sync: a home configured in an earlier
|
|
1001
|
+
// session, quit without opening the panel, still has to sync. Creating the system here is also what takes
|
|
1002
|
+
// the lock — and the store is still open at this point (the disposer closes it afterwards).
|
|
1003
|
+
const document = system?.engine?.config ?? readSyncDocument(dataDir, log)
|
|
1004
|
+
if (!document.enabled || !document.url || !document.ha1) return
|
|
1005
|
+
if (document.onExit === false) {
|
|
1006
|
+
log('exit sync is switched off')
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
const instance = syncSystem()
|
|
1010
|
+
if (!instance.engine.store) return
|
|
1011
|
+
if (!instance.lock?.acquired) {
|
|
1012
|
+
log(`exit sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
const budget = delayFor('DSH_USAGE_BADGE_EXIT_SYNC_MS', EXIT_SYNC_BUDGET_MS)
|
|
1016
|
+
if (budget <= 0) {
|
|
1017
|
+
// A zero budget means "there is no time for this" — the switch a test or a debugging session uses.
|
|
1018
|
+
log('exit sync skipped: no time budget')
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
let timer = null
|
|
1022
|
+
const deadline = new Promise((resolve) => {
|
|
1023
|
+
timer = setTimeout(() => resolve('deadline'), budget)
|
|
1024
|
+
timer.unref?.()
|
|
1025
|
+
})
|
|
1026
|
+
const outcome = await Promise.race([runSync().then((result) => result ?? 'done', () => 'failed'), deadline])
|
|
1027
|
+
clearTimeout(timer)
|
|
1028
|
+
if (outcome === 'deadline') {
|
|
1029
|
+
instance.engine.abort()
|
|
1030
|
+
log(`exit sync did not finish within ${budget}ms; it stops at its next step boundary`)
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
951
1036
|
async function warm() {
|
|
952
1037
|
try {
|
|
953
1038
|
const status = await refreshOnce()
|
|
@@ -1111,14 +1196,15 @@ export function apply(ctx) {
|
|
|
1111
1196
|
// plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
|
|
1112
1197
|
// leaving the host half.
|
|
1113
1198
|
if (route === '/sync' && req.method === 'GET') {
|
|
1114
|
-
// Loopback only, like every other sync route
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1199
|
+
// Loopback only, like every other sync route: the answer names the server and the account.
|
|
1200
|
+
//
|
|
1201
|
+
// **And it creates nothing.** Opening the tab is looking, not deciding — so this no longer takes the
|
|
1202
|
+
// lock or writes `sync.json`; the system comes into being when something is saved, tested or run.
|
|
1203
|
+
// `writable` is still answered honestly, by reading the lock file rather than writing one.
|
|
1117
1204
|
if (!isLoopback(req)) {
|
|
1118
1205
|
sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
|
|
1119
1206
|
return
|
|
1120
1207
|
}
|
|
1121
|
-
syncSystem()
|
|
1122
1208
|
let body
|
|
1123
1209
|
try {
|
|
1124
1210
|
body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
|
|
@@ -1138,9 +1224,14 @@ export function apply(ctx) {
|
|
|
1138
1224
|
const instance = syncSystem()
|
|
1139
1225
|
const patch = await readJsonBody(req)
|
|
1140
1226
|
const config = {}
|
|
1141
|
-
for (const field of ['enabled', 'url', 'username', '
|
|
1227
|
+
for (const field of ['enabled', 'url', 'username', 'deviceLabel', 'deviceId', 'realm']) {
|
|
1142
1228
|
if (patch[field] !== undefined) config[field] = patch[field]
|
|
1143
1229
|
}
|
|
1230
|
+
// The switches, coerced: a panel sends booleans, but a hand-written request may send 0/1, and a
|
|
1231
|
+
// stored `"false"` string would otherwise read as *on* (it is not `false`).
|
|
1232
|
+
for (const field of ['allowInsecureTls', 'onStart', 'onExit']) {
|
|
1233
|
+
if (patch[field] !== undefined) config[field] = patch[field] !== false && patch[field] !== 'false' && patch[field] !== 0
|
|
1234
|
+
}
|
|
1144
1235
|
if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
|
|
1145
1236
|
if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
|
|
1146
1237
|
try {
|
|
@@ -1259,12 +1350,27 @@ export function apply(ctx) {
|
|
|
1259
1350
|
// Fire and forget: this only fills in what the server holds, for the panel's next read.
|
|
1260
1351
|
void refreshRemoteDevices()
|
|
1261
1352
|
})
|
|
1262
|
-
|
|
1353
|
+
// The disposer is **async on purpose**, and everything below depends on the order:
|
|
1354
|
+
//
|
|
1355
|
+
// 1. stop automatic work that would fire into a process that is going away;
|
|
1356
|
+
// 2. sync once, while the store is still open (a sync reads it) and while we still own the lock;
|
|
1357
|
+
// 3. hand the lock back and close the store.
|
|
1358
|
+
//
|
|
1359
|
+
// Returning a promise is what makes step 2 real: DSH awaits the host's teardown, cordis awaits what an
|
|
1360
|
+
// effect's disposer returns, and the runtime force-exits only after 5 s. So this is not "fire and hope";
|
|
1361
|
+
// it is synchronised with the shutdown — and bounded, so a slow server cannot hold up quitting.
|
|
1362
|
+
return async () => {
|
|
1263
1363
|
disposed = true
|
|
1264
1364
|
// Any pending automatic work is dropped on unload: a push that fires while the host is going away
|
|
1265
1365
|
// would write state for a process that no longer exists.
|
|
1266
1366
|
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
1267
1367
|
if (startupSyncTimer) clearTimeout(startupSyncTimer)
|
|
1368
|
+
if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
|
|
1369
|
+
try {
|
|
1370
|
+
await syncOnExit()
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
log(`exit sync failed: ${error?.message ?? error}`)
|
|
1373
|
+
}
|
|
1268
1374
|
// **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
|
|
1269
1375
|
// re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
|
|
1270
1376
|
// refused by this plugin's own lock, for a pid that is very much alive: its own.
|
|
@@ -1278,7 +1384,6 @@ export function apply(ctx) {
|
|
|
1278
1384
|
// touch the store do not start.
|
|
1279
1385
|
system.engine.abort()
|
|
1280
1386
|
system.engine.release()
|
|
1281
|
-
if (system.running) log('unloading while a sync is in flight; it stops at its next step boundary')
|
|
1282
1387
|
}
|
|
1283
1388
|
releaseLock(dataDir)
|
|
1284
1389
|
aggregator.close()
|
package/lib/sync.js
CHANGED
|
@@ -627,6 +627,10 @@ export const defaultSyncConfig = () => ({
|
|
|
627
627
|
deviceLabel: hostname(),
|
|
628
628
|
instanceId: '',
|
|
629
629
|
windowYears: 5,
|
|
630
|
+
// The two automatic triggers, both on by default: a backup nobody remembers to run is not a backup. Both
|
|
631
|
+
// are switchable, because a sync that holds up quitting is worse than one that happens a minute later.
|
|
632
|
+
onStart: true,
|
|
633
|
+
onExit: true,
|
|
630
634
|
})
|
|
631
635
|
|
|
632
636
|
/** Read `sync.json`, or a default document when it is missing or unreadable. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-usage-badge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|