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 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 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后它还会**在启动后自己同步一次**,并在你**改完价格表几秒后自动推送**;服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。见 [docs/design-notes.md](docs/design-notes.md) 的「云同步(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 个套件 845 条断言:包契约、定价、节假日、官方定价解析、缓存存储、同步分片、同步编排、宿主同步接线、自动触发、WebDAV(Digest)、浏览器半渲染
98
+ npm test # 11 个套件 865 条断言:包契约、定价、节假日、官方定价解析、缓存存储、同步分片、同步编排、宿主同步接线、自动触发、WebDAV(Digest)、浏览器半渲染
99
99
  ```
100
100
 
101
101
  云同步的前五步已经在真实的 `bytemark/webdav` 容器上核对通过。要自己核对一遍:
@@ -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
- - 插件**挂载时不创建任何东西**:`sync.json`、`host.lock` 都是第一次碰「同步」时才出现(拿锁 = 这个进程可以写)。这条有断言钉着,否则每个从不用同步的安装也会多出两个文件。
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 秒):只在**同步已经配置且开启**时才排期,所以从没开过同步的 home 不会因此多出 `sync.json`、锁或网络请求;延迟是为了让冷折叠与首屏先走完 —— 一上来就同步会和「让徽标先显示东西」抢时间;锁不在本实例手上时**跳过并说明**,而不是把锁从别人手里抢过来。
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
- - 两个延迟都可以用环境变量覆盖(`DSH_USAGE_BADGE_STARTUP_SYNC_MS` / `DSH_USAGE_BADGE_PRICING_PUSH_MS`),这是 `test/sync-triggers.verify.mjs` 不必真等几秒的办法。**启动延迟在 `apply()` 里同步读取**,不在 warm 的回调里读 —— warm 是异步的,等它跑完再读,拿到的会是「那之后」的值(测试设的覆盖值那时已经被撤掉,于是它又变回 8 秒,测试只能干等)。
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 on only when the saved configuration says so, and
2155
- // turning it back on is a deliberate act the person can repeat.
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 it.
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 either, so an untouched install answers with the
738
- // sensible defaults rather than acquiring a lock it has no intention of using.
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({ ...document, hasCredential: Boolean(document.ha1 || document.passwordHash || document.password) }),
743
- writable: true,
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: document.state?.lastResult || 'never',
748
- message: document.state?.message ?? '',
749
- at: Number(document.state?.lastSyncAt) || 0,
775
+ result: state.lastResult || 'never',
776
+ message: state.message ?? '',
777
+ at: Number(state.lastSyncAt) || 0,
750
778
  },
751
- collision: document.state?.collision ?? null,
752
- remoteDevices: {},
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
- published: {},
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. The answer names the server and the account, and
1115
- // reading it *is* engaging with sync — this is where the system comes into being, and with it the
1116
- // owner lock. A page that never opens this tab still touches nothing.
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', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
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
- return () => {
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.6",
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",