dsh-usage-badge 0.1.6 → 0.1.8

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 秒,不会拖着不退出)、**改完价格表几秒后自动推送**;前两条在页签里各有一个开关。服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。**同步来的用量立刻计入**:拉取结束后徽标、日区间、热力图在同一次运行里就更新,不用重启 DSH(「按年」本来就是直查库)。见 [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
  ### 第六轮:一轮对抗性审查找到并修掉的问题
@@ -620,9 +624,10 @@ storages/usage-badge/sync.json # 同步配置与状态
620
624
 
621
625
  - **来源维度**:`days` / `sessions` 增加一列来源(本设备 / 某个远端设备)。本地 fold 只能写本设备的行;远端导入的行**只读**,永不被本地 fold 波及 —— 否则「B 下载了 A 的历史,B 本地没有那些日志」会让 B 的下一轮 fold 把它们删光。
622
626
  - **导入远端行**时:若已存在同一 `(session, date)` 且那份的 `(maxSeq, fileSize)` 更大,则跳过(**旧机器不能覆盖新数据**);否则写入。
623
- - **导入之后必须重载聚合器**:`refresh()` 的游标没变,不会自己发现库里的新行,否则同步来的历史要等到重启才可见。
627
+ - **导入之后必须重载聚合器**:`refresh()` 的游标没变,不会自己发现库里的新行,否则同步来的历史要等到重启才可见。实现是 `runSync()` 里 `engine.run()` 返回之后的 `aggregator.loadCache({ quiet: true })` 加 `snapshot = null`,条件是引擎自己报的 **`storeChanged`**(真的是库里写进或删掉了行才为真,取值来自 `intakeRows()`/`dropSourceMonth()` 的返回值 —— 重放一份库里已经全有的分片写 0 行,就不该为此付一次全量重载,实测 16k 行的库一次约 0.9 秒)。宿主还要 `!system.syncAbort`:正在关闭的宿主不能去读一个马上要 close 的库(宽松条件实测会打印 `cache load failed: statement has been finalized`),而**因为别的原因被中止**(未来的「取消同步」)仍会重载,所以 `aborted` 分支也带上 `storeChanged`。**撤回(墓碑删行)走的是同一段**,否则「别人撤回了,你这里还在多算」会一直算到重启为止。`test/host-sync.verify.mjs` 的两个方向、**两个后端**都钉着。
628
+ - **两个后端对「这个会话空了」必须给出同一种形状**:SQLite 的墓碑是 `DELETE FROM days`,`sessions` 行还在,于是 `load()` 返回一条**零行记录**;JSON 后端原先在最后一行被删时把整个 session 条目 `delete` 掉,`load()` 于是**少返回一条**。重载是 merge 语义(不能改成替换:只读库的 `put` 返回 null,内存里那次折叠是唯一的一份),它分不出「空了」和「从来没有过」——所以在 JSON 后端上墓碑撤回一直算到重启。现在 JSON 也保留空条目(`days: []`)。这是**只有两个后端都跑**才看得见的差异:契约套件(`cache.verify.mjs`)两个后端都跑却放过了它,因为它在「宿主重载」这一层,所以 `host-sync.verify.mjs` 也改成两个后端各跑一遍。
624
629
  - **「变短」要说出来**:折叠时若发现某会话的天数或总量变小,记一行日志、并在面板标一次「这段历史已脱离日志,缓存里仍保留」,让使用者能分辨「格式升级」和「真丢了东西」。
625
- - 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:614-623](../lib/index.js#L614-L623))。
630
+ - 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:1088-1099](../lib/index.js#L1088-L1099) —— 这条链接此前指向 `syncSystem()`,顺手改正)。
626
631
 
627
632
  ### 远端布局
628
633
 
@@ -783,9 +788,9 @@ storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at
783
788
  | `test/webdav.verify.mjs` ✅ **已实现(60 项)** | 纯函数:RFC 2617 向量(`Mufasa` 例子逐字段)、无 `qop` 的 RFC 2069 写法、`stale=true` 重试、UTF-8 与带逗号的 realm、`uri` 必须是请求行里的原始路径(含 `%XX`);以及对着假服务端的端到端:七个方法、`MOVE` 的 `Overwrite: F` 保护、404 语义、错误密码 / 一直挑战 / `SHA-256` / `Basic`-only 的四种失败各自报对原因 |
784
789
  | `test/webdav-server.mjs` ✅ **已实现(harness)** | 一个**假 WebDAV 服务端**(`node:http`,真发 Digest 挑战并按 `MD5(HA1:nonce:nc:cnonce:qop:HA2)` 校验,检查 `nc` 不回退、`uri` 与请求行一致),可切换 `qop: null`、`staleOnce`、`challengeEveryRequest`、`algorithm`、`scheme` 等恶意行为 |
785
790
  | `test/sync.verify.mjs` ✅ **已实现** | 分片与两个方向:路径/键的卫生(设备 id 与 `YYYY-MM` 不能带斜杠或穿越)、分片往返(stamp 与桶逐项保留)、**`source` 被改写成发布设备**、版本不符的分片与清单被具名拒绝、只发布本机 `current` 行(`fallback` 与别人的行都不发)、内容 sha256 决定「未变的月不重传」、首次拉取的窗口只限制拉取、`rowsToRecords()` 取最全 stamp、以及一次完整 trip(导出 → 空库导入 → 数字逐项相同、且**再导入一次不写任何东西**)。行模型的语义(截断/回滚/跨来源/从不删)由 `cache.verify.mjs` 的 section 8/9 覆盖,不在这里重测 |
786
- | `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1 而非明文、以及**配置写入不会覆盖上一次的状态** |
791
+ | `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1 而非明文、**配置写入不会覆盖上一次的状态**,以及三条「run 没走到 `ok`、但库里已经变了」的路径都报 `storeChanged`:**拉到行之后发布失败**、**分片在途中被中止(`aborted`)**、**分片行数比清单少(导入照做、失败照报)**;再加上「重放一份已经全有的分片写 0 行 ⇒ `storeChanged: false`」(宿主不必为此重载) |
787
792
  | `test/sync.live.mjs` ✅ **已实现(联网)** | 真实的 `bytemark/webdav` 容器上跑完整流水线:A 发布一天 → B 空库拉取并计数 → 再同步一次零传输 → 清理自己建的两个设备目录(**不删 `usage-badge/` 本身**)。URL / 账号 / 密码从环境变量取,缺失就跳过 |
788
- | `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 + 状态回填)、以及第二个实例拿到同一份配置但**被拒绝同步** |
793
+ | `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 + 状态回填)、第二个实例拿到同一份配置但**被拒绝同步**、以及**另一台机器同步来的用量在同一次进程里就计入徽标与日窗口(不重启)**——撤回方向也一样,且**两个后端各跑一遍**(这条差异只在 JSON 回退上出现,见「本地缓存」一节) |
789
794
  | `test/sync-triggers.verify.mjs` ✅ **已实现** | 两条自动触发:没配过同步的 home **什么都不做**(不建 `sync.json`、不拿锁、不发请求);配好并开启的 home 启动后**自己同步一次**(服务器上出现设备目录、状态记下那次运行);改一次价格表会**自动发布**(带上那次编辑、带设备副本、且不拖任何分片工作);服务器连不上时本地编辑**照样成功**、本地表照样生效 |
790
795
  | ⏳ **未做**:跨机同 `deviceId` 的 `instanceId` 报警 | 设计里写了、代码里还没接;本机并发的 owner 锁已经实现并测过 |
791
796
  | `test/webdav.live.mjs` ✅ **已实现(联网)** | 对着真实的 `bytemark/webdav` 容器跑:`OPTIONS`(打 realm 与 `DAV` 能力)→ `MKCOL` → `PUT` → `GET` 逐字节比对 → `PROPFIND` → `MOVE`(含撞名 412)→ `DELETE`,并在结尾清理自己创建的目录。URL / 账号 / 密码从环境变量取,缺失就跳过并打印怎么起容器 |
@@ -318,10 +318,14 @@ function createJsonStore({ path, log }) {
318
318
  if (!record) continue
319
319
  const keep = record.rows.filter((row) => !(rowSource(row) === source && String(row.date).startsWith(month)))
320
320
  removed += record.rows.length - keep.length
321
- if (keep.length === 0) {
322
- delete doc.sessions[sessionId]
323
- continue
324
- }
321
+ // A session left with **no rows keeps its entry**, empty. The SQLite backend's `sessions` row
322
+ // survives the same delete (`DELETE FROM days`), and the two backends have to answer this question
323
+ // the same way: `load()` returning a session with no rows means "this session is now empty", while
324
+ // dropping the entry means "there was never such a session" — and a reader that merges what it is
325
+ // given instead of replacing its world with it cannot tell those apart. The host's `loadCache` is
326
+ // exactly that reader (it must not drop a fold the store could not accept — a read-only store's
327
+ // `put` returns null, and the fold in memory is then the only copy), so a dropped entry here left
328
+ // the withdrawn month counted in memory until the host restarted.
325
329
  entry.days = keep.map((row) => ({ ...row }))
326
330
  }
327
331
  if (removed > 0) {
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/fold.js CHANGED
@@ -302,8 +302,15 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
302
302
  * The store has already resolved the copies it may hold for a `(session, date)` down to the
303
303
  * fuller log version; what arrives here is the merged view, with the raw `rows` alongside for
304
304
  * anything that needs to know where a day came from.
305
+ *
306
+ * It is also the only way rows that **arrived from another device** can enter this process: `refresh()`
307
+ * walks the logs, and a synced row has no log here to be discovered by. That is why the host calls it
308
+ * again after a sync run touches the store, and why that second call passes `quiet` — a reload is
309
+ * bookkeeping, not news, and a line per sync would read like the cache was being rebuilt from scratch.
310
+ *
311
+ * @param {{quiet?:boolean}} [options] `quiet` suppresses the summary line (the reload after a sync).
305
312
  */
306
- function loadCache() {
313
+ function loadCache({ quiet = false } = {}) {
307
314
  let loaded
308
315
  try {
309
316
  loaded = store.load()
@@ -323,7 +330,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
323
330
  })
324
331
  }
325
332
  mergeDays()
326
- log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
333
+ if (!quiet) log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
327
334
  return records.size > 0
328
335
  }
329
336
 
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
  }
@@ -821,6 +852,33 @@ export function apply(ctx) {
821
852
  if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
822
853
  try {
823
854
  const result = await instance.engine.run({ lock: attempt })
855
+ // A run changes the store in two ways that **no log cursor can see**: a pull imports rows another
856
+ // device published, and a withdrawn month deletes the rows that came from it. `refresh()` walks the
857
+ // session logs, so neither one would ever reach the in-memory view — the panel would keep showing
858
+ // what it had until the host process restarted, while the sync page (reading `sync.json`) reported
859
+ // the successful run. Reloading here is what makes "pulled 1 shard" and the numbers agree, and the
860
+ // engine's own `storeChanged` is the signal: it is true only when rows were written or deleted.
861
+ //
862
+ // Not "whenever the run was not locked": a run that never got past a failed request has nothing to
863
+ // reload, `storeChanged` is the engine's own answer to "did rows move", and a host that is on its
864
+ // way out must not touch a cache whose statements are about to be finalized — `syncAbort` is that
865
+ // flag, set by the disposer just before it closes the store. (A future way to *cancel* a run
866
+ // without unloading would leave `syncAbort` alone and would get its reload, which is right: this
867
+ // process keeps living and its view still has to catch up.)
868
+ if (result?.storeChanged && !instance.syncAbort) {
869
+ // Losing this must not turn a sync that worked into a reported failure, which is what an
870
+ // exception here would do on the way out of this `try`: the numbers stay stale (the very thing
871
+ // this reload is for) and the log says why, but the run is still what it was. `loadCache`
872
+ // already swallows a store it cannot read; this covers everything else.
873
+ try {
874
+ aggregator.loadCache({ quiet: true })
875
+ } catch (error) {
876
+ log(`the folded view could not be reloaded after the sync: ${error?.message ?? error}`)
877
+ }
878
+ // Nulled rather than left alone: the next poll would otherwise be allowed to answer from a
879
+ // snapshot built before these rows existed (it is reused for `SNAPSHOT_TTL_MS`).
880
+ snapshot = null
881
+ }
824
882
  // A shard may have carried a newer price table; the panel's next poll should see it.
825
883
  pricing = readPricing(pricingPaths)
826
884
  rules = resolveHolidayRules(pricing.holidays)
@@ -934,6 +992,10 @@ export function apply(ctx) {
934
992
  function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
935
993
  const configured = readSyncDocument(dataDir, log)
936
994
  if (!configured.enabled || !configured.url || !configured.ha1) return
995
+ if (configured.onStart === false) {
996
+ log('startup sync is switched off')
997
+ return
998
+ }
937
999
  const timer = setTimeout(() => {
938
1000
  startupSyncTimer = null
939
1001
  const instance = syncSystem()
@@ -948,6 +1010,56 @@ export function apply(ctx) {
948
1010
  }
949
1011
  let startupSyncTimer = null
950
1012
 
1013
+ /**
1014
+ * A last sync on the way out, awaited — but only for as long as there is time.
1015
+ *
1016
+ * DSH gives the whole application teardown **5 seconds** (`PROCESS_SHUTDOWN_TIMEOUT_MS` in the runtime)
1017
+ * before it exits the host process outright, and the desktop then waits up to 10 more for the child to go.
1018
+ * So the chain that reaches this function is: desktop asks the host to shut down → the host awaits its own
1019
+ * disposal → cordis disposes this plugin's effects, awaiting what they return. Returning a promise here is
1020
+ * what makes the sync happen at all instead of being cut off mid-request.
1021
+ *
1022
+ * The budget is what keeps that promise from becoming a slow quit: past it the run is told to stop at its
1023
+ * next step boundary (nothing half-written — uploads go through a temporary name and the manifest is
1024
+ * written last) and the disposal returns. A sync that did not finish is reported as such, not pretended.
1025
+ */
1026
+ async function syncOnExit() {
1027
+ // Read the configuration from disk when this session never touched sync: a home configured in an earlier
1028
+ // session, quit without opening the panel, still has to sync. Creating the system here is also what takes
1029
+ // the lock — and the store is still open at this point (the disposer closes it afterwards).
1030
+ const document = system?.engine?.config ?? readSyncDocument(dataDir, log)
1031
+ if (!document.enabled || !document.url || !document.ha1) return
1032
+ if (document.onExit === false) {
1033
+ log('exit sync is switched off')
1034
+ return
1035
+ }
1036
+ const instance = syncSystem()
1037
+ if (!instance.engine.store) return
1038
+ if (!instance.lock?.acquired) {
1039
+ log(`exit sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
1040
+ return
1041
+ }
1042
+ const budget = delayFor('DSH_USAGE_BADGE_EXIT_SYNC_MS', EXIT_SYNC_BUDGET_MS)
1043
+ if (budget <= 0) {
1044
+ // A zero budget means "there is no time for this" — the switch a test or a debugging session uses.
1045
+ log('exit sync skipped: no time budget')
1046
+ return
1047
+ }
1048
+ let timer = null
1049
+ const deadline = new Promise((resolve) => {
1050
+ timer = setTimeout(() => resolve('deadline'), budget)
1051
+ timer.unref?.()
1052
+ })
1053
+ const outcome = await Promise.race([runSync().then((result) => result ?? 'done', () => 'failed'), deadline])
1054
+ clearTimeout(timer)
1055
+ if (outcome === 'deadline') {
1056
+ instance.engine.abort()
1057
+ log(`exit sync did not finish within ${budget}ms; it stops at its next step boundary`)
1058
+ return
1059
+ }
1060
+ log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
1061
+ }
1062
+
951
1063
  async function warm() {
952
1064
  try {
953
1065
  const status = await refreshOnce()
@@ -1111,14 +1223,15 @@ export function apply(ctx) {
1111
1223
  // plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
1112
1224
  // leaving the host half.
1113
1225
  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.
1226
+ // Loopback only, like every other sync route: the answer names the server and the account.
1227
+ //
1228
+ // **And it creates nothing.** Opening the tab is looking, not deciding — so this no longer takes the
1229
+ // lock or writes `sync.json`; the system comes into being when something is saved, tested or run.
1230
+ // `writable` is still answered honestly, by reading the lock file rather than writing one.
1117
1231
  if (!isLoopback(req)) {
1118
1232
  sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
1119
1233
  return
1120
1234
  }
1121
- syncSystem()
1122
1235
  let body
1123
1236
  try {
1124
1237
  body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
@@ -1138,9 +1251,14 @@ export function apply(ctx) {
1138
1251
  const instance = syncSystem()
1139
1252
  const patch = await readJsonBody(req)
1140
1253
  const config = {}
1141
- for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
1254
+ for (const field of ['enabled', 'url', 'username', 'deviceLabel', 'deviceId', 'realm']) {
1142
1255
  if (patch[field] !== undefined) config[field] = patch[field]
1143
1256
  }
1257
+ // The switches, coerced: a panel sends booleans, but a hand-written request may send 0/1, and a
1258
+ // stored `"false"` string would otherwise read as *on* (it is not `false`).
1259
+ for (const field of ['allowInsecureTls', 'onStart', 'onExit']) {
1260
+ if (patch[field] !== undefined) config[field] = patch[field] !== false && patch[field] !== 'false' && patch[field] !== 0
1261
+ }
1144
1262
  if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
1145
1263
  if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
1146
1264
  try {
@@ -1259,12 +1377,27 @@ export function apply(ctx) {
1259
1377
  // Fire and forget: this only fills in what the server holds, for the panel's next read.
1260
1378
  void refreshRemoteDevices()
1261
1379
  })
1262
- return () => {
1380
+ // The disposer is **async on purpose**, and everything below depends on the order:
1381
+ //
1382
+ // 1. stop automatic work that would fire into a process that is going away;
1383
+ // 2. sync once, while the store is still open (a sync reads it) and while we still own the lock;
1384
+ // 3. hand the lock back and close the store.
1385
+ //
1386
+ // Returning a promise is what makes step 2 real: DSH awaits the host's teardown, cordis awaits what an
1387
+ // effect's disposer returns, and the runtime force-exits only after 5 s. So this is not "fire and hope";
1388
+ // it is synchronised with the shutdown — and bounded, so a slow server cannot hold up quitting.
1389
+ return async () => {
1263
1390
  disposed = true
1264
1391
  // Any pending automatic work is dropped on unload: a push that fires while the host is going away
1265
1392
  // would write state for a process that no longer exists.
1266
1393
  if (pricingPushTimer) clearTimeout(pricingPushTimer)
1267
1394
  if (startupSyncTimer) clearTimeout(startupSyncTimer)
1395
+ if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
1396
+ try {
1397
+ await syncOnExit()
1398
+ } catch (error) {
1399
+ log(`exit sync failed: ${error?.message ?? error}`)
1400
+ }
1268
1401
  // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
1269
1402
  // re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
1270
1403
  // refused by this plugin's own lock, for a pid that is very much alive: its own.
@@ -1278,7 +1411,6 @@ export function apply(ctx) {
1278
1411
  // touch the store do not start.
1279
1412
  system.engine.abort()
1280
1413
  system.engine.release()
1281
- if (system.running) log('unloading while a sync is in flight; it stops at its next step boundary')
1282
1414
  }
1283
1415
  releaseLock(dataDir)
1284
1416
  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. */
@@ -823,7 +827,12 @@ export class SyncEngine {
823
827
  /**
824
828
  * Pull: fetch the shards this device does not already hold, and store their rows.
825
829
  *
826
- * @returns {{fetched:number, rows:number, failed:Array<{path:string, error:string}>}}
830
+ * `wrote` is the honest answer to "did the store change", which is not the same as "did rows arrive":
831
+ * a shard that decodes into fewer rows than its manifest names is still imported (that part is
832
+ * idempotent) and then reported as a failure, and a tombstone **deletes** rows without any arriving at
833
+ * all. The host uses it to decide whether its in-memory view is now stale.
834
+ *
835
+ * @returns {{fetched:number, rows:number, failed:Array<{path:string, error:string}>, wrote:boolean}}
827
836
  */
828
837
  async pull(client, manifests) {
829
838
  const wanted = planDownload({
@@ -847,6 +856,11 @@ export class SyncEngine {
847
856
  })
848
857
  let rows = 0
849
858
  let fetched = 0
859
+ // `wrote` comes from the two places that touch the store, and it is the **store's own count** that
860
+ // decides it: a shard replayed against a store that already holds every one of its rows writes nothing,
861
+ // and claiming the view is stale then would cost a full reload for nothing (`rows` cannot answer this —
862
+ // it counts rows the manifest vouched for, which is not the same as rows that changed).
863
+ let wrote = false
850
864
  const failed = []
851
865
  for (const item of wanted) {
852
866
  // One shard at a time, and a shard is the natural boundary: stopping here leaves the store consistent
@@ -882,7 +896,10 @@ export class SyncEngine {
882
896
  // and the honest answer is to keep asking. This build never writes such a shard; an older one or a
883
897
  // hand-made file can.
884
898
  if (decoded.malformed > 0 || (item.entry.rows > 0 && decoded.rows.length !== item.entry.rows)) {
885
- this.store?.intakeRows(decoded.rows)
899
+ // Imported even though the shard is about to be reported as a failure, so the store's own count is
900
+ // what says whether the host's view moved. (`rows` stays at zero here: it counts what the manifest
901
+ // vouched for, and the manifest is exactly what is in question.)
902
+ if (this.store && this.store.intakeRows(decoded.rows) > 0) wrote = true
886
903
  failed.push({
887
904
  path: item.entry.path,
888
905
  error: `the shard holds ${decoded.rows.length} of the ${item.entry.rows || decoded.rows.length} rows its manifest names` + (decoded.malformed ? ` (${decoded.malformed} unreadable line(s))` : ''),
@@ -900,7 +917,10 @@ export class SyncEngine {
900
917
  if (this.store) {
901
918
  // `replace`: the month is this device's own statement about it, so rows it no longer lists go.
902
919
  // Without that, a publisher that dropped a row keeps every reader counting it forever.
903
- this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
920
+ const imported = this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
921
+ // In replace mode the count includes the rows it *deleted* as well as the ones it wrote, which is
922
+ // exactly the question being asked here.
923
+ if (imported > 0) wrote = true
904
924
  // `held` is only advanced once the rows are in the store: if the import throws, the shard
905
925
  // must be fetched again rather than remembered as applied.
906
926
  this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
@@ -925,6 +945,7 @@ export class SyncEngine {
925
945
  // Only what this reader actually imported: a tombstone for a month it never held is nothing.
926
946
  if (!this.state.held?.[context]) continue
927
947
  const removed = this.store?.dropSourceMonth(device, key) ?? 0
948
+ if (removed > 0) wrote = true
928
949
  const next = { ...(this.state.held ?? {}) }
929
950
  delete next[context]
930
951
  this.state.held = next
@@ -933,7 +954,7 @@ export class SyncEngine {
933
954
  this.log(`dropped ${removed} row(s) from ${context}: the device that published it no longer holds that month (withdrawn ${entry.at ? new Date(entry.at).toISOString().slice(0, 10) : 'at some point'})`)
934
955
  }
935
956
  }
936
- return { fetched, rows, failed, dropped, droppedKeys }
957
+ return { fetched, rows, failed, dropped, droppedKeys, wrote }
937
958
  }
938
959
 
939
960
  /**
@@ -1314,9 +1335,15 @@ export class SyncEngine {
1314
1335
  // statements are finalized, so continuing is not harmless.
1315
1336
  if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1316
1337
 
1338
+ // Whether this run changed the local store, which is a different question from whether it succeeded:
1339
+ // a pull that imported rows and then failed to publish them has still made the host's in-memory view
1340
+ // stale. Declared outside the `try` so every path out of it — including a failure and an abort — can
1341
+ // report it. The host decides what to do with it: an abort because *it* is unloading is skipped there
1342
+ // (`syncAbort`), while an abort for any other reason still has a live process to catch up.
1343
+ let storeChanged = false
1317
1344
  try {
1318
1345
  const { manifests } = await this.readManifests(client)
1319
- if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1346
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started, extra: { storeChanged } })
1320
1347
  // **Pull first, refuse only the push.** An installation whose device name is taken still wants the
1321
1348
  // history back, and that is the case the feature exists for (reinstall, another machine): the name
1322
1349
  // is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
@@ -1330,8 +1357,9 @@ export class SyncEngine {
1330
1357
  // refusing to publish — the one case where the two readings of `collision` differ.
1331
1358
  this.pullOwnDirectory = Boolean(clash)
1332
1359
  const pulled = await this.pull(client, manifests)
1360
+ storeChanged = pulled.wrote
1333
1361
  this.pullOwnDirectory = false
1334
- if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started })
1362
+ if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started, extra: { storeChanged } })
1335
1363
  const pushed = await this.push(client)
1336
1364
  if (pushed.manifest) {
1337
1365
  this.state.published = pushed.manifest.chunks
@@ -1341,7 +1369,7 @@ export class SyncEngine {
1341
1369
  }
1342
1370
  // The price table last: it is the only hand-written thing here, and it should not be touched by
1343
1371
  // a run that failed to move the usage it was about to move.
1344
- if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
1372
+ if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started, extra: { storeChanged } })
1345
1373
  const pricing = await this.syncPricing(client)
1346
1374
  const parts = [
1347
1375
  `pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
@@ -1377,12 +1405,14 @@ export class SyncEngine {
1377
1405
  refused: pushed.refused ?? null,
1378
1406
  withdrawn: pushed.withdrawn ?? 0,
1379
1407
  dropped: pulled.dropped ?? 0,
1408
+ // Named for the host, not for the panel: it is what tells the host its aggregate is now stale.
1409
+ storeChanged,
1380
1410
  },
1381
1411
  })
1382
1412
  } catch (error) {
1383
1413
  const message = String(error?.message ?? error)
1384
1414
  this.log(`sync failed: ${message}`)
1385
- return this.finish('failed', message, { started })
1415
+ return this.finish('failed', message, { started, extra: { storeChanged } })
1386
1416
  }
1387
1417
  }
1388
1418
 
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.8",
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",