dsh-usage-badge 0.1.4 → 0.1.6

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/lib/index.js CHANGED
@@ -15,11 +15,15 @@
15
15
  * @module dsh-usage-badge
16
16
  */
17
17
 
18
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
18
+ import { createHash, randomBytes } from 'node:crypto'
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
20
+ import { hostname } from 'node:os'
19
21
  import { join } from 'node:path'
20
22
 
21
23
  import { createCacheStore } from './cache-store.js'
22
24
  import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
25
+ import { SyncEngine, acquireLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
26
+ import { normalizeWebdavUrl } from './webdav.js'
23
27
  import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
24
28
  import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
25
29
  import {
@@ -53,7 +57,7 @@ const ROUTE_PREFIX = '/usage-badge'
53
57
  * (an unknown patch key is accepted with a 200 and ignored). The panel compares this number
54
58
  * and says so instead. `test/client.render.mjs` asserts the two halves agree.
55
59
  */
56
- export const HOST_API_VERSION = 6
60
+ export const HOST_API_VERSION = 7
57
61
 
58
62
  /**
59
63
  * Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
@@ -75,6 +79,48 @@ const DAYS_KEPT = 400
75
79
  /** A snapshot younger than this is reused instead of rebuilt on every poll. */
76
80
  const SNAPSHOT_TTL_MS = 2000
77
81
 
82
+ /**
83
+ * How long after an edit the price table is published.
84
+ *
85
+ * Long enough that editing three rows in a row is one publish rather than three, short enough that the
86
+ * person is still on the page when it happens. Only ever reached when sync is on and configured.
87
+ */
88
+ export const PRICING_PUSH_DELAY_MS = 3000
89
+
90
+ /** How long after startup the first automatic sync runs, when sync is enabled. */
91
+ export const STARTUP_SYNC_DELAY_MS = 8000
92
+
93
+ /**
94
+ * How long the panel waits for a status read before it is told the answer is not ready.
95
+ *
96
+ * The status read may ask the server what devices it holds. That happens in the background, but this
97
+ * bound is what keeps a slow or black-holed server from making the whole dialog look frozen.
98
+ */
99
+ const STATUS_TIMEOUT_MS = 5000
100
+
101
+ /** Resolve `promise`, or reject once `ms` have passed, so a route can always answer. */
102
+ function withTimeout(promise, ms, what) {
103
+ return Promise.race([
104
+ promise,
105
+ new Promise((_, reject) => {
106
+ const timer = setTimeout(() => reject(new Error(`${what} took longer than ${Math.round(ms / 1000)}s`)), ms)
107
+ timer.unref?.()
108
+ }),
109
+ ])
110
+ }
111
+
112
+ /**
113
+ * The delay to actually use, overridable so a test does not have to wait out a real one.
114
+ *
115
+ * Reading it per call rather than at import time is what lets a test set the variable, mount the plugin,
116
+ * and still get the short delay — the host half is imported once per scenario with a cache-busting
117
+ * query, but a constant captured at module load would freeze whatever the first reader saw.
118
+ */
119
+ function delayFor(name, fallback) {
120
+ const configured = Number(process.env[name])
121
+ return Number.isFinite(configured) && configured >= 0 ? configured : fallback
122
+ }
123
+
78
124
  /**
79
125
  * How long a fetched price list is reused. Opening the dialog asks for a fresh
80
126
  * copy every time, and this window only coalesces the requests that arrive within
@@ -409,6 +455,23 @@ function readPricing(paths) {
409
455
  }
410
456
 
411
457
  /** Read a request body, bounded. An oversized one is a 413, not a server error. */
458
+ /**
459
+ * Read a JSON request body, reporting a malformed one as the caller's mistake.
460
+ *
461
+ * A bare `JSON.parse` throws a `SyntaxError` with no status, which the route wrapper can only answer with a
462
+ * 500 — and a 500 for a body the client got wrong sends whoever is debugging it looking at the host.
463
+ */
464
+ async function readJsonBody(req) {
465
+ const text = (await readBody(req)) || '{}'
466
+ try {
467
+ return JSON.parse(text)
468
+ } catch (error) {
469
+ const bad = new Error(`the request body is not JSON: ${error?.message ?? error}`)
470
+ bad.statusCode = 400
471
+ throw bad
472
+ }
473
+ }
474
+
412
475
  function readBody(req) {
413
476
  return new Promise((resolvePromise, rejectPromise) => {
414
477
  const chunks = []
@@ -517,6 +580,57 @@ export function apply(ctx) {
517
580
  const aggregator = createAggregator({ sessionsRoot, store, log })
518
581
  aggregator.loadCache()
519
582
 
583
+ /**
584
+ * The sync system, created **on first use** rather than when the plugin mounts.
585
+ *
586
+ * Creating it eagerly would write `host.lock` and `sync.json` into the data directory of every
587
+ * install, including the many that never turn sync on — and would quietly change the directory a
588
+ * user is looking at. Mounting a plugin should not do that; asking it to sync should.
589
+ *
590
+ * The lock decides who may **write**: a second DSH instance pointed at the same home cannot share a
591
+ * `deviceId`, because two processes would each believe the same remote shard is theirs to overwrite.
592
+ * The instance that loses is not broken and does not refuse to start — it reports why and declines to
593
+ * sync, which is the same answer the design gives to a read-only cache.
594
+ */
595
+ let system = null
596
+ function syncSystem() {
597
+ if (system) return system
598
+ const document = readSyncDocument(dataDir, log)
599
+ const instanceId = document.instanceId || `${hostname()}-${process.pid}`
600
+ const lock = acquireLock(dataDir, { instanceId, log })
601
+ if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
602
+ const engine = new SyncEngine({
603
+ dataDir,
604
+ store: lock.acquired ? store : null,
605
+ log,
606
+ config: document,
607
+ state: document.state,
608
+ instanceId,
609
+ })
610
+ if (!document.deviceId) engine.save({ config: { deviceId: `${hostname()}-${randomBytes(3).toString('hex')}` } })
611
+ // **This** is where the lock becomes ours, not inside `run()`: the host takes it once, when the system
612
+ // comes into being, and hands the already-held lock to every run. Without this the engine reported
613
+ // `ownsLock: false`, so `release()` declined to remove a lock this very process was holding — and the
614
+ // next mount of the same directory was refused by this plugin's own lock, naming a live pid.
615
+ engine.ownsLock = lock.acquired
616
+ system = {
617
+ engine,
618
+ lock,
619
+ instanceId,
620
+ running: null,
621
+ pushing: null,
622
+ // The last run is **persisted** state, so a restart must show it rather than "从未": the panel's
623
+ // first question is "when did this last work", and the answer is in `sync.json` — the engine wrote
624
+ // it there precisely so that it would outlive the process.
625
+ last: {
626
+ result: document.state?.lastResult || (lock.acquired ? 'never' : 'locked'),
627
+ message: document.state?.message ?? (lock.acquired ? '' : lock.reason),
628
+ at: Number(document.state?.lastSyncAt) || 0,
629
+ },
630
+ }
631
+ return system
632
+ }
633
+
520
634
  const { path: pricingSourcePath } = readRawPricing(pricingPaths)
521
635
  if (pricingSourcePath === legacyPricingPath) {
522
636
  log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
@@ -543,6 +657,196 @@ export function apply(ctx) {
543
657
  return inFlight
544
658
  }
545
659
 
660
+ /**
661
+ * The sync state the panel reads: configuration, lock ownership, and the last run's outcome.
662
+ *
663
+ * The configuration is **rebuilt field by field** rather than spread. A spread would forward whatever a
664
+ * hand-edited `sync.json` happens to contain — and an older build of this plugin wrote exactly such a
665
+ * field, `password`, in plaintext. A blacklist that removes `ha1` and forwards everything else is one
666
+ * forgotten key away from putting a credential in an HTTP response.
667
+ */
668
+ function safeSyncConfig(config = {}) {
669
+ const ha1 = String(config.ha1 ?? '')
670
+ return {
671
+ enabled: Boolean(config.enabled),
672
+ url: String(config.url ?? ''),
673
+ username: String(config.username ?? ''),
674
+ realm: String(config.realm ?? ''),
675
+ allowInsecureTls: Boolean(config.allowInsecureTls),
676
+ deviceId: String(config.deviceId ?? ''),
677
+ deviceLabel: String(config.deviceLabel ?? ''),
678
+ windowYears: Number(config.windowYears) || 0,
679
+ instanceId: String(config.instanceId ?? ''),
680
+ hasCredential: Boolean(ha1),
681
+ // Only a real HA1 counts. `password` and `passwordHash` are fields an older build could leave behind,
682
+ // and `connect()` cannot use either — claiming a credential exists would enable 立即同步 and then
683
+ // fail with "no WebDAV credential is configured" the moment it was pressed.
684
+ credentialPrefix: ha1.length >= 16 ? `${ha1.slice(0, 6)}…` : '',
685
+ }
686
+ }
687
+
688
+ function conflictFiles() {
689
+ try {
690
+ return readdirSync(dataDir).filter((name) => name.startsWith('.conflict-'))
691
+ } catch {
692
+ return []
693
+ }
694
+ }
695
+
696
+ /** The devices the **server** holds, remembered between polls. Never a network call. */
697
+ let knownRemote = null
698
+ /** How long that answer is reused: the panel polls every 2s while a run is in flight. */
699
+ const REMOTE_CACHE_MS = 30_000
700
+
701
+ async function refreshRemoteDevices() {
702
+ if (!system?.engine?.store || !system.engine.config.enabled) return
703
+ if (system.syncAbort) return
704
+ if (system.remoteInFlight) return
705
+ system.remoteInFlight = true
706
+ try {
707
+ const client = system.engine.connect()
708
+ knownRemote = { devices: await system.engine.discover(client), at: Date.now() }
709
+ } catch (error) {
710
+ // A site the panel can show as "unknown" is better than an exception on every poll.
711
+ knownRemote = { devices: null, at: Date.now(), error: String(error?.message ?? error) }
712
+ } finally {
713
+ system.remoteInFlight = false
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Every piece of work that talks to the server goes through one queue.
719
+ *
720
+ * The owner lock keeps two **processes** apart; inside one process it cannot, because the host holds it for
721
+ * its whole life. Without this, 立即同步 and the automatic price push run as two independent chains: a run's
722
+ * `PROPFIND` and a push's `GET pricing.json` would already be interleaving on the wire (measured), and both
723
+ * mutate the same `engine.state` and rewrite `sync.json`, so whichever finishes last decides what the panel
724
+ * says about the other.
725
+ */
726
+ function enqueue(instance, work) {
727
+ const next = (instance.chain ?? Promise.resolve()).then(work, work)
728
+ // The chain must survive a rejection — neither breaking on it nor leaving it unhandled.
729
+ instance.chain = next.then(
730
+ () => {},
731
+ () => {},
732
+ )
733
+ return next
734
+ }
735
+
736
+ 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.
739
+ if (!system) {
740
+ const document = readSyncDocument(dataDir, log)
741
+ return {
742
+ config: safeSyncConfig({ ...document, hasCredential: Boolean(document.ha1 || document.passwordHash || document.password) }),
743
+ writable: true,
744
+ lock: null,
745
+ running: false,
746
+ last: {
747
+ result: document.state?.lastResult || 'never',
748
+ message: document.state?.message ?? '',
749
+ at: Number(document.state?.lastSyncAt) || 0,
750
+ },
751
+ collision: document.state?.collision ?? null,
752
+ remoteDevices: {},
753
+ // `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
754
+ // are no other devices".
755
+ remoteKnown: false,
756
+ remoteError: null,
757
+ published: {},
758
+ conflictFiles: conflictFiles(),
759
+ paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
760
+ }
761
+ }
762
+ const { engine, lock, running, last } = system
763
+ const pulled = {}
764
+ for (const context of Object.keys(engine.state.held ?? {})) {
765
+ const device = context.split('/')[0]
766
+ pulled[device] = (pulled[device] ?? 0) + 1
767
+ }
768
+ // The count of shards this device has actually read, and the names the server holds, are two
769
+ // different questions. The panel shows both: "已同步到的月份" from `held`, and "服务器上的设备" from
770
+ // this list, so a device that published but was never pulled from still shows up. Refreshed at most
771
+ // every `REMOTE_CACHE_MS` — the panel polls during a run, and a `PROPFIND` per poll would be a lot of
772
+ // requests for a list that changes when someone else syncs.
773
+ if (!knownRemote || Date.now() - (knownRemote.at ?? 0) > REMOTE_CACHE_MS) void refreshRemoteDevices()
774
+ return {
775
+ config: safeSyncConfig(engine.config),
776
+ writable: Boolean(engine.store),
777
+ lock: lock.acquired ? null : { reason: lock.reason, owner: lock.owner ?? null, pid: process.pid },
778
+ running: Boolean(running),
779
+ last,
780
+ collision: engine.state.collision ?? null,
781
+ remoteDevices: pulled,
782
+ remoteKnown: Boolean(knownRemote?.devices),
783
+ remoteError: knownRemote?.error ?? null,
784
+ remoteDevicesOnServer: knownRemote?.devices ?? null,
785
+ published: engine.state.published ?? {},
786
+ conflictFiles: conflictFiles(),
787
+ paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Run one sync, at most one at a time.
793
+ *
794
+ * The host owns the lock for as long as it lives, so an automatic pricing push that lands mid-run
795
+ * waits on the same chain rather than racing it — two writers on one data directory is the case the
796
+ * lock exists for, and a lock cannot serialise work inside a single process.
797
+ */
798
+ function runSync() {
799
+ const instance = syncSystem()
800
+ if (!instance.running) {
801
+ instance.running = enqueue(instance, async () => {
802
+ if (!instance.engine.store) {
803
+ // The cache is read-only here (another instance holds the lock, a permission problem, a newer
804
+ // schema). The sync would publish a history this process cannot even fold, so it declines
805
+ // rather than doing half of it.
806
+ return { result: 'locked', message: instance.lock.reason ?? 'the cache is read-only here', at: 0 }
807
+ }
808
+ try {
809
+ await refreshOnce()
810
+ } catch (error) {
811
+ // Refreshing is what makes the store worth publishing; without it there is nothing to send. A
812
+ // rejection here used to escape the route and the startup timer as an unhandled rejection.
813
+ return { result: 'failed', message: `could not fold the logs: ${error?.message ?? error}`, at: 0 }
814
+ }
815
+ // The system already owns the lock from the moment it was created; taking it again in the same
816
+ // process would see this process's own lock file and refuse. It is re-acquired only when the
817
+ // system was created without one (a second instance that lost the race).
818
+ const attempt = instance.lock?.acquired
819
+ ? instance.lock
820
+ : acquireLock(dataDir, { instanceId: instance.instanceId, log })
821
+ if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
822
+ try {
823
+ const result = await instance.engine.run({ lock: attempt })
824
+ // A shard may have carried a newer price table; the panel's next poll should see it.
825
+ pricing = readPricing(pricingPaths)
826
+ rules = resolveHolidayRules(pricing.holidays)
827
+ // The engine's own record of when this happened, not "now": `locked` and `skipped` deliberately
828
+ // leave `lastSyncAt` alone, and showing a fresh timestamp for them would claim a sync that did
829
+ // not happen.
830
+ return { ...result, at: Number(instance.engine.state.lastSyncAt) || 0 }
831
+ } catch (error) {
832
+ // `SyncEngine.run` catches its own failures; this is the belt to its braces, so a bug there
833
+ // cannot take the host half down.
834
+ log(`sync failed unexpectedly: ${error?.message ?? error}`)
835
+ return { result: 'failed', message: String(error?.message ?? error), at: 0 }
836
+ }
837
+ // Nothing to release: the lock belongs to the system and lives as long as this process does.
838
+ })
839
+ .then((result) => {
840
+ instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
841
+ return result
842
+ })
843
+ .finally(() => {
844
+ instance.running = null
845
+ })
846
+ }
847
+ return instance.running
848
+ }
849
+
546
850
  async function getSnapshot() {
547
851
  const status = await refreshOnce()
548
852
  // Nothing changed and the previous payload is still fresh: reuse it rather
@@ -577,8 +881,73 @@ export function apply(ctx) {
577
881
  pricing = normalizePricing(next)
578
882
  rules = resolveHolidayRules(pricing.holidays)
579
883
  snapshot = null // force a rebuild on the next read
884
+ // The price table is the one hand-written thing this plugin owns, so an edit is worth publishing
885
+ // while the person is still looking at the page. It is fire-and-forget: a sync that cannot reach
886
+ // the server must never make a local edit fail, and the state it leaves behind is what the panel
887
+ // reports.
888
+ schedulePricingPush()
889
+ }
890
+
891
+ /**
892
+ * Publish just the price table, a moment after the last edit.
893
+ *
894
+ * Debounced because editing a row and then the one below it is one intention, not two publishes, and
895
+ * because the fetch it needs would otherwise happen on every keystroke-sized save. It only ever runs
896
+ * when sync was already configured and enabled: a local edit is not a reason to create `sync.json`.
897
+ */
898
+ let pricingPushTimer = null
899
+ function schedulePricingPush() {
900
+ const configured = system ? system.engine.config : readSyncDocument(dataDir, log)
901
+ if (!configured.enabled || !configured.url || !configured.ha1) return
902
+ if (pricingPushTimer) clearTimeout(pricingPushTimer)
903
+ pricingPushTimer = setTimeout(() => {
904
+ pricingPushTimer = null
905
+ const instance = syncSystem()
906
+ if (!instance.lock?.acquired || !instance.engine.store) return
907
+ // Onto the **same** queue as a full run, so an edit landing mid-sync waits for it instead of
908
+ // interleaving its PUTs and its `sync.json` write with the run's.
909
+ instance.pushing = enqueue(instance, () => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
910
+ .then((result) => {
911
+ log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
912
+ instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
913
+ return result
914
+ })
915
+ .catch((error) => {
916
+ log(`pricing push failed: ${error?.message ?? error}`)
917
+ instance.last = { result: 'failed', message: `prices: ${error?.message ?? error}`, at: instance.last?.at ?? 0 }
918
+ })
919
+ }, delayFor('DSH_USAGE_BADGE_PRICING_PUSH_MS', PRICING_PUSH_DELAY_MS))
920
+ pricingPushTimer.unref?.()
580
921
  }
581
922
 
923
+ /**
924
+ * The first automatic sync, a little after startup.
925
+ *
926
+ * Deliberately conditional: it only runs when sync is **already configured and enabled**, so a home
927
+ * that never turned sync on stays untouched (no `sync.json`, no lock, no network). The delay exists so
928
+ * the cold fold and the first panel paint come first — a sync on the way up would compete with the
929
+ * work that makes the badge show anything at all.
930
+ *
931
+ * It is also skipped when the instance is not the writer: on a second DSH instance the lock belongs to
932
+ * the first, and the honest answer is to do nothing rather than take the lock away from it.
933
+ */
934
+ function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
935
+ const configured = readSyncDocument(dataDir, log)
936
+ if (!configured.enabled || !configured.url || !configured.ha1) return
937
+ const timer = setTimeout(() => {
938
+ startupSyncTimer = null
939
+ const instance = syncSystem()
940
+ if (!instance.lock?.acquired) {
941
+ log(`startup sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
942
+ return
943
+ }
944
+ void runSync().then((result) => log(`startup sync: ${result.result} — ${result.message}`))
945
+ }, delayMs)
946
+ timer.unref?.()
947
+ startupSyncTimer = timer
948
+ }
949
+ let startupSyncTimer = null
950
+
582
951
  async function warm() {
583
952
  try {
584
953
  const status = await refreshOnce()
@@ -688,7 +1057,7 @@ export function apply(ctx) {
688
1057
  sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
689
1058
  return
690
1059
  }
691
- const patch = JSON.parse((await readBody(req)) || '{}')
1060
+ const patch = await readJsonBody(req)
692
1061
  commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
693
1062
  log('pricing config updated')
694
1063
  sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
@@ -709,7 +1078,7 @@ export function apply(ctx) {
709
1078
  sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
710
1079
  return
711
1080
  }
712
- const body = JSON.parse((await readBody(req)) || '{}')
1081
+ const body = await readJsonBody(req)
713
1082
  // Apply the copy the user was looking at, reusing the recent fetch rather
714
1083
  // than pulling a possibly different page under them.
715
1084
  const parsed = await officialPricing(body.source ?? 'zh-cn')
@@ -736,6 +1105,131 @@ export function apply(ctx) {
736
1105
  return
737
1106
  }
738
1107
 
1108
+ // ── cloud sync ──────────────────────────────────────────────────────────
1109
+ //
1110
+ // The panel's third tab. The credential never travels back: `ha1` is reported as a short prefix
1111
+ // plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
1112
+ // leaving the host half.
1113
+ 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.
1117
+ if (!isLoopback(req)) {
1118
+ sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
1119
+ return
1120
+ }
1121
+ syncSystem()
1122
+ let body
1123
+ try {
1124
+ body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
1125
+ } catch (error) {
1126
+ sendJson(res, 200, { error: String(error?.message ?? error) })
1127
+ return
1128
+ }
1129
+ sendJson(res, 200, body)
1130
+ return
1131
+ }
1132
+
1133
+ if (route === '/sync' && (req.method === 'PUT' || req.method === 'POST')) {
1134
+ if (!isLoopback(req)) {
1135
+ sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1136
+ return
1137
+ }
1138
+ const instance = syncSystem()
1139
+ const patch = await readJsonBody(req)
1140
+ const config = {}
1141
+ for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
1142
+ if (patch[field] !== undefined) config[field] = patch[field]
1143
+ }
1144
+ if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
1145
+ if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
1146
+ try {
1147
+ config.deviceId = safeDeviceId(patch.deviceId)
1148
+ } catch (error) {
1149
+ sendJson(res, 400, { error: error.message })
1150
+ return
1151
+ }
1152
+ }
1153
+ // Either a plaintext password (hashed here, immediately, and never stored) or an HA1 someone
1154
+ // pasted in — the same value the server keeps in its password file.
1155
+ //
1156
+ // The realm is part of the digest input, so it has to be right before the password can be hashed.
1157
+ // The panel does not offer it as a field: a person has no reason to care, and the servers this is
1158
+ // built for use `WebDAV`. So it defaults here rather than being demanded — requiring it made
1159
+ // "fill in address/user/password and press 保存" fail with a sentence about a term the UI never
1160
+ // showed. A connection test replaces the guess with the realm the server actually announced.
1161
+ if (typeof patch.password === 'string' && patch.password !== '') {
1162
+ const username = String(patch.username ?? instance.engine.config.username ?? '')
1163
+ const realm = String(patch.realm ?? instance.engine.config.realm ?? '').trim() || 'WebDAV'
1164
+ if (!username) {
1165
+ sendJson(res, 400, { error: 'a username is needed to hash the password' })
1166
+ return
1167
+ }
1168
+ config.ha1 = createHash('md5').update(`${username}:${realm}:${patch.password}`).digest('hex')
1169
+ config.realm = realm
1170
+ } else if (typeof patch.ha1 === 'string' && /^[0-9a-f]{32}$/i.test(patch.ha1)) {
1171
+ config.ha1 = patch.ha1.toLowerCase()
1172
+ }
1173
+ if (typeof config.url === 'string' && config.url !== '') {
1174
+ // The **same** judgement the client makes, from the same function. A copy here is what let the
1175
+ // route accept a query string, embedded credentials and `ftp:` — addresses the client refuses —
1176
+ // so the panel stored one, showed it, and the sync could not run at all. Two copies of a rule
1177
+ // drift; one copy cannot.
1178
+ const judged = normalizeWebdavUrl(config.url)
1179
+ if (!judged.ok) {
1180
+ sendJson(res, 400, { error: judged.reason })
1181
+ return
1182
+ }
1183
+ // The trailing slash is **added** rather than demanded: someone typing `host:port` means the
1184
+ // collection at that address. The panel stores what this returns, so the field ends up showing
1185
+ // the address that will actually be used instead of the one that was typed.
1186
+ config.url = judged.url
1187
+ }
1188
+ instance.engine.save({ config })
1189
+ log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
1190
+ sendJson(res, 200, { ok: true, ...(await syncStatus()) })
1191
+ return
1192
+ }
1193
+
1194
+ // A connection check: it must not sync, and it must not write anything anywhere.
1195
+ if (route === '/sync/test' && req.method === 'POST') {
1196
+ if (!isLoopback(req)) {
1197
+ sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1198
+ return
1199
+ }
1200
+ // The client is kept across the catch so a **failure** can still report the realm the server
1201
+ // announced. That realm is the missing piece when someone's server does not use `WebDAV`: the
1202
+ // password was hashed against the default, so nothing can work until it is re-hashed against the
1203
+ // real one — and without this the panel had no way to learn it.
1204
+ let client = null
1205
+ try {
1206
+ client = syncSystem().engine.connect()
1207
+ const options = await client.options()
1208
+ const devices = await syncSystem().engine.discover(client)
1209
+ sendJson(res, 200, {
1210
+ ok: true,
1211
+ realm: options.realm,
1212
+ dav: options.dav,
1213
+ devices,
1214
+ insecure: Boolean(client.insecureWarning),
1215
+ warning: client.insecureWarning ?? null,
1216
+ })
1217
+ } catch (error) {
1218
+ sendJson(res, 200, { ok: false, error: String(error?.message ?? error), realm: client?.challenge?.realm ?? null })
1219
+ }
1220
+ return
1221
+ }
1222
+
1223
+ if (route === '/sync/run' && req.method === 'POST') {
1224
+ if (!isLoopback(req)) {
1225
+ sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1226
+ return
1227
+ }
1228
+ const result = await runSync()
1229
+ sendJson(res, 200, { ok: result.result === 'ok' || result.result === 'partial', ...result, ...(await syncStatus()) })
1230
+ return
1231
+ }
1232
+
739
1233
  sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
740
1234
  } catch (error) {
741
1235
  // A caller's mistake is reported as one; only genuinely unexpected failures are 500s.
@@ -751,9 +1245,45 @@ export function apply(ctx) {
751
1245
  )
752
1246
 
753
1247
  ctx.effect(() => {
754
- void warm()
755
- return () => aggregator.close()
756
- }, 'usage-badge: warm the fold and close the cache on unload')
1248
+ // The delay is read **here**, synchronously, rather than inside the warm-up callback: warming is
1249
+ // asynchronous, and a value read after it would be whatever the environment holds by then — which a
1250
+ // test that sets an override for exactly this mount would already have taken away.
1251
+ const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
1252
+ // Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
1253
+ // callback ran anyway, took the lock back, and synced through a store that had already been closed
1254
+ // (`statement has been finalized`), leaving a lock file behind for a host that was gone.
1255
+ let disposed = false
1256
+ void warm().then(() => {
1257
+ if (disposed) return
1258
+ scheduleStartupSync(startupDelay)
1259
+ // Fire and forget: this only fills in what the server holds, for the panel's next read.
1260
+ void refreshRemoteDevices()
1261
+ })
1262
+ return () => {
1263
+ disposed = true
1264
+ // Any pending automatic work is dropped on unload: a push that fires while the host is going away
1265
+ // would write state for a process that no longer exists.
1266
+ if (pricingPushTimer) clearTimeout(pricingPushTimer)
1267
+ if (startupSyncTimer) clearTimeout(startupSyncTimer)
1268
+ // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
1269
+ // re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
1270
+ // refused by this plugin's own lock, for a pid that is very much alive: its own.
1271
+ //
1272
+ // `engine.release()` alone is not enough: a run in flight was handed the host's lock object, and the
1273
+ // engine only releases one it acquired itself (`ownsLock`), so unloading mid-run would leave the file
1274
+ // behind. `releaseLock` checks the pid, so this can only ever remove our own.
1275
+ if (system) {
1276
+ system.syncAbort = true
1277
+ // Stops a run at its next step boundary. Requests already on the wire finish; the phases that would
1278
+ // touch the store do not start.
1279
+ system.engine.abort()
1280
+ system.engine.release()
1281
+ if (system.running) log('unloading while a sync is in flight; it stops at its next step boundary')
1282
+ }
1283
+ releaseLock(dataDir)
1284
+ aggregator.close()
1285
+ }
1286
+ }, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
757
1287
 
758
1288
  log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
759
1289
  }