dsh-usage-badge 0.1.5 → 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 +55 -8
- package/lib/cache-store.js +113 -14
- package/lib/client.js +160 -12
- package/lib/index.js +210 -39
- package/lib/sync.js +292 -57
- package/lib/webdav.js +67 -15
- 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
|
### 第六轮:一轮对抗性审查找到并修掉的问题
|
|
@@ -442,13 +446,56 @@ npm test
|
|
|
442
446
|
| A15 | **`deviceId = 'local'` 被允许**,而 `source: 'local'` 正是「本机自己的行」这条不变量的标记 | 一台叫 `local` 的机器让所有对端把它人的行当成自己的再转发,本机 fold 还能删掉它们 | `safeDeviceId` 拒绝 `local`(它同时是路径段和归属标记) |
|
|
443
447
|
| A16 | **`run()` 与自动价格推送之间没有互斥**(同进程共用一把锁当串行化用) | 分片 PUT 与价格表 PUT 交错 | 推送排进同一条链(`instance.pushing`),与 `run()` 串行 |
|
|
444
448
|
| A17 | `GET /sync` 无 loopback 检查,且会拿锁建文件 | 局域网内可读到地址/用户名/前缀 | `GET /sync` 也要求 loopback |
|
|
449
|
+
| A18 | **同步页签的表单只在挂载时读一次初值**,`GET /sync` 回来后只更新表单**下面**的状态,**从不回填输入框** | 重启或刷新页面后地址/用户名全空 —— 配置明明还在 `sync.json` 里,看起来像「我的设置丢了」。实测:`url`/`username`/`ha1`/`deviceId` 都在文件里且上次同步是 `ok`,面板却是空的 | 配置到达时回填一次(`fillSyncForm`:**只填空的,绝不覆盖**用户已输入的内容);规则抽成纯函数并从 bundle 导出,测试直接测它 —— 这个项目的测试环境装不了组件(`react-dom/test-utils` 被包导出挡住),所以宁可测纯函数也不写「自造 DOM 再断言自己」的假测试 |
|
|
450
|
+
| A19 | 保存路由只校验「是不是 URL」,**放行没有结尾斜杠的地址**,而客户端构造函数**明确拒绝**它 | 面板会存下一个**它永远用不了**的地址,并在自己的字段里显示它;真去连时抛「must end with /」,而 bytemark 对无斜杠目录回 301,看起来像密码错 | 保存时规范化(补斜杠,响应回传规范化后的值,面板字段改用返回值);相对根路径与已带斜杠的地址都不动 |
|
|
451
|
+
| A20 | **同一条 URL 规则被写了两遍**(宿主一份、客户端一份),于是规则漂移 | 保存路由接受了 **6 种客户端明确拒绝的地址**:`?query`、`#frag`、`user:pw@`、`ftp:`、`nas:19347/dav/`(无协议)—— 面板存下其中之一并显示在字段里,而同步**完全跑不起来**。这是「同一规则两处实现」的典型后果,不是漏了某一条校验 | 抽成 `normalizeWebdavUrl()`(`lib/webdav.js` 导出),路由与构造函数**共用同一个函数**;`allowTrailingSlashFix` 只在路由侧开启(补斜杠),客户端侧关闭(存下来的地址必须已经正确)。另加一条**不变量断言**:路由接受的每一种输入,客户端都必须能构造出可用客户端 —— 参数化跑 12 种地址 |
|
|
452
|
+
| A21 | 我写 `normalizeWebdavUrl` 时的**错判**:以为 `new URL('http://nas:19347').pathname` 是空串 | 一度「修」出一个 `pathname !== '/'` 的条件,反而让严格模式接受了本该拒绝的地址。实际上 `pathname` 就是 `"/"`:**裸主机本来就是集合、本来就以斜杠结尾**,什么都不用补 | 回退该条件,并把这件事实写成注释 + 两条断言(裸主机不被改动;带路径而缺斜杠的仍被拒)。教训:**改代码前先实测那个「事实」**,不要凭记忆断言 |
|
|
453
|
+
|
|
454
|
+
### 第八轮:把「已知未修」清掉 —— 墓碑、中止、无法辨认的目录、大小写、原子锁
|
|
455
|
+
|
|
456
|
+
这一轮的目标是上一轮诚实列出的每一条「已知未修」。其中「撤回不传播给已拉过的对端」需要真正的语义扩展,不是加一个判断。
|
|
457
|
+
|
|
458
|
+
| # | 问题 | 后果 | 修法 |
|
|
459
|
+
|---|---|---|---|
|
|
460
|
+
| C-2 | **撤回不传播给已经导入过它的对端**:`intakeRows` 只增,发布者删掉一行后,每个读者永远保留它 | 实测:发布者 2222、读者 5555,且**永久**分叉(方向是别人多、自己少) | 两半机制:①导入改为**替换语义** —— 一个分片是一个设备对一个月的**陈述**,它不再列出的行要删掉(只删那个设备自己的行,永不碰 `local` 或别的设备);②整月消失时清单留下**墓碑** `retired: {月: {at, sha}}`,读者据此把自己从该设备导入的整月丢掉。墓碑在月份回来时自动清除,所以恢复日志不会把它卡住 |
|
|
461
|
+
| — | 连带发现:**同一日志版本下订正的 payload 被「严格更新才覆盖」挡掉**(100 → 400 同 seq 同 size) | 替换语义形同虚设,两台机器仍不一致 | 替换模式下来源分片就是权威,不再让 stamp 比较否决;非替换路径保持原规则(更短的日志不得覆盖更长的) |
|
|
462
|
+
| — | 连带发现:替换语义会放大**回滚**的破坏力(服务器被还原成旧分片 → 读者的新行被删) | 一次还原备份就能让读者丢数据 | `held` 记 `{sha, at}`(旧格式的裸 sha 仍可读);分片比已应用的**更早**时整片跳过并记日志 |
|
|
463
|
+
| D-21 | 卸载时正在飞的同步不会停 | 卸载后仍去写状态、用已关闭的 store(`statement has been finalized`),极端的时序下与重挂载的新实例同时写同一分区 | `SyncEngine.abort()` + 每个阶段边界检查(读清单后、拉取后、推送前、价格表前,以及拉/推的每片之间);宿主卸载时调用,结果记为 `aborted` |
|
|
464
|
+
| D-15 | 目录存在但清单读不出来时,冲突保护看不到它 | 两台机器可能写同一分区而无人察觉 | 这类目录按**已占用**处理并拒绝发布(`refused: 'unreadable'`);但本设备**曾经发布过**该目录时例外 —— 那是自己的半截清单,修复它正是下一次发布的意义 |
|
|
465
|
+
| — | **大小写不敏感的 WebDAV 服务器**上 `Desk` 与 `desk` 是同一个目录,而冲突判定按字符串比较 | 理论上可互相覆盖 | 发现只差大小写的同名目录时拒绝发布(`refused: 'case'`)。目标镜像区分大小写,这里宁可保守:只差大小写的名字从来不是谁的本意 |
|
|
466
|
+
| — | **URL 的两个观感项**:`http:///dav` 被 WHATWG 当成主机 `dav`(少了主机的笔误被静默指向另一台机器);`dav%2f` 的尾斜杠补在编码片段之后(Apache 默认策略下 404) | 前者会连到**别的机器**,后者会存下一个永远 404 的地址 | 两者都**明确拒绝**并说明原因:`afterScheme` 为空或以 `/` 开头 → 「没有主机」;路径里出现 `%2f` → 「编码斜杠,这个服务器家族默认拒绝,请把 `/` 写出来」。`%20` 之类**保留**(它是名字里的真实字符) |
|
|
467
|
+
| — | **`acquireLock` 不是原子的**(`readLock` → `writeFileSync`,无 `O_EXCL`) | 两个进程同时接管一把陈旧锁时理论上可双双认为成功 | 改为 `writeFileSync(..., { flag: 'wx' })`:**由内核裁决**,一个成功、另一个拿到 `EEXIST`;陈旧锁仍然是「删掉再 `wx`」,而两个进程同时删+抢时只有一个的 `wx` 会成功 |
|
|
445
468
|
|
|
446
469
|
**已知未修(诚实列出)**:
|
|
447
470
|
|
|
448
|
-
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
471
|
+
- **解压没有大小上限**:一个 zstd 炸弹会占满内存。分片来自自己的服务器,与清单同属一个信任域,且给它加上限只会变成一个新的失败模式,所以暂不设限。
|
|
472
|
+
|
|
473
|
+
### 第七轮:改名之后的两处深层问题 + 宿主生命周期
|
|
474
|
+
|
|
475
|
+
这一轮由两个只读审查者(一个随机化属性测试)交回,其中**由「改名」这个受支持操作触发**的问题最要紧:改名会留下一个旧目录,而**每个读者都把发现的每份清单当权威**,于是旧目录里的陈旧数据会喂给所有新机器。
|
|
476
|
+
|
|
477
|
+
| # | 问题 | 后果 | 修法 |
|
|
478
|
+
|---|---|---|---|
|
|
479
|
+
| C-1 | 改名后旧目录无人维护:改名后撤回的月份、或被订正的行,仍然通过旧目录回到所有新读者 | 实测:设备实际持有 100,空库新机器读到 **300**;随机化(3×60 轮)**28/180** 违反不变量,首个反例全部紧跟 rename+retire | 把 `instanceId` 当作**安装身份**:本安装的**所有**目录都算自己的。发布后把其它目录里「与当前内容哈希不一致」的分片全部撤回(只撤月不够:一个月可以留着而其中一行消失),清单同步改写 |
|
|
480
|
+
| C-1b | 连带发现:`chunkRows` 只导出 `source === 'local'` 的行,但改名后设备会从**自己旧名下**导入一份副本 | 那份是它自己的历史,却永远不再被转发 → 本机算 18766、发出去的只有 10000,新机器永远少一截 | `owns` 支持一组名字(`local` + 本安装的所有设备名);`encodeShard` 把行来源**统一盖成发布它的设备**,不再沿用旧名(否则接收方库里会出现一个服务器上不存在的来源) |
|
|
481
|
+
| C-3 | 分片里 `malformed` 的行被静默丢弃,`held` 仍被推进、结果报 `ok` | 手写/旧版分片会造成**永久少行**且不再重试 | 解出的行数与清单声明的 `rows` 不一致(或有不可解析行)→ 记 `failed`、不推进 `held`;好行照常入库(幂等) |
|
|
482
|
+
| C-4 | 撤回循环中途失败时「先删后写」的顺序不对 | 分片已删、清单仍声明它 → 新读者永久 `partial` | **先删完再写清单**:删成功的月从清单移除,删失败的保留(声明与字节永不矛盾),失败写进 message 与结果 |
|
|
483
|
+
| C-2 | 撤回不传播给**已经导入过**的对端(`intakeRows` 永不删) | 两台机器数字长期分叉(方向:别人多、自己少) | **未修**,见文末「已知未修」;需要 tombstone 或「减少」语义 |
|
|
484
|
+
|
|
485
|
+
| # | 宿主/面板问题 | 后果 | 修法 |
|
|
486
|
+
|---|---|---|---|
|
|
487
|
+
| D-1 | 「首次拉取」用 `?? 5` 播种,而回填只填空值 → 该字段永不为空 | 任何非 5 的已保存值(0/3/10)刷新后都显示 5 —— 与「地址不显示」同一类症状 | 播种改成 `''`(表单只在拿到状态后才渲染,默认值没有用处) |
|
|
488
|
+
| D-2 | 勾选框不进签名,且只可能被置 `true` | 同页面内配置由 true 变 false(另一标签页/手改)后,勾选框一直显示已勾 | 签名纳入 `allowInsecureTls`;`!formDirty` 时按保存值**双向**设置(勾选框没有「空」状态可保护) |
|
|
489
|
+
| D-6 | 请求体不是 JSON 时裸 `JSON.parse` 抛错 | 路由回 **500**,把「调用方写错了」报成「宿主坏了」 | `readJsonBody()` 包一层,回 **400** |
|
|
490
|
+
| D-7 | `GET /sync` 回 200+`{error}` 时面板只记 failNote 而不改 state | 渲染门是 `state === 'failed'`,于是页面永远停在「正在读取同步配置…」 | 同时置 `failed` |
|
|
491
|
+
| D-9 | `hasCredential` 把旧版的 `password`/`passwordHash` 也算作有凭据 | 面板显示「凭据已配置」、按钮可点,一点就 `no WebDAV credential is configured` | 只认 `ha1`(`connect()` 只用它) |
|
|
492
|
+
| D-12 | **realm 不是 `WebDAV` 的服务器根本配不上**:保存时已把 realm 填成默认值,测试连接里「realm 为空才采纳」的分支永远不成立 | 用户只会看到 401,没有任何线索说 realm 不对 | 失败的连接检查也回传服务器宣告的 realm;面板据此改写 realm 并明确提示「请重新输入一次密码」 |
|
|
493
|
+
| D-16 | **在飞同步时卸载会留下锁**:宿主把锁对象交给引擎,而引擎只释放自己获取的(`ownsLock`) | 热重载后同目录**永久只读**(被自己那个还活着的 pid 拒绝) | 清理里直接 `releaseLock(dataDir)`(它按 pid 判断,只会删自己的) |
|
|
494
|
+
| D-17 | `warm()` 尚未 resolve 时卸载:回调照跑,**卸载之后**再排一次启动同步 | 拿回锁、用已关闭的 store 同步(`statement has been finalized`)、留下锁文件 | 清理里置 `disposed`,warm 回调开头检查 |
|
|
495
|
+
| D-19 | `skipped`(排期后用户关了同步)也会盖 `lastSyncAt` | 面板显示「上次同步:刚刚」,其实什么都没同步 | `finish()` 只对非 `skipped` 盖时间戳(`locked` 本来就提前 return) |
|
|
496
|
+
| D-20 | `run()` 与自动价格推送是**两条独立的链**(我上一轮以为已经串行) | 实测两边的请求已在网络上交叠;两者同改 `engine.state` 与 `sync.json`,谁后完成谁决定面板说什么 | 抽出 `enqueue()`:所有对服务器的操作走同一条队列 |
|
|
497
|
+
|
|
498
|
+
**这一轮的方法论**:审查者 C 用**随机化属性测试**(每轮随机 grow/sync/rename/collide/retire/loseState/corruptHeld,然后新建一台空库设备,断言「空库总量 === 设备真实用量」)在 180 轮里抓出 28 轮反例 —— 这类不变量测试抓到了逐条断言抓不到的交互缺陷。审查者 D 两次主动报告基线漂移,并以「结论对应修订哈希」收尾。
|
|
452
499
|
|
|
453
500
|
**每一步抓到真 bug 的清单**(几乎都是「离线假服务端通过、真实容器才暴露」或反之):
|
|
454
501
|
|
package/lib/cache-store.js
CHANGED
|
@@ -303,6 +303,33 @@ function createJsonStore({ path, log }) {
|
|
|
303
303
|
}
|
|
304
304
|
return [...years].filter((year) => /^\d{4}$/.test(year)).sort().reverse()
|
|
305
305
|
},
|
|
306
|
+
/**
|
|
307
|
+
* Drop one device's whole month — the reading side of a **tombstone**. Only that device's rows go:
|
|
308
|
+
* never this device's own fold, and never another device's rows.
|
|
309
|
+
*
|
|
310
|
+
* @returns {number} how many rows were removed.
|
|
311
|
+
*/
|
|
312
|
+
dropSourceMonth(source, month) {
|
|
313
|
+
if (!source || !/^\d{4}-\d{2}$/.test(String(month))) return 0
|
|
314
|
+
const doc = ensureDocument()
|
|
315
|
+
let removed = 0
|
|
316
|
+
for (const [sessionId, entry] of Object.entries(doc.sessions ?? {})) {
|
|
317
|
+
const record = documentToRecord(sessionId, entry)
|
|
318
|
+
if (!record) continue
|
|
319
|
+
const keep = record.rows.filter((row) => !(rowSource(row) === source && String(row.date).startsWith(month)))
|
|
320
|
+
removed += record.rows.length - keep.length
|
|
321
|
+
if (keep.length === 0) {
|
|
322
|
+
delete doc.sessions[sessionId]
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
entry.days = keep.map((row) => ({ ...row }))
|
|
326
|
+
}
|
|
327
|
+
if (removed > 0) {
|
|
328
|
+
dirty = true
|
|
329
|
+
flush()
|
|
330
|
+
}
|
|
331
|
+
return removed
|
|
332
|
+
},
|
|
306
333
|
/**
|
|
307
334
|
* Store one fold and hand back the resolved view of that session.
|
|
308
335
|
*
|
|
@@ -389,15 +416,22 @@ function createJsonStore({ path, log }) {
|
|
|
389
416
|
/**
|
|
390
417
|
* Store rows that came from another device, and hand back how many were new.
|
|
391
418
|
*
|
|
392
|
-
* The SQLite backend's rules on the document: never delete, and store a row
|
|
393
|
-
* strictly fuller than the one already held for that `(session, date, source, slot)`.
|
|
394
|
-
* session because a shard's rows arrive together but a document is edited per session.
|
|
419
|
+
* The SQLite backend's rules on the document: never delete **another device's** rows, and store a row
|
|
420
|
+
* only when it is strictly fuller than the one already held for that `(session, date, source, slot)`.
|
|
421
|
+
* Grouped by session because a shard's rows arrive together but a document is edited per session.
|
|
422
|
+
*
|
|
423
|
+
* With `replace`, that device's own rows for one month are treated as *its* statement about that month:
|
|
424
|
+
* rows it no longer lists are removed. That is not the same as letting another machine erase this one's
|
|
425
|
+
* history — the rows removed belong to the device that is speaking, which is the only party entitled to
|
|
426
|
+
* say what it holds.
|
|
395
427
|
*/
|
|
396
|
-
intakeRows(rows) {
|
|
428
|
+
intakeRows(rows, { replace = null } = {}) {
|
|
397
429
|
const doc = ensureDocument()
|
|
398
430
|
const bySession = new Map()
|
|
431
|
+
const incoming = new Set()
|
|
399
432
|
for (const row of rows ?? []) {
|
|
400
433
|
if (!row?.sessionId || typeof row.date !== 'string') continue
|
|
434
|
+
incoming.add(`${row.sessionId}\u0000${row.date}\u0000${rowSource(row)}\u0000${rowKind(row)}`)
|
|
401
435
|
let list = bySession.get(row.sessionId)
|
|
402
436
|
if (!list) {
|
|
403
437
|
list = []
|
|
@@ -406,17 +440,31 @@ function createJsonStore({ path, log }) {
|
|
|
406
440
|
list.push(row)
|
|
407
441
|
}
|
|
408
442
|
let written = 0
|
|
409
|
-
for (const [sessionId,
|
|
443
|
+
for (const [sessionId, arriving] of bySession) {
|
|
410
444
|
const existing = documentToRecord(sessionId, doc.sessions[sessionId])
|
|
411
445
|
const merged = new Map((existing?.rows ?? []).map((row) => [`${row.date}\u0000${rowSource(row)}\u0000${rowKind(row)}`, row]))
|
|
412
|
-
for (const row of
|
|
446
|
+
for (const row of arriving) {
|
|
413
447
|
const kind = rowKind(row) === FALLBACK ? FALLBACK : CURRENT
|
|
414
448
|
const key = `${row.date}\u0000${row.source}\u0000${kind}`
|
|
415
449
|
const known = merged.get(key)
|
|
416
|
-
if (known
|
|
450
|
+
if (known) {
|
|
451
|
+
if (JSON.stringify(known.routes ?? {}) === JSON.stringify(row.routes ?? {})) continue
|
|
452
|
+
// See the SQLite backend: replace mode takes the publisher's word for its own month, while a
|
|
453
|
+
// plain import only lets a strictly fuller copy through.
|
|
454
|
+
if (!replace && !rowIsStrictlyNewer(row, known)) continue
|
|
455
|
+
}
|
|
417
456
|
merged.set(key, { ...row, kind })
|
|
418
457
|
written++
|
|
419
458
|
}
|
|
459
|
+
if (replace) {
|
|
460
|
+
for (const [key, row] of [...merged]) {
|
|
461
|
+
if (rowSource(row) !== replace.source) continue
|
|
462
|
+
if (!String(row.date).startsWith(replace.month)) continue
|
|
463
|
+
if (incoming.has(`${sessionId}\u0000${key}`)) continue
|
|
464
|
+
merged.delete(key)
|
|
465
|
+
written++
|
|
466
|
+
}
|
|
467
|
+
}
|
|
420
468
|
const rows_ = [...merged.values()]
|
|
421
469
|
const fullest = rows_.reduce((top, row) => (top === null || rowIsStrictlyNewer(row, top) ? row : top), null)
|
|
422
470
|
const previous = doc.sessions[sessionId]
|
|
@@ -664,6 +712,10 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
664
712
|
? prepare('INSERT OR REPLACE INTO days (session_id, date, source, kind, seq, file_size, file_mtime_ms, at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
665
713
|
: null
|
|
666
714
|
const deleteRow = writable ? prepare('DELETE FROM days WHERE session_id = ? AND date = ? AND source = ? AND kind = ?') : null
|
|
715
|
+
/** Every row one device claims for one month — what a replacing import has to compare against. */
|
|
716
|
+
const selectSourceMonth = tryPrepare('SELECT session_id, date, kind, payload FROM days WHERE source = ? AND substr(date, 1, 7) = ?')
|
|
717
|
+
/** Drop one device's whole month: what a device's own "I no longer hold this" means to a reader. */
|
|
718
|
+
const deleteSourceMonth = writable ? prepare("DELETE FROM days WHERE source = ? AND substr(date, 1, 7) = ?") : null
|
|
667
719
|
const upsertSession = writable
|
|
668
720
|
? prepare(
|
|
669
721
|
`INSERT INTO sessions (id, path, file_size, file_mtime_ms, seq, provider, model)
|
|
@@ -780,25 +832,53 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
780
832
|
* `(date, source, slot)`. An equal-stamp copy is the same bytes and is left as it is; a weaker
|
|
781
833
|
* one is a worse answer to the same question.
|
|
782
834
|
*
|
|
783
|
-
*
|
|
835
|
+
* With `replace`, one device's rows for one month are taken as *that device's own statement* about the
|
|
836
|
+
* month, so rows it no longer lists are removed. Without it, two machines that both pulled a month keep
|
|
837
|
+
* different totals forever: the publisher drops a row, republishes, and every reader keeps the old one,
|
|
838
|
+
* because an import only ever adds. The rows removed belong to the device that is speaking, which is the
|
|
839
|
+
* only party entitled to say what it holds — not the "another machine erased my history" case the first
|
|
840
|
+
* rule forbids.
|
|
841
|
+
*
|
|
842
|
+
* @param {object[]} rows
|
|
843
|
+
* @param {{replace?:{source:string, month:string}}} [options]
|
|
844
|
+
* @returns {number} how many rows were written or removed.
|
|
784
845
|
*/
|
|
785
|
-
const intakeRows = (rows) => {
|
|
846
|
+
const intakeRows = (rows, { replace = null } = {}) => {
|
|
786
847
|
const known = new Map(readSessionRows(undefined).map((row) => [`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${row.kind}`, row]))
|
|
787
848
|
let written = 0
|
|
849
|
+
const arriving = new Set()
|
|
788
850
|
for (const row of rows ?? []) {
|
|
789
851
|
if (!row?.sessionId || typeof row.date !== 'string') continue
|
|
790
852
|
const kind = rowKind(row) === FALLBACK ? FALLBACK : CURRENT
|
|
853
|
+
arriving.add(`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${kind}`)
|
|
791
854
|
// The day rows are only reachable through a session row (`readRecords` walks `sessions` and
|
|
792
855
|
// hangs the days off it), so an import has to make sure one exists — otherwise the history is
|
|
793
856
|
// written into a table nothing reads.
|
|
794
857
|
const existing = known.get(`${row.sessionId}\u0000${row.date}\u0000${row.source}\u0000${kind}`)
|
|
795
|
-
if (existing && !rowIsStrictlyNewer(row, existing)) continue
|
|
796
858
|
const payload = JSON.stringify(row.routes ?? {})
|
|
797
|
-
if (existing
|
|
798
|
-
|
|
859
|
+
if (existing) {
|
|
860
|
+
// Same bytes: nothing to write, and nothing to argue about.
|
|
861
|
+
if (existing.payload === payload) continue
|
|
862
|
+
// Outside replace mode only a **strictly fuller** copy may overwrite what is held — that is what
|
|
863
|
+
// keeps a shorter log from erasing a longer one. In replace mode the incoming shard is the
|
|
864
|
+
// publisher's own statement about that month, so it wins even at the same log version: a corrected
|
|
865
|
+
// day (100 → 400) carries the same stamp, and vetoing it would leave the two machines disagreeing.
|
|
866
|
+
if (!replace && !rowIsStrictlyNewer(row, existing)) continue
|
|
867
|
+
deleteRow.run(row.sessionId, row.date, row.source, kind)
|
|
868
|
+
}
|
|
799
869
|
writeRow(row.sessionId, { ...row, kind })
|
|
800
870
|
written++
|
|
801
871
|
}
|
|
872
|
+
if (replace && writable) {
|
|
873
|
+
// Everything this device used to claim for the month that it does not claim now. Deleting only its
|
|
874
|
+
// own rows is what makes this safe: this device's own fold and every other device's rows are untouched.
|
|
875
|
+
for (const row of all(selectSourceMonth, replace.source, replace.month)) {
|
|
876
|
+
const kind = row.kind ?? CURRENT
|
|
877
|
+
if (arriving.has(`${row.session_id}\u0000${row.date}\u0000${replace.source}\u0000${kind}`)) continue
|
|
878
|
+
deleteRow.run(row.session_id, row.date, replace.source, kind)
|
|
879
|
+
written++
|
|
880
|
+
}
|
|
881
|
+
}
|
|
802
882
|
// One cursor per session that arrived, from the fullest row that arrived for it.
|
|
803
883
|
const bySession = new Map()
|
|
804
884
|
for (const row of rows ?? []) {
|
|
@@ -925,6 +1005,25 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
925
1005
|
years() {
|
|
926
1006
|
return all(selectYears).map((row) => String(row.year))
|
|
927
1007
|
},
|
|
1008
|
+
/**
|
|
1009
|
+
* Drop one device's whole month.
|
|
1010
|
+
*
|
|
1011
|
+
* This is what a **tombstone** means on the reading side: the device that published the month says it
|
|
1012
|
+
* no longer holds any of it, so the copy this store imported is no longer backed by anything. Only that
|
|
1013
|
+
* device's rows go — never this device's own fold, and never another device's rows.
|
|
1014
|
+
*
|
|
1015
|
+
* @returns {number} how many rows were removed.
|
|
1016
|
+
*/
|
|
1017
|
+
dropSourceMonth(source, month) {
|
|
1018
|
+
if (!writable || !source || !/^\d{4}-\d{2}$/.test(String(month))) return 0
|
|
1019
|
+
try {
|
|
1020
|
+
const result = deleteSourceMonth.run(String(source), String(month))
|
|
1021
|
+
return Number(result?.changes) || 0
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
log(`could not drop ${source}/${month}: ${error?.message ?? error}`)
|
|
1024
|
+
return 0
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
928
1027
|
/**
|
|
929
1028
|
* Store one fold and hand back the resolved view of that session.
|
|
930
1029
|
*
|
|
@@ -956,11 +1055,11 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
956
1055
|
* Wrapped in one transaction: an imported shard is a month of somebody's history, and a partial
|
|
957
1056
|
* month would leave the two devices disagreeing about which days exist.
|
|
958
1057
|
*/
|
|
959
|
-
intakeRows(rows) {
|
|
1058
|
+
intakeRows(rows, options) {
|
|
960
1059
|
if (!writable) return 0
|
|
961
1060
|
try {
|
|
962
1061
|
db.exec('BEGIN IMMEDIATE')
|
|
963
|
-
const written = intakeRows(rows)
|
|
1062
|
+
const written = intakeRows(rows, options)
|
|
964
1063
|
db.exec('COMMIT')
|
|
965
1064
|
return written
|
|
966
1065
|
} catch (error) {
|
package/lib/client.js
CHANGED
|
@@ -2121,6 +2121,58 @@ window.__ModuleLoader__.load({
|
|
|
2121
2121
|
* server itself stores, and never written down in plaintext anywhere. That is also why the field
|
|
2122
2122
|
* comes back empty: the page can say a credential exists, never what it is.
|
|
2123
2123
|
*/
|
|
2124
|
+
/**
|
|
2125
|
+
* What the sync form should show, given what was saved and what is already in the fields.
|
|
2126
|
+
*
|
|
2127
|
+
* Pure and exported for the test suite: the browser half cannot be mounted in this project's tests
|
|
2128
|
+
* (`react-dom/test-utils` sits behind a package export here), so the one piece of behaviour that
|
|
2129
|
+
* matters is kept where it can be tested directly instead of only inspected by eye.
|
|
2130
|
+
*
|
|
2131
|
+
* The rule is **fill, never overwrite**. The status arrives after the first render, when the fields are
|
|
2132
|
+
* empty because `/sync` had not been read yet — that is the bug this exists for. A field that already
|
|
2133
|
+
* holds something was either typed by the person or filled from an earlier answer, and replacing either
|
|
2134
|
+
* silently is worse than leaving a value alone.
|
|
2135
|
+
*
|
|
2136
|
+
* @param {object} status - a `/usage-badge/sync` response, or anything shaped like one.
|
|
2137
|
+
* @param {{url?:string, username?:string, realm?:string, deviceId?:string, windowYears?:string|number}} current
|
|
2138
|
+
* @returns {object} only the fields that need to change
|
|
2139
|
+
*/
|
|
2140
|
+
function fillSyncForm(status, current = {}) {
|
|
2141
|
+
const config = status?.config ?? {}
|
|
2142
|
+
const patch = {}
|
|
2143
|
+
const fill = (name, saved) => {
|
|
2144
|
+
const had = current[name]
|
|
2145
|
+
if (saved === undefined || saved === null || saved === '') return
|
|
2146
|
+
if (had !== undefined && had !== null && String(had) !== '') return
|
|
2147
|
+
patch[name] = String(saved)
|
|
2148
|
+
}
|
|
2149
|
+
fill('url', config.url)
|
|
2150
|
+
fill('username', config.username)
|
|
2151
|
+
fill('realm', config.realm)
|
|
2152
|
+
fill('deviceId', config.deviceId)
|
|
2153
|
+
if (config.windowYears !== undefined && config.windowYears !== null) fill('windowYears', String(config.windowYears))
|
|
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".
|
|
2158
|
+
if (config.allowInsecureTls) patch.allowInsecureTls = true
|
|
2159
|
+
if (config.onStart !== false) patch.onStart = true
|
|
2160
|
+
if (config.onExit !== false) patch.onExit = true
|
|
2161
|
+
return patch
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* Whether a `/usage-badge/sync` reply is a status the panel can use.
|
|
2166
|
+
*
|
|
2167
|
+
* The route answers `{error}` with HTTP 200 when it cannot produce one, so "the request succeeded" is
|
|
2168
|
+
* not the same as "this is a status". Adopting an error object would leave `config` undefined and blank
|
|
2169
|
+
* every field — the same complaint this tab already had, arriving by a second road. Pure, and exported
|
|
2170
|
+
* for the same reason as `fillSyncForm`: there is no way to mount a component in this project's tests.
|
|
2171
|
+
*/
|
|
2172
|
+
function isSyncStatus(body) {
|
|
2173
|
+
return Boolean(body) && typeof body === 'object' && typeof body.config === 'object' && body.config !== null
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2124
2176
|
function SyncPanel({ initialSync = null }) {
|
|
2125
2177
|
const [status, setStatus] = React.useState(initialSync ?? cachedSync)
|
|
2126
2178
|
const [url, setUrl] = React.useState(initialSync?.config?.url ?? '')
|
|
@@ -2128,8 +2180,14 @@ window.__ModuleLoader__.load({
|
|
|
2128
2180
|
const [password, setPassword] = React.useState('')
|
|
2129
2181
|
const [realm, setRealm] = React.useState(initialSync?.config?.realm ?? '')
|
|
2130
2182
|
const [deviceId, setDeviceId] = React.useState(initialSync?.config?.deviceId ?? '')
|
|
2131
|
-
|
|
2183
|
+
// Seeded empty rather than with the default: the form is only rendered once a status exists, and by
|
|
2184
|
+
// then `fillSyncForm` puts the saved value in. Seeding `5` would make the field non-empty, and
|
|
2185
|
+
// "fill only what is empty" would then leave a saved 0 or 30 looking like the default forever.
|
|
2186
|
+
const [windowYears, setWindowYears] = React.useState(String(initialSync?.config?.windowYears ?? ''))
|
|
2132
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)
|
|
2133
2191
|
const [state, setState] = React.useState('idle')
|
|
2134
2192
|
const [note, setNote] = React.useState('')
|
|
2135
2193
|
const [devices, setDevices] = React.useState(null)
|
|
@@ -2137,12 +2195,28 @@ window.__ModuleLoader__.load({
|
|
|
2137
2195
|
// The failure verbatim and shown as-is: a sliced string is what once turned a connection error into
|
|
2138
2196
|
// a blank line beside the word 失败. Kept beside the state so the state stays a name.
|
|
2139
2197
|
const [failNote, setFailNote] = React.useState('')
|
|
2198
|
+
// Whether the person has typed into the form since it appeared. The status arrives **after** the
|
|
2199
|
+
// first render (there is nothing cached on a fresh page load), so the fields have to be filled in
|
|
2200
|
+
// when it does — but only while they still hold what this component put there.
|
|
2201
|
+
const formDirty = React.useRef(false)
|
|
2202
|
+
const filledFrom = React.useRef(null)
|
|
2140
2203
|
|
|
2141
2204
|
const load = React.useCallback(async () => {
|
|
2142
2205
|
try {
|
|
2143
2206
|
const response = await fetch('/usage-badge/sync', { cache: 'no-store' })
|
|
2144
2207
|
if (!response.ok) return null
|
|
2145
|
-
|
|
2208
|
+
const body = await response.json()
|
|
2209
|
+
// A reply without a `config` is not a status: the route answers `{error}` with a 200 when it cannot
|
|
2210
|
+
// produce one. Adopting that would blank every field, so the last good answer is kept and the
|
|
2211
|
+
// failure is shown instead.
|
|
2212
|
+
if (!isSyncStatus(body)) {
|
|
2213
|
+
if (body?.error) setFailNote(String(body.error))
|
|
2214
|
+
// `failed`, not just a note: the note is only rendered for that state, so a status the host
|
|
2215
|
+
// could not produce used to leave the tab saying "正在读取同步配置…" forever.
|
|
2216
|
+
setState('failed')
|
|
2217
|
+
return null
|
|
2218
|
+
}
|
|
2219
|
+
cachedSync = body
|
|
2146
2220
|
setStatus(cachedSync)
|
|
2147
2221
|
return cachedSync
|
|
2148
2222
|
} catch {
|
|
@@ -2154,6 +2228,39 @@ window.__ModuleLoader__.load({
|
|
|
2154
2228
|
void load()
|
|
2155
2229
|
}, [load])
|
|
2156
2230
|
|
|
2231
|
+
/**
|
|
2232
|
+
* Fill the form from the saved configuration.
|
|
2233
|
+
*
|
|
2234
|
+
* This is the whole reason the tab used to look empty after a restart: the fields were seeded from
|
|
2235
|
+
* `initialSync` once, and `initialSync` is `null` on a page that has not read `/sync` yet — so
|
|
2236
|
+
* `load()` updated the status *below* the form and never the form itself. The configuration was
|
|
2237
|
+
* always there; it just was not shown.
|
|
2238
|
+
*
|
|
2239
|
+
* Skipped once the person has typed: silently replacing an edit in progress is worse than leaving a
|
|
2240
|
+
* field empty, and an empty field can be typed into again.
|
|
2241
|
+
*/
|
|
2242
|
+
React.useEffect(() => {
|
|
2243
|
+
if (!status || formDirty.current) return
|
|
2244
|
+
const saved = status.config ?? {}
|
|
2245
|
+
// The checkbox belongs in the signature too: it is the one field whose saved value can change from
|
|
2246
|
+
// `true` to `false`, and without this the signature would look unchanged and the box would keep
|
|
2247
|
+
// showing the old answer.
|
|
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}`
|
|
2249
|
+
if (filledFrom.current === signature) return
|
|
2250
|
+
filledFrom.current = signature
|
|
2251
|
+
const patch = fillSyncForm(status, { url, username, realm, deviceId, windowYears })
|
|
2252
|
+
if (patch.url !== undefined) setUrl(patch.url)
|
|
2253
|
+
if (patch.username !== undefined) setUsername(patch.username)
|
|
2254
|
+
if (patch.realm !== undefined) setRealm(patch.realm)
|
|
2255
|
+
if (patch.deviceId !== undefined) setDeviceId(patch.deviceId)
|
|
2256
|
+
if (patch.windowYears !== undefined) setWindowYears(patch.windowYears)
|
|
2257
|
+
// Two-way, unlike the text fields: a checkbox has no empty state to protect, and the saved answer is
|
|
2258
|
+
// the answer. `formDirty` still stops this from fighting someone who has just clicked one.
|
|
2259
|
+
setAllowInsecureTls(Boolean(saved.allowInsecureTls))
|
|
2260
|
+
setOnStart(saved.onStart !== false)
|
|
2261
|
+
setOnExit(saved.onExit !== false)
|
|
2262
|
+
}, [status, url, username, realm, deviceId, windowYears])
|
|
2263
|
+
|
|
2157
2264
|
// While a run is in flight the panel shows what the host is doing, so it asks again. The condition
|
|
2158
2265
|
// includes the local `state` on purpose: the host's `running` flag only becomes true once *its* run
|
|
2159
2266
|
// has started, and a 立即同步 press would otherwise sit through the whole sync without a single
|
|
@@ -2171,7 +2278,7 @@ window.__ModuleLoader__.load({
|
|
|
2171
2278
|
setFailNote('')
|
|
2172
2279
|
try {
|
|
2173
2280
|
const sentPassword = password !== ''
|
|
2174
|
-
const payload = { url, username, realm, deviceId, windowYears: Number(windowYears), allowInsecureTls }
|
|
2281
|
+
const payload = { url, username, realm, deviceId, windowYears: Number(windowYears), allowInsecureTls, onStart, onExit }
|
|
2175
2282
|
if (sentPassword) payload.password = password
|
|
2176
2283
|
const response = await fetch('/usage-badge/sync', {
|
|
2177
2284
|
method: 'POST',
|
|
@@ -2184,6 +2291,9 @@ window.__ModuleLoader__.load({
|
|
|
2184
2291
|
setStatus(body)
|
|
2185
2292
|
setPassword('')
|
|
2186
2293
|
if (body.config?.realm) setRealm(body.config.realm)
|
|
2294
|
+
// The host normalises the address (a collection needs its trailing slash); show what it stored
|
|
2295
|
+
// rather than what was typed, so the field cannot disagree with the configuration.
|
|
2296
|
+
if (body.config?.url) setUrl(body.config.url)
|
|
2187
2297
|
setState('saved')
|
|
2188
2298
|
// Saving only proves the values were accepted, not that they work — so the note says what the
|
|
2189
2299
|
// next press is for instead of implying this one settled it.
|
|
@@ -2205,11 +2315,29 @@ window.__ModuleLoader__.load({
|
|
|
2205
2315
|
try {
|
|
2206
2316
|
const response = await fetch('/usage-badge/sync/test', { method: 'POST' })
|
|
2207
2317
|
const body = await response.json()
|
|
2208
|
-
if (!body.ok)
|
|
2318
|
+
if (!body.ok) {
|
|
2319
|
+
setDevices(null)
|
|
2320
|
+
setState('failed')
|
|
2321
|
+
// The server answers a failed check with the realm it announced. If that is not the realm the
|
|
2322
|
+
// saved password was hashed against, nothing can work until it is re-hashed — and this is the
|
|
2323
|
+
// only moment the panel can learn it. (`WebDAV` is the default, and the panel offers no realm
|
|
2324
|
+
// field, so a server using anything else would otherwise be unconfigurable: the saved credential
|
|
2325
|
+
// would simply never match, with no way to say why.)
|
|
2326
|
+
const announced = body.realm ? String(body.realm) : ''
|
|
2327
|
+
if (announced && announced !== realm) {
|
|
2328
|
+
setRealm(announced)
|
|
2329
|
+
setFailNote(`${body.error ?? '连接失败'} —— 服务器宣告的 realm 是「${announced}」,与此前用的「${realm || '(空)'}」不同,所以密码是按错的 realm 算的。请重新输入一次密码再保存,这次会用正确的 realm 计算。`)
|
|
2330
|
+
return
|
|
2331
|
+
}
|
|
2332
|
+
setFailNote(String(body.error ?? '连接失败'))
|
|
2333
|
+
return
|
|
2334
|
+
}
|
|
2209
2335
|
setDevices(body.devices ?? [])
|
|
2210
2336
|
setState('tested')
|
|
2211
2337
|
setNote(`连上了:realm ${body.realm ?? '未知'},DAV ${body.dav || '未声明'}${body.devices?.length ? `,已有 ${body.devices.length} 台设备` : ''}${body.warning ? ` ⚠ ${body.warning}` : ''}`)
|
|
2212
|
-
|
|
2338
|
+
// The realm the server actually announced is the authoritative one; keeping the guessed default
|
|
2339
|
+
// would leave the panel disagreeing with the credential that just worked.
|
|
2340
|
+
if (body.realm) setRealm(String(body.realm))
|
|
2213
2341
|
} catch (error) {
|
|
2214
2342
|
setDevices(null)
|
|
2215
2343
|
setState('failed')
|
|
@@ -2312,11 +2440,11 @@ window.__ModuleLoader__.load({
|
|
|
2312
2440
|
style: { flex: '1 1 260px' },
|
|
2313
2441
|
value: url,
|
|
2314
2442
|
placeholder: 'http://nas:19347/',
|
|
2315
|
-
onChange: (event) => setUrl(event.target.value),
|
|
2443
|
+
onChange: (event) => { formDirty.current = true; setUrl(event.target.value) },
|
|
2316
2444
|
}),
|
|
2317
2445
|
'必须以 / 结尾,且指到集合(目录)',
|
|
2318
2446
|
),
|
|
2319
|
-
row('用户名', h('input', { className: 'dub-op', value: username, onChange: (event) => setUsername(event.target.value) })),
|
|
2447
|
+
row('用户名', h('input', { className: 'dub-op', value: username, onChange: (event) => { formDirty.current = true; setUsername(event.target.value) } })),
|
|
2320
2448
|
row(
|
|
2321
2449
|
'密码',
|
|
2322
2450
|
h('input', {
|
|
@@ -2324,7 +2452,7 @@ window.__ModuleLoader__.load({
|
|
|
2324
2452
|
type: 'password',
|
|
2325
2453
|
value: password,
|
|
2326
2454
|
placeholder: config.hasCredential ? `已配置(${config.credentialPrefix}…),留空则不改` : '只用于当场算出 HA1',
|
|
2327
|
-
onChange: (event) => setPassword(event.target.value),
|
|
2455
|
+
onChange: (event) => { formDirty.current = true; setPassword(event.target.value) },
|
|
2328
2456
|
}),
|
|
2329
2457
|
'明文不会落盘:宿主半立刻算成服务端同款的 HA1',
|
|
2330
2458
|
),
|
|
@@ -2335,7 +2463,7 @@ window.__ModuleLoader__.load({
|
|
|
2335
2463
|
list: 'dub-sync-devices',
|
|
2336
2464
|
value: deviceId,
|
|
2337
2465
|
placeholder: '重装后填回原来的设备名即可接着同一台记',
|
|
2338
|
-
onChange: (event) => setDeviceId(event.target.value),
|
|
2466
|
+
onChange: (event) => { formDirty.current = true; setDeviceId(event.target.value) },
|
|
2339
2467
|
}),
|
|
2340
2468
|
devices ? `远端已有:${devices.join('、') || '(还没有)'}` : '点「测试连接」可以列出远端已有的设备',
|
|
2341
2469
|
),
|
|
@@ -2346,7 +2474,7 @@ window.__ModuleLoader__.load({
|
|
|
2346
2474
|
className: 'dub-op',
|
|
2347
2475
|
style: { width: '72px' },
|
|
2348
2476
|
value: windowYears,
|
|
2349
|
-
onChange: (event) => setWindowYears(event.target.value),
|
|
2477
|
+
onChange: (event) => { formDirty.current = true; setWindowYears(event.target.value) },
|
|
2350
2478
|
}),
|
|
2351
2479
|
'年内(0 = 全都要);只影响拉取,不影响保留',
|
|
2352
2480
|
),
|
|
@@ -2355,10 +2483,28 @@ window.__ModuleLoader__.load({
|
|
|
2355
2483
|
h('input', {
|
|
2356
2484
|
type: 'checkbox',
|
|
2357
2485
|
checked: allowInsecureTls,
|
|
2358
|
-
onChange: (event) => setAllowInsecureTls(event.target.checked),
|
|
2486
|
+
onChange: (event) => { formDirty.current = true; setAllowInsecureTls(event.target.checked) },
|
|
2359
2487
|
}),
|
|
2360
2488
|
'HTTPS 但证书不受信任时才勾',
|
|
2361
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
|
+
),
|
|
2362
2508
|
h(
|
|
2363
2509
|
'div',
|
|
2364
2510
|
{ className: 'dub-form-actions' },
|
|
@@ -2655,6 +2801,8 @@ window.__ModuleLoader__.load({
|
|
|
2655
2801
|
)
|
|
2656
2802
|
}
|
|
2657
2803
|
|
|
2658
|
-
|
|
2804
|
+
// `fillSyncForm` and `isSyncStatus` ride along so the test suite can exercise them: this bundle is
|
|
2805
|
+
// loaded through the DSH module loader, and there is no way to mount a component in a plain Node test.
|
|
2806
|
+
return { apply, inject, fillSyncForm, isSyncStatus }
|
|
2659
2807
|
},
|
|
2660
2808
|
})
|