dsh-cherry-bridge 0.2.0 → 0.2.2
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.en.md +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +808 -47
- package/docs/e2e-checklist.md +72 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +106 -22
- package/src/connection.ts +187 -2
- package/src/deviceStore.ts +314 -1
- package/src/diagnostics.ts +6 -5
- package/src/httpApi.ts +234 -27
- package/src/index.ts +18 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +158 -14
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
package/src/deviceStore.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import { dirname } from 'node:path'
|
|
14
|
+
import type { DirectEndpoint } from '@cherrystudio/remote-protocol'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Crash-durable, corruption-safe archive of paired Cherry Studio desktop
|
|
@@ -75,6 +76,21 @@ export interface DeviceRecord {
|
|
|
75
76
|
* `ports` from `port`; `port` itself stays the record-level fallback.
|
|
76
77
|
*/
|
|
77
78
|
ports: Record<string, number>
|
|
79
|
+
/**
|
|
80
|
+
* Addresses the operator has FORGOTTEN (issue #185): never dialed and never
|
|
81
|
+
* learned again, matched CASE-INSENSITIVELY and disjoint from `hosts` — a
|
|
82
|
+
* record may not dial what it has forgotten, and `assertValidRecord` refuses
|
|
83
|
+
* the overlap rather than picking a winner at read time.
|
|
84
|
+
*
|
|
85
|
+
* OPTIONAL on purpose, and therefore not in {@link REQUIRED_KEYS}. Absence is
|
|
86
|
+
* the total default ("nothing forgotten"); no invariant depends on the key
|
|
87
|
+
* being present, unlike `security`/`ports` whose absence would make a host
|
|
88
|
+
* undialable; and an older build ignores an unknown key instead of refusing
|
|
89
|
+
* the archive. A `schemaVersion` bump would invalidate every v4 archive (and
|
|
90
|
+
* every fixture built from the v4 shape) for a list whose empty value needs
|
|
91
|
+
* no migration.
|
|
92
|
+
*/
|
|
93
|
+
forgottenHosts?: string[]
|
|
78
94
|
desktopIdentity: string
|
|
79
95
|
grants: 'agent'[]
|
|
80
96
|
/** Paired with `tokenExpiresAt`: present together or absent together. */
|
|
@@ -107,6 +123,80 @@ export type DeviceResolution = DeviceRecord | AmbiguousDevice | UnresolvedDevice
|
|
|
107
123
|
/** The only `schemaVersion` this build reads and writes. */
|
|
108
124
|
export const CURRENT_SCHEMA_VERSION = 4
|
|
109
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The most candidate hosts one record may carry (issue #143).
|
|
128
|
+
*
|
|
129
|
+
* `hosts` is a DIAL list, not a table: every reconnect walks it with one
|
|
130
|
+
* `connectTimeoutMs` per address, so its LENGTH is a time cost — a 32-host
|
|
131
|
+
* record already allows a 320 s pass, which is the number the pairing walk's
|
|
132
|
+
* aggregate budget exists for. Pairing can only ever produce 32
|
|
133
|
+
* (`pairingQrSchema`'s `.max(32)`), and learning is held to the same bound: an
|
|
134
|
+
* enrichment may not build a record that no pairing could have produced.
|
|
135
|
+
*/
|
|
136
|
+
export const MAX_CANDIDATE_HOSTS = 32
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Adds the candidate addresses a peer ADVERTISED that this record does not
|
|
140
|
+
* already dial, returning how many hosts were added (issue #143).
|
|
141
|
+
*
|
|
142
|
+
* The rule is ADD-ONLY, deliberately:
|
|
143
|
+
*
|
|
144
|
+
* - a host already in `hosts` is left exactly as it is, INCLUDING its port and
|
|
145
|
+
* its scheme. The peer's snapshot describes where it listens NOW, while a
|
|
146
|
+
* record entry may be an operator's choice (a pinned port, a TLS proxy) that
|
|
147
|
+
* every reconnect must keep honouring; re-pointing it would silently rewrite
|
|
148
|
+
* the operator's address book on a schedule. "Already dials" is judged
|
|
149
|
+
* CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
|
|
150
|
+
* lowercases what it advertises, and a PATCHed record stores what the
|
|
151
|
+
* operator typed — without this, `MyNAS.example` and `mynas.example` would
|
|
152
|
+
* both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
|
|
153
|
+
* reconciled here; the PATCH path owns the archive's spelling, and it does
|
|
154
|
+
* not canonicalize either.)
|
|
155
|
+
* - nothing is removed HERE: a host the peer still advertises is re-learned
|
|
156
|
+
* after an operator PATCH drops it, because the peer is the source of truth
|
|
157
|
+
* for where it listens. The operator's answer to that is `forgetEndpoints`
|
|
158
|
+
* (issue #185), whose mark this function honours — addition stays add-only,
|
|
159
|
+
* and forgetting is a separate, explicit decision;
|
|
160
|
+
* - host, port and scheme are appended TOGETHER, which is what keeps the v4
|
|
161
|
+
* one-entry-per-host invariant true. That invariant is enforced on the READ
|
|
162
|
+
* path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
|
|
163
|
+
* there is NOT a quarantine (that class covers unreadable bytes): the throw
|
|
164
|
+
* escapes `parseArchive` out of `list()`, so a split entry would break every
|
|
165
|
+
* tool until an operator repaired the file;
|
|
166
|
+
* - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
|
|
167
|
+
* rather than scanning the rest.
|
|
168
|
+
*
|
|
169
|
+
* `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
|
|
170
|
+
* (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
|
|
171
|
+
* loopback destination), so every value written here is archive-valid without a
|
|
172
|
+
* second validation pass.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeAdvertisedEndpoints(
|
|
175
|
+
record: DeviceRecord,
|
|
176
|
+
endpoints: readonly DirectEndpoint[],
|
|
177
|
+
): number {
|
|
178
|
+
// One comparison rule, applied to what the record already dials AND to what
|
|
179
|
+
// this answer has added so far: a duplicate inside one answer must not take a
|
|
180
|
+
// second slot either.
|
|
181
|
+
const dialed = new Set(record.hosts.map((host) => host.toLowerCase()))
|
|
182
|
+
// A FORGOTTEN address is skipped here, not removed afterwards: the operator's
|
|
183
|
+
// decision outranks the peer's snapshot, and an answer that names it must not
|
|
184
|
+
// even spend a dial slot on it.
|
|
185
|
+
const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()))
|
|
186
|
+
let added = 0
|
|
187
|
+
for (const endpoint of endpoints) {
|
|
188
|
+
const key = endpoint.host.toLowerCase()
|
|
189
|
+
if (dialed.has(key) || forgotten.has(key)) continue
|
|
190
|
+
if (record.hosts.length >= MAX_CANDIDATE_HOSTS) break
|
|
191
|
+
record.hosts.push(endpoint.host)
|
|
192
|
+
record.ports[endpoint.host] = endpoint.port
|
|
193
|
+
record.security[endpoint.host] = endpoint.security
|
|
194
|
+
dialed.add(key)
|
|
195
|
+
added += 1
|
|
196
|
+
}
|
|
197
|
+
return added
|
|
198
|
+
}
|
|
199
|
+
|
|
110
200
|
const FILE_MODE = 0o600
|
|
111
201
|
|
|
112
202
|
/**
|
|
@@ -275,6 +365,7 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
|
|
|
275
365
|
}
|
|
276
366
|
assertValidSecurity(disk.hosts, disk.security)
|
|
277
367
|
assertValidPorts(disk.hosts, disk.ports)
|
|
368
|
+
assertValidForgotten(disk.hosts, disk.forgottenHosts)
|
|
278
369
|
|
|
279
370
|
// accessToken / tokenExpiresAt live and die together.
|
|
280
371
|
const hasToken = disk.accessToken !== undefined
|
|
@@ -363,6 +454,72 @@ function assertValidPorts(hosts: string[], ports: unknown): void {
|
|
|
363
454
|
}
|
|
364
455
|
}
|
|
365
456
|
|
|
457
|
+
/**
|
|
458
|
+
* `forgottenHosts`, when present, must be a list of non-empty strings with no
|
|
459
|
+
* case-variant duplicates and no overlap with `hosts`.
|
|
460
|
+
*
|
|
461
|
+
* Each rule refuses a state whose MEANING is ambiguous: two spellings of one
|
|
462
|
+
* host make "is this address suppressed?" depend on which entry the comparison
|
|
463
|
+
* hit first, and a host that is both dialed and forgotten makes the dial list
|
|
464
|
+
* and the suppression list disagree about the same address. The message names
|
|
465
|
+
* the offending host — these are addresses an operator chose, not secrets.
|
|
466
|
+
*/
|
|
467
|
+
function assertValidForgotten(hosts: string[], forgotten: unknown): void {
|
|
468
|
+
if (forgotten === undefined) return
|
|
469
|
+
if (!Array.isArray(forgotten)) {
|
|
470
|
+
throw new Error('device archive entry field forgottenHosts must be an array')
|
|
471
|
+
}
|
|
472
|
+
const dialed = new Set(hosts.map((host) => host.toLowerCase()))
|
|
473
|
+
const seen = new Set<string>()
|
|
474
|
+
for (const entry of forgotten) {
|
|
475
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
476
|
+
throw new Error('device archive entry field forgottenHosts must list non-empty strings')
|
|
477
|
+
}
|
|
478
|
+
const key = entry.toLowerCase()
|
|
479
|
+
if (seen.has(key)) {
|
|
480
|
+
throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`)
|
|
481
|
+
}
|
|
482
|
+
if (dialed.has(key)) {
|
|
483
|
+
throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`)
|
|
484
|
+
}
|
|
485
|
+
seen.add(key)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Repairs ONE cross-build state on the READ path: a host listed in both
|
|
491
|
+
* `hosts` and `forgottenHosts` (issue #185).
|
|
492
|
+
*
|
|
493
|
+
* A build that predates the field PRESERVES it but cannot honour it, so a
|
|
494
|
+
* downgrade followed by a still-advertising desktop appends the host to
|
|
495
|
+
* `hosts` while the mark stays. Upgrading again then yields a record that says
|
|
496
|
+
* both "dial it" and "never dial it". Refusing it would brick every device tool
|
|
497
|
+
* until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
|
|
498
|
+
* list — what the running build actually did — and drops the mark, with a
|
|
499
|
+
* warning naming the device and host (addresses are not secrets).
|
|
500
|
+
*
|
|
501
|
+
* The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
|
|
502
|
+
* `rememberEndpoints` never produce the overlap, and `assertValidRecord`
|
|
503
|
+
* refuses it. This runs ONLY on the read path, before that assertion.
|
|
504
|
+
*/
|
|
505
|
+
function reconcileForgottenMarks(disk: DeviceRecordOnDisk): DeviceRecordOnDisk {
|
|
506
|
+
const forgotten = disk.forgottenHosts
|
|
507
|
+
if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk
|
|
508
|
+
const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()))
|
|
509
|
+
const kept = forgotten.filter(
|
|
510
|
+
(host) => !(typeof host === 'string' && dialed.has(host.toLowerCase())),
|
|
511
|
+
)
|
|
512
|
+
if (kept.length === forgotten.length) return disk
|
|
513
|
+
for (const host of forgotten) {
|
|
514
|
+
if (typeof host !== 'string' || !dialed.has(host.toLowerCase())) continue
|
|
515
|
+
console.warn(
|
|
516
|
+
`device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and ` +
|
|
517
|
+
`forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
return { ...disk, forgottenHosts: kept }
|
|
521
|
+
}
|
|
522
|
+
|
|
366
523
|
/**
|
|
367
524
|
* Read path, fixed order: loose parse -> completeness gate -> version judgement
|
|
368
525
|
* -> migration -> assert the required `DeviceRecord` type.
|
|
@@ -425,7 +582,9 @@ function parseRecord(raw: unknown, archiveVersion: number): DeviceRecord {
|
|
|
425
582
|
migrated = migrate(migrated)
|
|
426
583
|
}
|
|
427
584
|
|
|
428
|
-
|
|
585
|
+
// A cross-build repair, never a write-path policy: see
|
|
586
|
+
// `reconcileForgottenMarks` for why the read path is the forgiving one.
|
|
587
|
+
return assertValidRecord(reconcileForgottenMarks(migrated))
|
|
429
588
|
}
|
|
430
589
|
|
|
431
590
|
function parseArchive(parsed: unknown): ArchiveFile {
|
|
@@ -579,6 +738,31 @@ export class QuarantinedArchiveError extends Error {
|
|
|
579
738
|
}
|
|
580
739
|
}
|
|
581
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
|
|
743
|
+
* would leave a record with NO dial candidate.
|
|
744
|
+
*
|
|
745
|
+
* A record with nothing to dial is worse than the stale address the operator
|
|
746
|
+
* wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
|
|
747
|
+
* with a non-empty `hosts` list writes a fresh one, and the archive would look
|
|
748
|
+
* healthy the whole time. The check is ATOMIC — the whole call refuses, so a
|
|
749
|
+
* name for the last host cannot remove a different host on its way to failing.
|
|
750
|
+
* Nothing is written when this is thrown.
|
|
751
|
+
*/
|
|
752
|
+
export class LastCandidateHostError extends Error {
|
|
753
|
+
readonly deviceId: string
|
|
754
|
+
|
|
755
|
+
constructor(deviceId: string) {
|
|
756
|
+
super(
|
|
757
|
+
`device ${deviceId}: refusing to forget the last dial candidate — the record would have ` +
|
|
758
|
+
'nothing to dial. Keep at least one address.',
|
|
759
|
+
)
|
|
760
|
+
this.name = 'LastCandidateHostError'
|
|
761
|
+
this.deviceId = deviceId
|
|
762
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
582
766
|
/**
|
|
583
767
|
* Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
|
|
584
768
|
* copy must not mask the original problem) and returns the path it landed at.
|
|
@@ -772,6 +956,135 @@ export class DeviceStore {
|
|
|
772
956
|
this.#persist(devices)
|
|
773
957
|
}
|
|
774
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Merges freshly learned addresses into one record and writes ONLY when that
|
|
961
|
+
* record gained a host (issue #143), returning the number added.
|
|
962
|
+
*
|
|
963
|
+
* It exists so the enrichment is an ARCHIVE operation rather than an
|
|
964
|
+
* `upsert` the assembly assembles by hand, and so a merge that adds nothing
|
|
965
|
+
* never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
|
|
966
|
+
* every connect, and a stable desktop would otherwise pay a full atomic write
|
|
967
|
+
* per reconnect. The merge runs on a CLONE and is committed as a new list,
|
|
968
|
+
* mirroring `upsert`'s discipline — the store never adopts a caller's object
|
|
969
|
+
* graph, and a write refused at {@link #persist}'s own gate cannot have
|
|
970
|
+
* half-edited the record the archive still holds.
|
|
971
|
+
*
|
|
972
|
+
* `desktopIdentity` is the pin the answering connection belongs to: the
|
|
973
|
+
* record must still name that desktop. A RE-PAIR under the same `deviceId`
|
|
974
|
+
* (the desktop regenerated its identity) would otherwise take an address
|
|
975
|
+
* learned from the previous pairing — a stale candidate whose every dial then
|
|
976
|
+
* fails the Noise pin, permanently, because learning never removes.
|
|
977
|
+
*
|
|
978
|
+
* An unknown `deviceId` is a no-op — the record may have been removed (or
|
|
979
|
+
* re-paired) between the RPC and this write.
|
|
980
|
+
*/
|
|
981
|
+
learnEndpoints(deviceId: string, endpoints: readonly DirectEndpoint[], desktopIdentity: string): number {
|
|
982
|
+
const devices = this.#load()
|
|
983
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId)
|
|
984
|
+
if (index < 0) return 0
|
|
985
|
+
if (devices[index]!.desktopIdentity !== desktopIdentity) return 0
|
|
986
|
+
const merged = structuredClone(devices[index]!)
|
|
987
|
+
const added = mergeAdvertisedEndpoints(merged, endpoints)
|
|
988
|
+
if (added === 0) return 0
|
|
989
|
+
const next = [...devices]
|
|
990
|
+
next[index] = merged
|
|
991
|
+
this.#persist(next)
|
|
992
|
+
return added
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* FORGETS addresses (issue #185): drops each named host from the dial list —
|
|
997
|
+
* host, scheme and port as one unit — and records it so the next
|
|
998
|
+
* advertisement cannot put it back. Returns the hosts that left the dial list
|
|
999
|
+
* (`removed`) and the marks this call added (`marked`).
|
|
1000
|
+
*
|
|
1001
|
+
* This is the operation learning cannot provide. `connection.endpoints` is
|
|
1002
|
+
* asked on every handshake, so an address the desktop still advertises
|
|
1003
|
+
* returns after any removal the operator makes by PATCH; forgetting is the
|
|
1004
|
+
* explicit decision that outranks it.
|
|
1005
|
+
*
|
|
1006
|
+
* - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
|
|
1007
|
+
* spelling, the peer's schema lowercases what it advertises), and the mark
|
|
1008
|
+
* records the ARCHIVE's spelling when the host was dialed, the caller's
|
|
1009
|
+
* otherwise.
|
|
1010
|
+
* - Naming an address the record does not dial is legal and still marks it:
|
|
1011
|
+
* suppression is the point, and a pre-emptive forget must survive until
|
|
1012
|
+
* the peer advertises the address.
|
|
1013
|
+
* - Removing the LAST candidate throws {@link LastCandidateHostError} and
|
|
1014
|
+
* writes NOTHING — no host, no scheme, no port, no mark.
|
|
1015
|
+
* - A call that changes no byte (the host was already forgotten) does not
|
|
1016
|
+
* rewrite (or fsync) the archive.
|
|
1017
|
+
*
|
|
1018
|
+
* The write goes through `#persist` on a CLONE with the result re-validated,
|
|
1019
|
+
* so a caller-supplied junk host can never land a record the next read would
|
|
1020
|
+
* refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
|
|
1021
|
+
*/
|
|
1022
|
+
forgetEndpoints(deviceId: string, hosts: readonly string[]): { removed: string[]; marked: string[] } {
|
|
1023
|
+
const devices = this.#load()
|
|
1024
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId)
|
|
1025
|
+
if (index < 0) return { removed: [], marked: [] }
|
|
1026
|
+
const merged = structuredClone(devices[index]!)
|
|
1027
|
+
const wanted = new Set(hosts.map((host) => host.toLowerCase()))
|
|
1028
|
+
const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()))
|
|
1029
|
+
if (removed.length > 0 && removed.length === merged.hosts.length) {
|
|
1030
|
+
throw new LastCandidateHostError(deviceId)
|
|
1031
|
+
}
|
|
1032
|
+
for (const host of removed) {
|
|
1033
|
+
delete merged.security[host]
|
|
1034
|
+
delete merged.ports[host]
|
|
1035
|
+
}
|
|
1036
|
+
merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()))
|
|
1037
|
+
const forgotten = merged.forgottenHosts ?? []
|
|
1038
|
+
const known = new Set(forgotten.map((host) => host.toLowerCase()))
|
|
1039
|
+
const marked: string[] = []
|
|
1040
|
+
for (const host of hosts) {
|
|
1041
|
+
const key = host.toLowerCase()
|
|
1042
|
+
if (known.has(key)) continue
|
|
1043
|
+
const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host
|
|
1044
|
+
forgotten.push(spelling)
|
|
1045
|
+
known.add(key)
|
|
1046
|
+
marked.push(spelling)
|
|
1047
|
+
}
|
|
1048
|
+
if (removed.length === 0 && marked.length === 0) return { removed: [], marked: [] }
|
|
1049
|
+
merged.forgottenHosts = forgotten
|
|
1050
|
+
assertValidRecord(merged)
|
|
1051
|
+
const next = [...devices]
|
|
1052
|
+
next[index] = merged
|
|
1053
|
+
this.#persist(next)
|
|
1054
|
+
return { removed, marked }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Lifts a forget mark (issue #185), returning the marks that were removed.
|
|
1059
|
+
*
|
|
1060
|
+
* It does NOT dial the host again: it only lets the next handshake's
|
|
1061
|
+
* advertisement add it back through the ordinary learning path, with the
|
|
1062
|
+
* port and scheme the peer reports at that moment. An operator who wants the
|
|
1063
|
+
* address back immediately PATCHes it into `hosts`, which clears the mark on
|
|
1064
|
+
* the same write.
|
|
1065
|
+
*
|
|
1066
|
+
* No-op (and no write) when the device is unknown, nothing is forgotten, or
|
|
1067
|
+
* none of the named hosts is marked.
|
|
1068
|
+
*/
|
|
1069
|
+
rememberEndpoints(deviceId: string, hosts: readonly string[]): { restored: string[] } {
|
|
1070
|
+
const devices = this.#load()
|
|
1071
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId)
|
|
1072
|
+
if (index < 0) return { restored: [] }
|
|
1073
|
+
const current = devices[index]!.forgottenHosts ?? []
|
|
1074
|
+
if (current.length === 0) return { restored: [] }
|
|
1075
|
+
const wanted = new Set(hosts.map((host) => host.toLowerCase()))
|
|
1076
|
+
const restored = current.filter((host) => wanted.has(host.toLowerCase()))
|
|
1077
|
+
if (restored.length === 0) return { restored: [] }
|
|
1078
|
+
const merged = structuredClone(devices[index]!)
|
|
1079
|
+
merged.forgottenHosts = (merged.forgottenHosts ?? []).filter(
|
|
1080
|
+
(host) => !wanted.has(host.toLowerCase()),
|
|
1081
|
+
)
|
|
1082
|
+
const next = [...devices]
|
|
1083
|
+
next[index] = merged
|
|
1084
|
+
this.#persist(next)
|
|
1085
|
+
return { restored }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
775
1088
|
#load(): DeviceRecord[] {
|
|
776
1089
|
if (this.#devices !== undefined) return this.#devices
|
|
777
1090
|
|
package/src/diagnostics.ts
CHANGED
|
@@ -199,7 +199,7 @@ export interface DiagnosticsDevice {
|
|
|
199
199
|
* `partsMode: 'paged'`.
|
|
200
200
|
*
|
|
201
201
|
* Its own field, not a fifth key inside `limits`: it is a VERSION, not a
|
|
202
|
-
* count, and `limits` is
|
|
202
|
+
* count, and `limits` here is the four numeric caps this report CARRIES. Present only
|
|
203
203
|
* when a LIVE connection reported it (the same rule as `limits`: the endpoint
|
|
204
204
|
* never dials to find out). ABSENT therefore means "not advertised **or** not
|
|
205
205
|
* connected" — every desktop through 2.1.7, and any device whose link is
|
|
@@ -555,10 +555,11 @@ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
|
|
|
555
555
|
}
|
|
556
556
|
|
|
557
557
|
/**
|
|
558
|
-
* Re-projects the four numeric limit keys, dropping any
|
|
559
|
-
* caller's `limits` object might carry
|
|
560
|
-
*
|
|
561
|
-
*
|
|
558
|
+
* Re-projects the four numeric limit keys this report carries, dropping any
|
|
559
|
+
* foreign key that a caller's `limits` object might carry — including
|
|
560
|
+
* `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
|
|
561
|
+
* A whitelist inside the whitelist: the shape is rebuilt field by field rather
|
|
562
|
+
* than spread, so a future addition cannot arrive here unvetted.
|
|
562
563
|
*/
|
|
563
564
|
function projectLimits(raw: unknown): { limits?: EffectiveLimits } {
|
|
564
565
|
if (raw === null || typeof raw !== 'object') return {}
|