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/lib/index.js CHANGED
@@ -22,7 +22,8 @@ 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
+ import { normalizeWebdavUrl } from './webdav.js'
26
27
  import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
27
28
  import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
28
29
  import {
@@ -89,6 +90,16 @@ export const PRICING_PUSH_DELAY_MS = 3000
89
90
  /** How long after startup the first automatic sync runs, when sync is enabled. */
90
91
  export const STARTUP_SYNC_DELAY_MS = 8000
91
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
+
92
103
  /**
93
104
  * How long the panel waits for a status read before it is told the answer is not ready.
94
105
  *
@@ -454,6 +465,23 @@ function readPricing(paths) {
454
465
  }
455
466
 
456
467
  /** Read a request body, bounded. An oversized one is a 413, not a server error. */
468
+ /**
469
+ * Read a JSON request body, reporting a malformed one as the caller's mistake.
470
+ *
471
+ * A bare `JSON.parse` throws a `SyntaxError` with no status, which the route wrapper can only answer with a
472
+ * 500 — and a 500 for a body the client got wrong sends whoever is debugging it looking at the host.
473
+ */
474
+ async function readJsonBody(req) {
475
+ const text = (await readBody(req)) || '{}'
476
+ try {
477
+ return JSON.parse(text)
478
+ } catch (error) {
479
+ const bad = new Error(`the request body is not JSON: ${error?.message ?? error}`)
480
+ bad.statusCode = 400
481
+ throw bad
482
+ }
483
+ }
484
+
457
485
  function readBody(req) {
458
486
  return new Promise((resolvePromise, rejectPromise) => {
459
487
  const chunks = []
@@ -648,6 +676,7 @@ export function apply(ctx) {
648
676
  * forgotten key away from putting a credential in an HTTP response.
649
677
  */
650
678
  function safeSyncConfig(config = {}) {
679
+ const ha1 = String(config.ha1 ?? '')
651
680
  return {
652
681
  enabled: Boolean(config.enabled),
653
682
  url: String(config.url ?? ''),
@@ -658,8 +687,15 @@ export function apply(ctx) {
658
687
  deviceLabel: String(config.deviceLabel ?? ''),
659
688
  windowYears: Number(config.windowYears) || 0,
660
689
  instanceId: String(config.instanceId ?? ''),
661
- hasCredential: Boolean(config.ha1 || config.passwordHash || config.password),
662
- credentialPrefix: config.ha1 ? `${String(config.ha1).slice(0, 6)}…` : '',
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,
694
+ hasCredential: Boolean(ha1),
695
+ // Only a real HA1 counts. `password` and `passwordHash` are fields an older build could leave behind,
696
+ // and `connect()` cannot use either — claiming a credential exists would enable 立即同步 and then
697
+ // fail with "no WebDAV credential is configured" the moment it was pressed.
698
+ credentialPrefix: ha1.length >= 16 ? `${ha1.slice(0, 6)}…` : '',
663
699
  }
664
700
  }
665
701
 
@@ -692,28 +728,64 @@ export function apply(ctx) {
692
728
  }
693
729
  }
694
730
 
731
+ /**
732
+ * Every piece of work that talks to the server goes through one queue.
733
+ *
734
+ * The owner lock keeps two **processes** apart; inside one process it cannot, because the host holds it for
735
+ * its whole life. Without this, 立即同步 and the automatic price push run as two independent chains: a run's
736
+ * `PROPFIND` and a push's `GET pricing.json` would already be interleaving on the wire (measured), and both
737
+ * mutate the same `engine.state` and rewrite `sync.json`, so whichever finishes last decides what the panel
738
+ * says about the other.
739
+ */
740
+ function enqueue(instance, work) {
741
+ const next = (instance.chain ?? Promise.resolve()).then(work, work)
742
+ // The chain must survive a rejection — neither breaking on it nor leaving it unhandled.
743
+ instance.chain = next.then(
744
+ () => {},
745
+ () => {},
746
+ )
747
+ return next
748
+ }
749
+
695
750
  async function syncStatus() {
696
- // Reading the status must not create anything either, so an untouched install answers with the
697
- // 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.
698
755
  if (!system) {
699
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
700
769
  return {
701
- config: safeSyncConfig({ ...document, hasCredential: Boolean(document.ha1 || document.passwordHash || document.password) }),
702
- writable: true,
703
- 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,
704
773
  running: false,
705
774
  last: {
706
- result: document.state?.lastResult || 'never',
707
- message: document.state?.message ?? '',
708
- at: Number(document.state?.lastSyncAt) || 0,
775
+ result: state.lastResult || 'never',
776
+ message: state.message ?? '',
777
+ at: Number(state.lastSyncAt) || 0,
709
778
  },
710
- collision: document.state?.collision ?? null,
711
- 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,
712
783
  // `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
713
784
  // are no other devices".
714
785
  remoteKnown: false,
715
786
  remoteError: null,
716
- published: {},
787
+ remoteDevicesOnServer: null,
788
+ published: state.published ?? {},
717
789
  conflictFiles: conflictFiles(),
718
790
  paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
719
791
  }
@@ -757,7 +829,7 @@ export function apply(ctx) {
757
829
  function runSync() {
758
830
  const instance = syncSystem()
759
831
  if (!instance.running) {
760
- instance.running = (async () => {
832
+ instance.running = enqueue(instance, async () => {
761
833
  if (!instance.engine.store) {
762
834
  // The cache is read-only here (another instance holds the lock, a permission problem, a newer
763
835
  // schema). The sync would publish a history this process cannot even fold, so it declines
@@ -794,7 +866,7 @@ export function apply(ctx) {
794
866
  return { result: 'failed', message: String(error?.message ?? error), at: 0 }
795
867
  }
796
868
  // Nothing to release: the lock belongs to the system and lives as long as this process does.
797
- })()
869
+ })
798
870
  .then((result) => {
799
871
  instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
800
872
  return result
@@ -863,11 +935,9 @@ export function apply(ctx) {
863
935
  pricingPushTimer = null
864
936
  const instance = syncSystem()
865
937
  if (!instance.lock?.acquired || !instance.engine.store) return
866
- // Chained onto the same queue as a full run, so an edit landing mid-sync waits rather than
867
- // interleaving PUTs with it. The result is recorded like any other outcome: an edit whose publish
868
- // failed is exactly the thing the person would otherwise never learn about.
869
- instance.pushing = (instance.pushing ?? Promise.resolve())
870
- .then(() => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
938
+ // Onto the **same** queue as a full run, so an edit landing mid-sync waits for it instead of
939
+ // interleaving its PUTs and its `sync.json` write with the run's.
940
+ instance.pushing = enqueue(instance, () => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
871
941
  .then((result) => {
872
942
  log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
873
943
  instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
@@ -895,6 +965,10 @@ export function apply(ctx) {
895
965
  function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
896
966
  const configured = readSyncDocument(dataDir, log)
897
967
  if (!configured.enabled || !configured.url || !configured.ha1) return
968
+ if (configured.onStart === false) {
969
+ log('startup sync is switched off')
970
+ return
971
+ }
898
972
  const timer = setTimeout(() => {
899
973
  startupSyncTimer = null
900
974
  const instance = syncSystem()
@@ -909,6 +983,56 @@ export function apply(ctx) {
909
983
  }
910
984
  let startupSyncTimer = null
911
985
 
986
+ /**
987
+ * A last sync on the way out, awaited — but only for as long as there is time.
988
+ *
989
+ * DSH gives the whole application teardown **5 seconds** (`PROCESS_SHUTDOWN_TIMEOUT_MS` in the runtime)
990
+ * before it exits the host process outright, and the desktop then waits up to 10 more for the child to go.
991
+ * So the chain that reaches this function is: desktop asks the host to shut down → the host awaits its own
992
+ * disposal → cordis disposes this plugin's effects, awaiting what they return. Returning a promise here is
993
+ * what makes the sync happen at all instead of being cut off mid-request.
994
+ *
995
+ * The budget is what keeps that promise from becoming a slow quit: past it the run is told to stop at its
996
+ * next step boundary (nothing half-written — uploads go through a temporary name and the manifest is
997
+ * written last) and the disposal returns. A sync that did not finish is reported as such, not pretended.
998
+ */
999
+ async function syncOnExit() {
1000
+ // Read the configuration from disk when this session never touched sync: a home configured in an earlier
1001
+ // session, quit without opening the panel, still has to sync. Creating the system here is also what takes
1002
+ // the lock — and the store is still open at this point (the disposer closes it afterwards).
1003
+ const document = system?.engine?.config ?? readSyncDocument(dataDir, log)
1004
+ if (!document.enabled || !document.url || !document.ha1) return
1005
+ if (document.onExit === false) {
1006
+ log('exit sync is switched off')
1007
+ return
1008
+ }
1009
+ const instance = syncSystem()
1010
+ if (!instance.engine.store) return
1011
+ if (!instance.lock?.acquired) {
1012
+ log(`exit sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
1013
+ return
1014
+ }
1015
+ const budget = delayFor('DSH_USAGE_BADGE_EXIT_SYNC_MS', EXIT_SYNC_BUDGET_MS)
1016
+ if (budget <= 0) {
1017
+ // A zero budget means "there is no time for this" — the switch a test or a debugging session uses.
1018
+ log('exit sync skipped: no time budget')
1019
+ return
1020
+ }
1021
+ let timer = null
1022
+ const deadline = new Promise((resolve) => {
1023
+ timer = setTimeout(() => resolve('deadline'), budget)
1024
+ timer.unref?.()
1025
+ })
1026
+ const outcome = await Promise.race([runSync().then((result) => result ?? 'done', () => 'failed'), deadline])
1027
+ clearTimeout(timer)
1028
+ if (outcome === 'deadline') {
1029
+ instance.engine.abort()
1030
+ log(`exit sync did not finish within ${budget}ms; it stops at its next step boundary`)
1031
+ return
1032
+ }
1033
+ log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
1034
+ }
1035
+
912
1036
  async function warm() {
913
1037
  try {
914
1038
  const status = await refreshOnce()
@@ -1018,7 +1142,7 @@ export function apply(ctx) {
1018
1142
  sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
1019
1143
  return
1020
1144
  }
1021
- const patch = JSON.parse((await readBody(req)) || '{}')
1145
+ const patch = await readJsonBody(req)
1022
1146
  commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
1023
1147
  log('pricing config updated')
1024
1148
  sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
@@ -1039,7 +1163,7 @@ export function apply(ctx) {
1039
1163
  sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
1040
1164
  return
1041
1165
  }
1042
- const body = JSON.parse((await readBody(req)) || '{}')
1166
+ const body = await readJsonBody(req)
1043
1167
  // Apply the copy the user was looking at, reusing the recent fetch rather
1044
1168
  // than pulling a possibly different page under them.
1045
1169
  const parsed = await officialPricing(body.source ?? 'zh-cn')
@@ -1072,14 +1196,15 @@ export function apply(ctx) {
1072
1196
  // plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
1073
1197
  // leaving the host half.
1074
1198
  if (route === '/sync' && req.method === 'GET') {
1075
- // Loopback only, like every other sync route. The answer names the server and the account, and
1076
- // reading it *is* engaging with sync — this is where the system comes into being, and with it the
1077
- // owner lock. A page that never opens this tab still touches nothing.
1199
+ // Loopback only, like every other sync route: the answer names the server and the account.
1200
+ //
1201
+ // **And it creates nothing.** Opening the tab is looking, not deciding — so this no longer takes the
1202
+ // lock or writes `sync.json`; the system comes into being when something is saved, tested or run.
1203
+ // `writable` is still answered honestly, by reading the lock file rather than writing one.
1078
1204
  if (!isLoopback(req)) {
1079
1205
  sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
1080
1206
  return
1081
1207
  }
1082
- syncSystem()
1083
1208
  let body
1084
1209
  try {
1085
1210
  body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
@@ -1097,11 +1222,16 @@ export function apply(ctx) {
1097
1222
  return
1098
1223
  }
1099
1224
  const instance = syncSystem()
1100
- const patch = JSON.parse((await readBody(req)) || '{}')
1225
+ const patch = await readJsonBody(req)
1101
1226
  const config = {}
1102
- for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
1227
+ for (const field of ['enabled', 'url', 'username', 'deviceLabel', 'deviceId', 'realm']) {
1103
1228
  if (patch[field] !== undefined) config[field] = patch[field]
1104
1229
  }
1230
+ // The switches, coerced: a panel sends booleans, but a hand-written request may send 0/1, and a
1231
+ // stored `"false"` string would otherwise read as *on* (it is not `false`).
1232
+ for (const field of ['allowInsecureTls', 'onStart', 'onExit']) {
1233
+ if (patch[field] !== undefined) config[field] = patch[field] !== false && patch[field] !== 'false' && patch[field] !== 0
1234
+ }
1105
1235
  if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
1106
1236
  if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
1107
1237
  try {
@@ -1132,12 +1262,19 @@ export function apply(ctx) {
1132
1262
  config.ha1 = patch.ha1.toLowerCase()
1133
1263
  }
1134
1264
  if (typeof config.url === 'string' && config.url !== '') {
1135
- try {
1136
- new URL(config.url)
1137
- } catch {
1138
- sendJson(res, 400, { error: `the WebDAV URL is not a URL: ${config.url}` })
1265
+ // The **same** judgement the client makes, from the same function. A copy here is what let the
1266
+ // route accept a query string, embedded credentials and `ftp:` — addresses the client refuses —
1267
+ // so the panel stored one, showed it, and the sync could not run at all. Two copies of a rule
1268
+ // drift; one copy cannot.
1269
+ const judged = normalizeWebdavUrl(config.url)
1270
+ if (!judged.ok) {
1271
+ sendJson(res, 400, { error: judged.reason })
1139
1272
  return
1140
1273
  }
1274
+ // The trailing slash is **added** rather than demanded: someone typing `host:port` means the
1275
+ // collection at that address. The panel stores what this returns, so the field ends up showing
1276
+ // the address that will actually be used instead of the one that was typed.
1277
+ config.url = judged.url
1141
1278
  }
1142
1279
  instance.engine.save({ config })
1143
1280
  log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
@@ -1151,8 +1288,13 @@ export function apply(ctx) {
1151
1288
  sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1152
1289
  return
1153
1290
  }
1291
+ // The client is kept across the catch so a **failure** can still report the realm the server
1292
+ // announced. That realm is the missing piece when someone's server does not use `WebDAV`: the
1293
+ // password was hashed against the default, so nothing can work until it is re-hashed against the
1294
+ // real one — and without this the panel had no way to learn it.
1295
+ let client = null
1154
1296
  try {
1155
- const client = syncSystem().engine.connect()
1297
+ client = syncSystem().engine.connect()
1156
1298
  const options = await client.options()
1157
1299
  const devices = await syncSystem().engine.discover(client)
1158
1300
  sendJson(res, 200, {
@@ -1164,7 +1306,7 @@ export function apply(ctx) {
1164
1306
  warning: client.insecureWarning ?? null,
1165
1307
  })
1166
1308
  } catch (error) {
1167
- sendJson(res, 200, { ok: false, error: String(error?.message ?? error) })
1309
+ sendJson(res, 200, { ok: false, error: String(error?.message ?? error), realm: client?.challenge?.realm ?? null })
1168
1310
  }
1169
1311
  return
1170
1312
  }
@@ -1198,23 +1340,52 @@ export function apply(ctx) {
1198
1340
  // asynchronous, and a value read after it would be whatever the environment holds by then — which a
1199
1341
  // test that sets an override for exactly this mount would already have taken away.
1200
1342
  const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
1343
+ // Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
1344
+ // callback ran anyway, took the lock back, and synced through a store that had already been closed
1345
+ // (`statement has been finalized`), leaving a lock file behind for a host that was gone.
1346
+ let disposed = false
1201
1347
  void warm().then(() => {
1348
+ if (disposed) return
1202
1349
  scheduleStartupSync(startupDelay)
1203
1350
  // Fire and forget: this only fills in what the server holds, for the panel's next read.
1204
1351
  void refreshRemoteDevices()
1205
1352
  })
1206
- return () => {
1353
+ // The disposer is **async on purpose**, and everything below depends on the order:
1354
+ //
1355
+ // 1. stop automatic work that would fire into a process that is going away;
1356
+ // 2. sync once, while the store is still open (a sync reads it) and while we still own the lock;
1357
+ // 3. hand the lock back and close the store.
1358
+ //
1359
+ // Returning a promise is what makes step 2 real: DSH awaits the host's teardown, cordis awaits what an
1360
+ // effect's disposer returns, and the runtime force-exits only after 5 s. So this is not "fire and hope";
1361
+ // it is synchronised with the shutdown — and bounded, so a slow server cannot hold up quitting.
1362
+ return async () => {
1363
+ disposed = true
1207
1364
  // Any pending automatic work is dropped on unload: a push that fires while the host is going away
1208
1365
  // would write state for a process that no longer exists.
1209
1366
  if (pricingPushTimer) clearTimeout(pricingPushTimer)
1210
1367
  if (startupSyncTimer) clearTimeout(startupSyncTimer)
1211
- // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process,
1212
- // and a re-mount inside the same process — a hot reload, or a test mounting the same home twice —
1213
- // is then refused by this plugin's own lock, for a pid that is very much alive: its own.
1368
+ if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
1369
+ try {
1370
+ await syncOnExit()
1371
+ } catch (error) {
1372
+ log(`exit sync failed: ${error?.message ?? error}`)
1373
+ }
1374
+ // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
1375
+ // re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
1376
+ // refused by this plugin's own lock, for a pid that is very much alive: its own.
1377
+ //
1378
+ // `engine.release()` alone is not enough: a run in flight was handed the host's lock object, and the
1379
+ // engine only releases one it acquired itself (`ownsLock`), so unloading mid-run would leave the file
1380
+ // behind. `releaseLock` checks the pid, so this can only ever remove our own.
1214
1381
  if (system) {
1215
1382
  system.syncAbort = true
1383
+ // Stops a run at its next step boundary. Requests already on the wire finish; the phases that would
1384
+ // touch the store do not start.
1385
+ system.engine.abort()
1216
1386
  system.engine.release()
1217
1387
  }
1388
+ releaseLock(dataDir)
1218
1389
  aggregator.close()
1219
1390
  }
1220
1391
  }, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')