dsh-cherry-bridge 0.2.0 → 0.2.1
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 +668 -35
- package/docs/e2e-checklist.md +49 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +103 -14
- package/src/connection.ts +174 -2
- package/src/deviceStore.ts +314 -1
- package/src/httpApi.ts +234 -27
- package/src/index.ts +13 -0
- package/src/tools.ts +148 -9
- 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/dist/index.mjs
CHANGED
|
@@ -5,8 +5,60 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { WebSocket } from "ws";
|
|
8
|
-
import { directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
|
|
8
|
+
import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
|
|
9
9
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
10
|
+
/**
|
|
11
|
+
* Adds the candidate addresses a peer ADVERTISED that this record does not
|
|
12
|
+
* already dial, returning how many hosts were added (issue #143).
|
|
13
|
+
*
|
|
14
|
+
* The rule is ADD-ONLY, deliberately:
|
|
15
|
+
*
|
|
16
|
+
* - a host already in `hosts` is left exactly as it is, INCLUDING its port and
|
|
17
|
+
* its scheme. The peer's snapshot describes where it listens NOW, while a
|
|
18
|
+
* record entry may be an operator's choice (a pinned port, a TLS proxy) that
|
|
19
|
+
* every reconnect must keep honouring; re-pointing it would silently rewrite
|
|
20
|
+
* the operator's address book on a schedule. "Already dials" is judged
|
|
21
|
+
* CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
|
|
22
|
+
* lowercases what it advertises, and a PATCHed record stores what the
|
|
23
|
+
* operator typed — without this, `MyNAS.example` and `mynas.example` would
|
|
24
|
+
* both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
|
|
25
|
+
* reconciled here; the PATCH path owns the archive's spelling, and it does
|
|
26
|
+
* not canonicalize either.)
|
|
27
|
+
* - nothing is removed HERE: a host the peer still advertises is re-learned
|
|
28
|
+
* after an operator PATCH drops it, because the peer is the source of truth
|
|
29
|
+
* for where it listens. The operator's answer to that is `forgetEndpoints`
|
|
30
|
+
* (issue #185), whose mark this function honours — addition stays add-only,
|
|
31
|
+
* and forgetting is a separate, explicit decision;
|
|
32
|
+
* - host, port and scheme are appended TOGETHER, which is what keeps the v4
|
|
33
|
+
* one-entry-per-host invariant true. That invariant is enforced on the READ
|
|
34
|
+
* path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
|
|
35
|
+
* there is NOT a quarantine (that class covers unreadable bytes): the throw
|
|
36
|
+
* escapes `parseArchive` out of `list()`, so a split entry would break every
|
|
37
|
+
* tool until an operator repaired the file;
|
|
38
|
+
* - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
|
|
39
|
+
* rather than scanning the rest.
|
|
40
|
+
*
|
|
41
|
+
* `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
|
|
42
|
+
* (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
|
|
43
|
+
* loopback destination), so every value written here is archive-valid without a
|
|
44
|
+
* second validation pass.
|
|
45
|
+
*/
|
|
46
|
+
function mergeAdvertisedEndpoints(record, endpoints) {
|
|
47
|
+
const dialed = new Set(record.hosts.map((host) => host.toLowerCase()));
|
|
48
|
+
const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()));
|
|
49
|
+
let added = 0;
|
|
50
|
+
for (const endpoint of endpoints) {
|
|
51
|
+
const key = endpoint.host.toLowerCase();
|
|
52
|
+
if (dialed.has(key) || forgotten.has(key)) continue;
|
|
53
|
+
if (record.hosts.length >= 32) break;
|
|
54
|
+
record.hosts.push(endpoint.host);
|
|
55
|
+
record.ports[endpoint.host] = endpoint.port;
|
|
56
|
+
record.security[endpoint.host] = endpoint.security;
|
|
57
|
+
dialed.add(key);
|
|
58
|
+
added += 1;
|
|
59
|
+
}
|
|
60
|
+
return added;
|
|
61
|
+
}
|
|
10
62
|
const FILE_MODE = 384;
|
|
11
63
|
/**
|
|
12
64
|
* Version-to-version migrations, keyed by the version they upgrade from.
|
|
@@ -118,6 +170,7 @@ function assertValidRecord(disk) {
|
|
|
118
170
|
if (typeof disk.port !== "number" || !Number.isInteger(disk.port) || disk.port < 1 || disk.port > 65535) throw new Error("device archive entry field port must be an integer in 1..65535");
|
|
119
171
|
assertValidSecurity(disk.hosts, disk.security);
|
|
120
172
|
assertValidPorts(disk.hosts, disk.ports);
|
|
173
|
+
assertValidForgotten(disk.hosts, disk.forgottenHosts);
|
|
121
174
|
if (disk.accessToken !== void 0 !== (disk.tokenExpiresAt !== void 0)) throw new Error("device archive entry must carry accessToken and tokenExpiresAt together or not at all");
|
|
122
175
|
return disk;
|
|
123
176
|
}
|
|
@@ -166,6 +219,60 @@ function assertValidPorts(hosts, ports) {
|
|
|
166
219
|
}
|
|
167
220
|
}
|
|
168
221
|
/**
|
|
222
|
+
* `forgottenHosts`, when present, must be a list of non-empty strings with no
|
|
223
|
+
* case-variant duplicates and no overlap with `hosts`.
|
|
224
|
+
*
|
|
225
|
+
* Each rule refuses a state whose MEANING is ambiguous: two spellings of one
|
|
226
|
+
* host make "is this address suppressed?" depend on which entry the comparison
|
|
227
|
+
* hit first, and a host that is both dialed and forgotten makes the dial list
|
|
228
|
+
* and the suppression list disagree about the same address. The message names
|
|
229
|
+
* the offending host — these are addresses an operator chose, not secrets.
|
|
230
|
+
*/
|
|
231
|
+
function assertValidForgotten(hosts, forgotten) {
|
|
232
|
+
if (forgotten === void 0) return;
|
|
233
|
+
if (!Array.isArray(forgotten)) throw new Error("device archive entry field forgottenHosts must be an array");
|
|
234
|
+
const dialed = new Set(hosts.map((host) => host.toLowerCase()));
|
|
235
|
+
const seen = /* @__PURE__ */ new Set();
|
|
236
|
+
for (const entry of forgotten) {
|
|
237
|
+
if (typeof entry !== "string" || entry.length === 0) throw new Error("device archive entry field forgottenHosts must list non-empty strings");
|
|
238
|
+
const key = entry.toLowerCase();
|
|
239
|
+
if (seen.has(key)) throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`);
|
|
240
|
+
if (dialed.has(key)) throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`);
|
|
241
|
+
seen.add(key);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Repairs ONE cross-build state on the READ path: a host listed in both
|
|
246
|
+
* `hosts` and `forgottenHosts` (issue #185).
|
|
247
|
+
*
|
|
248
|
+
* A build that predates the field PRESERVES it but cannot honour it, so a
|
|
249
|
+
* downgrade followed by a still-advertising desktop appends the host to
|
|
250
|
+
* `hosts` while the mark stays. Upgrading again then yields a record that says
|
|
251
|
+
* both "dial it" and "never dial it". Refusing it would brick every device tool
|
|
252
|
+
* until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
|
|
253
|
+
* list — what the running build actually did — and drops the mark, with a
|
|
254
|
+
* warning naming the device and host (addresses are not secrets).
|
|
255
|
+
*
|
|
256
|
+
* The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
|
|
257
|
+
* `rememberEndpoints` never produce the overlap, and `assertValidRecord`
|
|
258
|
+
* refuses it. This runs ONLY on the read path, before that assertion.
|
|
259
|
+
*/
|
|
260
|
+
function reconcileForgottenMarks(disk) {
|
|
261
|
+
const forgotten = disk.forgottenHosts;
|
|
262
|
+
if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk;
|
|
263
|
+
const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()));
|
|
264
|
+
const kept = forgotten.filter((host) => !(typeof host === "string" && dialed.has(host.toLowerCase())));
|
|
265
|
+
if (kept.length === forgotten.length) return disk;
|
|
266
|
+
for (const host of forgotten) {
|
|
267
|
+
if (typeof host !== "string" || !dialed.has(host.toLowerCase())) continue;
|
|
268
|
+
console.warn(`device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
...disk,
|
|
272
|
+
forgottenHosts: kept
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
169
276
|
* Read path, fixed order: loose parse -> completeness gate -> version judgement
|
|
170
277
|
* -> migration -> assert the required `DeviceRecord` type.
|
|
171
278
|
*
|
|
@@ -193,7 +300,7 @@ function parseRecord(raw, archiveVersion) {
|
|
|
193
300
|
if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
|
|
194
301
|
migrated = migrate(migrated);
|
|
195
302
|
}
|
|
196
|
-
return assertValidRecord(migrated);
|
|
303
|
+
return assertValidRecord(reconcileForgottenMarks(migrated));
|
|
197
304
|
}
|
|
198
305
|
function parseArchive(parsed) {
|
|
199
306
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
|
|
@@ -311,6 +418,26 @@ var QuarantinedArchiveError = class QuarantinedArchiveError extends Error {
|
|
|
311
418
|
}
|
|
312
419
|
};
|
|
313
420
|
/**
|
|
421
|
+
* Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
|
|
422
|
+
* would leave a record with NO dial candidate.
|
|
423
|
+
*
|
|
424
|
+
* A record with nothing to dial is worse than the stale address the operator
|
|
425
|
+
* wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
|
|
426
|
+
* with a non-empty `hosts` list writes a fresh one, and the archive would look
|
|
427
|
+
* healthy the whole time. The check is ATOMIC — the whole call refuses, so a
|
|
428
|
+
* name for the last host cannot remove a different host on its way to failing.
|
|
429
|
+
* Nothing is written when this is thrown.
|
|
430
|
+
*/
|
|
431
|
+
var LastCandidateHostError = class extends Error {
|
|
432
|
+
deviceId;
|
|
433
|
+
constructor(deviceId) {
|
|
434
|
+
super(`device ${deviceId}: refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address.`);
|
|
435
|
+
this.name = "LastCandidateHostError";
|
|
436
|
+
this.deviceId = deviceId;
|
|
437
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
314
441
|
* Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
|
|
315
442
|
* copy must not mask the original problem) and returns the path it landed at.
|
|
316
443
|
*/
|
|
@@ -477,6 +604,137 @@ var DeviceStore = class {
|
|
|
477
604
|
const devices = this.#load().filter((record) => record.deviceId !== deviceId);
|
|
478
605
|
this.#persist(devices);
|
|
479
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Merges freshly learned addresses into one record and writes ONLY when that
|
|
609
|
+
* record gained a host (issue #143), returning the number added.
|
|
610
|
+
*
|
|
611
|
+
* It exists so the enrichment is an ARCHIVE operation rather than an
|
|
612
|
+
* `upsert` the assembly assembles by hand, and so a merge that adds nothing
|
|
613
|
+
* never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
|
|
614
|
+
* every connect, and a stable desktop would otherwise pay a full atomic write
|
|
615
|
+
* per reconnect. The merge runs on a CLONE and is committed as a new list,
|
|
616
|
+
* mirroring `upsert`'s discipline — the store never adopts a caller's object
|
|
617
|
+
* graph, and a write refused at {@link #persist}'s own gate cannot have
|
|
618
|
+
* half-edited the record the archive still holds.
|
|
619
|
+
*
|
|
620
|
+
* `desktopIdentity` is the pin the answering connection belongs to: the
|
|
621
|
+
* record must still name that desktop. A RE-PAIR under the same `deviceId`
|
|
622
|
+
* (the desktop regenerated its identity) would otherwise take an address
|
|
623
|
+
* learned from the previous pairing — a stale candidate whose every dial then
|
|
624
|
+
* fails the Noise pin, permanently, because learning never removes.
|
|
625
|
+
*
|
|
626
|
+
* An unknown `deviceId` is a no-op — the record may have been removed (or
|
|
627
|
+
* re-paired) between the RPC and this write.
|
|
628
|
+
*/
|
|
629
|
+
learnEndpoints(deviceId, endpoints, desktopIdentity) {
|
|
630
|
+
const devices = this.#load();
|
|
631
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId);
|
|
632
|
+
if (index < 0) return 0;
|
|
633
|
+
if (devices[index].desktopIdentity !== desktopIdentity) return 0;
|
|
634
|
+
const merged = structuredClone(devices[index]);
|
|
635
|
+
const added = mergeAdvertisedEndpoints(merged, endpoints);
|
|
636
|
+
if (added === 0) return 0;
|
|
637
|
+
const next = [...devices];
|
|
638
|
+
next[index] = merged;
|
|
639
|
+
this.#persist(next);
|
|
640
|
+
return added;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* FORGETS addresses (issue #185): drops each named host from the dial list —
|
|
644
|
+
* host, scheme and port as one unit — and records it so the next
|
|
645
|
+
* advertisement cannot put it back. Returns the hosts that left the dial list
|
|
646
|
+
* (`removed`) and the marks this call added (`marked`).
|
|
647
|
+
*
|
|
648
|
+
* This is the operation learning cannot provide. `connection.endpoints` is
|
|
649
|
+
* asked on every handshake, so an address the desktop still advertises
|
|
650
|
+
* returns after any removal the operator makes by PATCH; forgetting is the
|
|
651
|
+
* explicit decision that outranks it.
|
|
652
|
+
*
|
|
653
|
+
* - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
|
|
654
|
+
* spelling, the peer's schema lowercases what it advertises), and the mark
|
|
655
|
+
* records the ARCHIVE's spelling when the host was dialed, the caller's
|
|
656
|
+
* otherwise.
|
|
657
|
+
* - Naming an address the record does not dial is legal and still marks it:
|
|
658
|
+
* suppression is the point, and a pre-emptive forget must survive until
|
|
659
|
+
* the peer advertises the address.
|
|
660
|
+
* - Removing the LAST candidate throws {@link LastCandidateHostError} and
|
|
661
|
+
* writes NOTHING — no host, no scheme, no port, no mark.
|
|
662
|
+
* - A call that changes no byte (the host was already forgotten) does not
|
|
663
|
+
* rewrite (or fsync) the archive.
|
|
664
|
+
*
|
|
665
|
+
* The write goes through `#persist` on a CLONE with the result re-validated,
|
|
666
|
+
* so a caller-supplied junk host can never land a record the next read would
|
|
667
|
+
* refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
|
|
668
|
+
*/
|
|
669
|
+
forgetEndpoints(deviceId, hosts) {
|
|
670
|
+
const devices = this.#load();
|
|
671
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId);
|
|
672
|
+
if (index < 0) return {
|
|
673
|
+
removed: [],
|
|
674
|
+
marked: []
|
|
675
|
+
};
|
|
676
|
+
const merged = structuredClone(devices[index]);
|
|
677
|
+
const wanted = new Set(hosts.map((host) => host.toLowerCase()));
|
|
678
|
+
const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()));
|
|
679
|
+
if (removed.length > 0 && removed.length === merged.hosts.length) throw new LastCandidateHostError(deviceId);
|
|
680
|
+
for (const host of removed) {
|
|
681
|
+
delete merged.security[host];
|
|
682
|
+
delete merged.ports[host];
|
|
683
|
+
}
|
|
684
|
+
merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()));
|
|
685
|
+
const forgotten = merged.forgottenHosts ?? [];
|
|
686
|
+
const known = new Set(forgotten.map((host) => host.toLowerCase()));
|
|
687
|
+
const marked = [];
|
|
688
|
+
for (const host of hosts) {
|
|
689
|
+
const key = host.toLowerCase();
|
|
690
|
+
if (known.has(key)) continue;
|
|
691
|
+
const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host;
|
|
692
|
+
forgotten.push(spelling);
|
|
693
|
+
known.add(key);
|
|
694
|
+
marked.push(spelling);
|
|
695
|
+
}
|
|
696
|
+
if (removed.length === 0 && marked.length === 0) return {
|
|
697
|
+
removed: [],
|
|
698
|
+
marked: []
|
|
699
|
+
};
|
|
700
|
+
merged.forgottenHosts = forgotten;
|
|
701
|
+
assertValidRecord(merged);
|
|
702
|
+
const next = [...devices];
|
|
703
|
+
next[index] = merged;
|
|
704
|
+
this.#persist(next);
|
|
705
|
+
return {
|
|
706
|
+
removed,
|
|
707
|
+
marked
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Lifts a forget mark (issue #185), returning the marks that were removed.
|
|
712
|
+
*
|
|
713
|
+
* It does NOT dial the host again: it only lets the next handshake's
|
|
714
|
+
* advertisement add it back through the ordinary learning path, with the
|
|
715
|
+
* port and scheme the peer reports at that moment. An operator who wants the
|
|
716
|
+
* address back immediately PATCHes it into `hosts`, which clears the mark on
|
|
717
|
+
* the same write.
|
|
718
|
+
*
|
|
719
|
+
* No-op (and no write) when the device is unknown, nothing is forgotten, or
|
|
720
|
+
* none of the named hosts is marked.
|
|
721
|
+
*/
|
|
722
|
+
rememberEndpoints(deviceId, hosts) {
|
|
723
|
+
const devices = this.#load();
|
|
724
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId);
|
|
725
|
+
if (index < 0) return { restored: [] };
|
|
726
|
+
const current = devices[index].forgottenHosts ?? [];
|
|
727
|
+
if (current.length === 0) return { restored: [] };
|
|
728
|
+
const wanted = new Set(hosts.map((host) => host.toLowerCase()));
|
|
729
|
+
const restored = current.filter((host) => wanted.has(host.toLowerCase()));
|
|
730
|
+
if (restored.length === 0) return { restored: [] };
|
|
731
|
+
const merged = structuredClone(devices[index]);
|
|
732
|
+
merged.forgottenHosts = (merged.forgottenHosts ?? []).filter((host) => !wanted.has(host.toLowerCase()));
|
|
733
|
+
const next = [...devices];
|
|
734
|
+
next[index] = merged;
|
|
735
|
+
this.#persist(next);
|
|
736
|
+
return { restored };
|
|
737
|
+
}
|
|
480
738
|
#load() {
|
|
481
739
|
if (this.#devices !== void 0) return this.#devices;
|
|
482
740
|
let archive;
|
|
@@ -2647,6 +2905,33 @@ const DEFAULT_BACKOFF_MAX_MS = 6e4;
|
|
|
2647
2905
|
const DEFAULT_REFRESH_GRACE_MS = 3e4;
|
|
2648
2906
|
const DEFAULT_CONNECT_TIMEOUT_MS$1 = 1e4;
|
|
2649
2907
|
const DEFAULT_PROTOCOL_VERSIONS = [1];
|
|
2908
|
+
/**
|
|
2909
|
+
* How long the optional `connection.endpoints` enrichment (issue #143) may hold
|
|
2910
|
+
* up ONE handshake before the connection gives up on it.
|
|
2911
|
+
*
|
|
2912
|
+
* The call runs INSIDE the handshake, so without a bound of its own it would
|
|
2913
|
+
* inherit the rpc layer's 60 s default: a peer that advertises
|
|
2914
|
+
* `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
|
|
2915
|
+
* would keep the device in `connecting` for a minute, and every reconnect would
|
|
2916
|
+
* pay it again. THIS layer bounds it because the enrichment is worth
|
|
2917
|
+
* milliseconds, not a retry.
|
|
2918
|
+
*
|
|
2919
|
+
* WHY 2 s. The peer's handler is a local computation
|
|
2920
|
+
* (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
|
|
2921
|
+
* round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
|
|
2922
|
+
* slack.
|
|
2923
|
+
*
|
|
2924
|
+
* The UPPER bound is NOT derived from a claim about the whole handshake: a dial
|
|
2925
|
+
* already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
|
|
2926
|
+
* ride the rpc layer's 60 s default, so `connected` can arrive long after a
|
|
2927
|
+
* tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
|
|
2928
|
+
* derived is narrower and true: THIS wait alone must stay below that window, so
|
|
2929
|
+
* a peer that advertises the capability and then hangs on this one call cannot
|
|
2930
|
+
* by itself push the state flip past the window a tool already waited out. A
|
|
2931
|
+
* timeout costs one reconnect's worth of enrichment and says nothing about the
|
|
2932
|
+
* device.
|
|
2933
|
+
*/
|
|
2934
|
+
const ENDPOINTS_LEARN_TIMEOUT_MS = 2e3;
|
|
2650
2935
|
/** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
|
|
2651
2936
|
const SILENCE_FACTOR = 2;
|
|
2652
2937
|
/** The deliberate local-shutdown face of a disposed client (see the replay path). */
|
|
@@ -2735,7 +3020,8 @@ var SecondWindowRateLimiter = class {
|
|
|
2735
3020
|
async acquire() {
|
|
2736
3021
|
let release;
|
|
2737
3022
|
const ahead = this.#queue;
|
|
2738
|
-
|
|
3023
|
+
const next = () => new Promise((resolve) => release = resolve);
|
|
3024
|
+
this.#queue = ahead.then(next, next);
|
|
2739
3025
|
await ahead;
|
|
2740
3026
|
try {
|
|
2741
3027
|
for (;;) {
|
|
@@ -2920,6 +3206,50 @@ function businessWindowFor(reportedInFlightRequests) {
|
|
|
2920
3206
|
return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
|
|
2921
3207
|
}
|
|
2922
3208
|
/**
|
|
3209
|
+
* The destinations a `connection.endpoints` answer may contribute, in the order
|
|
3210
|
+
* the peer listed them (issue #143). Empty when the answer must be discarded.
|
|
3211
|
+
*
|
|
3212
|
+
* TWO answers are refused WHOLE:
|
|
3213
|
+
*
|
|
3214
|
+
* - **the identity pin.** The answer must name the very desktop this record is
|
|
3215
|
+
* pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
|
|
3216
|
+
* static key, so this is not the adversarial case it protects against — it
|
|
3217
|
+
* is the defence against a CONFUSED answer (a proxy, a desktop whose identity
|
|
3218
|
+
* was regenerated, a future protocol that answers for more than one host):
|
|
3219
|
+
* the list decides where this client dials, so a list that cannot be
|
|
3220
|
+
* attributed to our own desktop may not change it;
|
|
3221
|
+
* - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
|
|
3222
|
+
* number is not the pin either, so both fall out of the same comparison.
|
|
3223
|
+
*
|
|
3224
|
+
* One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
|
|
3225
|
+
* destination schema, rejects a LOOPBACK destination (a shared address that
|
|
3226
|
+
* resolves to our own machine would send the next reconnect to the wrong host)
|
|
3227
|
+
* and normalizes the host (case, IPv6), so the archive never stores a spelling
|
|
3228
|
+
* the dial cannot use. One unusable row must not discard its neighbours.
|
|
3229
|
+
*
|
|
3230
|
+
* NOT total on the wire shape, deliberately: a non-object answer or a non-array
|
|
3231
|
+
* `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
|
|
3232
|
+
* turns that into the silent no-op the contract promises. Defending each shape
|
|
3233
|
+
* twice would look safer and be worse: the redundancy masks which guard a test
|
|
3234
|
+
* actually pins (issue #114's lesson), and the whole ANSWER is untrusted
|
|
3235
|
+
* anyway, so the boundary belongs at the caller, where the handshake can see
|
|
3236
|
+
* it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
|
|
3237
|
+
* "no addresses right now" answer (the desktop's `addresses` can be empty).
|
|
3238
|
+
*
|
|
3239
|
+
* No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
|
|
3240
|
+
* the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
|
|
3241
|
+
*/
|
|
3242
|
+
function advertisedEndpoints(result, desktopIdentity) {
|
|
3243
|
+
const answer = result;
|
|
3244
|
+
if (answer.desktopIdentity !== desktopIdentity) return [];
|
|
3245
|
+
const accepted = [];
|
|
3246
|
+
for (const raw of answer.endpoints ?? []) {
|
|
3247
|
+
const parsed = advertisedEndpointSchema.safeParse(raw);
|
|
3248
|
+
if (parsed.success) accepted.push(parsed.data);
|
|
3249
|
+
}
|
|
3250
|
+
return accepted;
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
2923
3253
|
* The desktop's content-read allowance: 512/s, and the SAME in both generations
|
|
2924
3254
|
* (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
|
|
2925
3255
|
* 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
|
|
@@ -3000,6 +3330,7 @@ var DeviceConnection = class {
|
|
|
3000
3330
|
#protocolVersions;
|
|
3001
3331
|
#helloProtocolVersions;
|
|
3002
3332
|
#transportRetryWaitMs;
|
|
3333
|
+
#onAdvertisedEndpoints;
|
|
3003
3334
|
#stateListeners = /* @__PURE__ */ new Set();
|
|
3004
3335
|
/**
|
|
3005
3336
|
* Callers parked in {@link waitForSettled}. Separate from the state listeners
|
|
@@ -3062,6 +3393,7 @@ var DeviceConnection = class {
|
|
|
3062
3393
|
this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS;
|
|
3063
3394
|
this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions;
|
|
3064
3395
|
this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? 5e3;
|
|
3396
|
+
this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints;
|
|
3065
3397
|
}
|
|
3066
3398
|
/**
|
|
3067
3399
|
* The business RPC surface: rate-smoothed and gated on `connected`. Typed
|
|
@@ -3388,6 +3720,7 @@ var DeviceConnection = class {
|
|
|
3388
3720
|
if (reason === "GRANT_REVOKED" || reason === "UNAUTHENTICATED") throw new AuthRejectedError(reason);
|
|
3389
3721
|
throw error instanceof Error ? error : new Error(String(error));
|
|
3390
3722
|
}
|
|
3723
|
+
await this.#learnEndpoints(link, hello.connectionEndpointsVersion);
|
|
3391
3724
|
if (epoch !== this.#epoch) {
|
|
3392
3725
|
client.dispose();
|
|
3393
3726
|
link.channel.close().catch(noop$1);
|
|
@@ -3411,6 +3744,51 @@ var DeviceConnection = class {
|
|
|
3411
3744
|
console.info(`[cherry-remote] device ${this.record.deviceId} connected (${digestIdentity(link.channel.remoteIdentity)})`);
|
|
3412
3745
|
}
|
|
3413
3746
|
/**
|
|
3747
|
+
* Best-effort enrichment of the dial list from the peer's advertised
|
|
3748
|
+
* endpoints (issue #143).
|
|
3749
|
+
*
|
|
3750
|
+
* WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
|
|
3751
|
+
* address that appeared after the QR was minted. It does NOT heal a total
|
|
3752
|
+
* address change (the list is captured on the old path) and can never carry
|
|
3753
|
+
* TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
|
|
3754
|
+
* comes from the QR or the operator.
|
|
3755
|
+
*
|
|
3756
|
+
* EVERY failure here is SILENT and non-fatal, because this is an enrichment,
|
|
3757
|
+
* not a verdict: a peer that does not advertise the capability (`connection`
|
|
3758
|
+
* through 2.1.3 has neither the hello bit nor this method), one advertising a
|
|
3759
|
+
* version this client cannot speak (strictly `1` — a higher number is not
|
|
3760
|
+
* evidence that it speaks 1, the same rule the paged-summaries bit took), a
|
|
3761
|
+
* refused call (the desktop throws when its gateway is not running), a wedged
|
|
3762
|
+
* one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
|
|
3763
|
+
* answer, or a store that cannot write. None of them is a CONNECTION failure:
|
|
3764
|
+
* the link comes up either way, and only the archive is left without the new
|
|
3765
|
+
* addresses — this instance's own record may already carry them, because the
|
|
3766
|
+
* merge runs before the callback (see the paragraph below).
|
|
3767
|
+
*
|
|
3768
|
+
* The MERGE happens in two places on purpose: here (this instance's record,
|
|
3769
|
+
* so its next reconnect dials the new address) and through the callback (the
|
|
3770
|
+
* assembly's archive, so a restart keeps it). They are the same pure rule
|
|
3771
|
+
* against two copies of the record, and each is idempotent.
|
|
3772
|
+
*
|
|
3773
|
+
* ONE `catch` covers everything after the gate, and that is the contract, not
|
|
3774
|
+
* laziness: a refused call, a wedged one (bounded by
|
|
3775
|
+
* {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
|
|
3776
|
+
* parser throws rather than defending every field twice), a throwing assembly
|
|
3777
|
+
* hook (an unwritable archive), or a bug in the merge must all land here —
|
|
3778
|
+
* none of them may fail the handshake. The observable promise is therefore
|
|
3779
|
+
* structural: this method cannot reject.
|
|
3780
|
+
*/
|
|
3781
|
+
async #learnEndpoints(link, capability) {
|
|
3782
|
+
if (capability !== 1) return;
|
|
3783
|
+
try {
|
|
3784
|
+
const endpoints = advertisedEndpoints(await this.#houseCall(link, "connection.endpoints", { domain: "agent" }, ENDPOINTS_LEARN_TIMEOUT_MS), this.record.desktopIdentity);
|
|
3785
|
+
if (endpoints.length === 0) return;
|
|
3786
|
+
const added = mergeAdvertisedEndpoints(this.record, endpoints);
|
|
3787
|
+
if (added > 0) console.info(`[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`);
|
|
3788
|
+
this.#onAdvertisedEndpoints?.(endpoints);
|
|
3789
|
+
} catch {}
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3414
3792
|
* authenticate with the stored token; a stale token (TOKEN_EXPIRED /
|
|
3415
3793
|
* UNAUTHENTICATED) is retried exactly once without it before the pairing
|
|
3416
3794
|
* is declared invalid.
|
|
@@ -5892,6 +6270,54 @@ function optionalString(value) {
|
|
|
5892
6270
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
5893
6271
|
}
|
|
5894
6272
|
/**
|
|
6273
|
+
* The `hosts` argument of `cherry_devices`' write actions (issue #185): a
|
|
6274
|
+
* non-empty list of non-empty host names, with no case-variant duplicate.
|
|
6275
|
+
*
|
|
6276
|
+
* Each rule is a state the store would have to interpret: an empty name can
|
|
6277
|
+
* never match a dialed host, and two spellings of one host would make "is this
|
|
6278
|
+
* address forgotten?" depend on which entry a comparison hit first. Values are
|
|
6279
|
+
* TRIMMED here (the model-facing surface is where a stray space is caught);
|
|
6280
|
+
* the archive then stores the spelling it already had for a dialed host.
|
|
6281
|
+
*/
|
|
6282
|
+
function hostListArgument(value) {
|
|
6283
|
+
if (!Array.isArray(value) || value.length === 0) return {
|
|
6284
|
+
error: "invalid-hosts",
|
|
6285
|
+
message: "`hosts` must be a non-empty array of host names"
|
|
6286
|
+
};
|
|
6287
|
+
const hosts = [];
|
|
6288
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6289
|
+
for (const entry of value) {
|
|
6290
|
+
if (typeof entry !== "string" || entry.trim() === "") return {
|
|
6291
|
+
error: "invalid-hosts",
|
|
6292
|
+
message: "`hosts` entries must be non-empty strings"
|
|
6293
|
+
};
|
|
6294
|
+
let host = entry.trim();
|
|
6295
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
6296
|
+
try {
|
|
6297
|
+
new URL(`http://${host}/`);
|
|
6298
|
+
} catch {
|
|
6299
|
+
return {
|
|
6300
|
+
error: "invalid-hosts",
|
|
6301
|
+
message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`
|
|
6302
|
+
};
|
|
6303
|
+
}
|
|
6304
|
+
host = host.slice(1, -1);
|
|
6305
|
+
}
|
|
6306
|
+
if (host.length > 256) return {
|
|
6307
|
+
error: "invalid-hosts",
|
|
6308
|
+
message: "`hosts` entry exceeds 256 characters"
|
|
6309
|
+
};
|
|
6310
|
+
const key = host.toLowerCase();
|
|
6311
|
+
if (seen.has(key)) return {
|
|
6312
|
+
error: "invalid-hosts",
|
|
6313
|
+
message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`
|
|
6314
|
+
};
|
|
6315
|
+
seen.add(key);
|
|
6316
|
+
hosts.push(host);
|
|
6317
|
+
}
|
|
6318
|
+
return hosts;
|
|
6319
|
+
}
|
|
6320
|
+
/**
|
|
5895
6321
|
* The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
|
|
5896
6322
|
* in the exact shape Cherry Studio answers it: a `rejected` receipt whose
|
|
5897
6323
|
* `error` is `{reason: 'CONFLICT', message: 'Session is not idle at the
|
|
@@ -6572,26 +6998,86 @@ var CherryToolEngine = class {
|
|
|
6572
6998
|
#devicesTool() {
|
|
6573
6999
|
return defineCherryTool({
|
|
6574
7000
|
name: "cherry_devices",
|
|
6575
|
-
description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId).",
|
|
7001
|
+
description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId). `action: \"forget\"` removes the named `hosts` from that device's dial candidates AND stops the desktop's advertisements from adding them back; `action: \"remember\"` lifts that mark, so the next handshake may learn them again. Both write actions require `device` and `hosts`.",
|
|
6576
7002
|
parameters: {
|
|
6577
7003
|
type: "object",
|
|
6578
|
-
properties: {
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
7004
|
+
properties: {
|
|
7005
|
+
device: {
|
|
7006
|
+
type: "string",
|
|
7007
|
+
description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
|
|
7008
|
+
},
|
|
7009
|
+
action: {
|
|
7010
|
+
type: "string",
|
|
7011
|
+
enum: [
|
|
7012
|
+
"list",
|
|
7013
|
+
"forget",
|
|
7014
|
+
"remember"
|
|
7015
|
+
],
|
|
7016
|
+
description: "What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE device's forgotten addresses and require `device` and `hosts`."
|
|
7017
|
+
},
|
|
7018
|
+
hosts: {
|
|
7019
|
+
type: "array",
|
|
7020
|
+
items: { type: "string" },
|
|
7021
|
+
description: "For `forget`/`remember`: the host names to act on, matched case-insensitively. `forget` refuses to remove the last remaining address."
|
|
7022
|
+
}
|
|
7023
|
+
},
|
|
6582
7024
|
additionalProperties: false
|
|
6583
7025
|
},
|
|
6584
7026
|
execute: async (args) => {
|
|
7027
|
+
const action = optionalString(args["action"]) ?? "list";
|
|
7028
|
+
if (action !== "list" && action !== "forget" && action !== "remember") return {
|
|
7029
|
+
error: "invalid-action",
|
|
7030
|
+
message: "`action` accepts exactly \"list\", \"forget\" or \"remember\""
|
|
7031
|
+
};
|
|
6585
7032
|
const selector = optionalString(args["device"]);
|
|
7033
|
+
if (action === "list") {
|
|
7034
|
+
const quarantined = this.#quarantineVerdict();
|
|
7035
|
+
if (quarantined !== void 0) return quarantined;
|
|
7036
|
+
if (selector === void 0) {
|
|
7037
|
+
const records = this.#deps.store.list();
|
|
7038
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
|
|
7039
|
+
}
|
|
7040
|
+
const selected = this.#selectRecords(selector);
|
|
7041
|
+
if (!Array.isArray(selected)) return selected;
|
|
7042
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
|
|
7043
|
+
}
|
|
7044
|
+
if (selector === void 0) return {
|
|
7045
|
+
error: "device-required",
|
|
7046
|
+
message: "`action` \"forget\"/\"remember\" needs `device`: a selector that names exactly one device"
|
|
7047
|
+
};
|
|
7048
|
+
const hosts = hostListArgument(args["hosts"]);
|
|
7049
|
+
if (!Array.isArray(hosts)) return hosts;
|
|
6586
7050
|
const quarantined = this.#quarantineVerdict();
|
|
6587
7051
|
if (quarantined !== void 0) return quarantined;
|
|
6588
|
-
if (selector === void 0) {
|
|
6589
|
-
const records = this.#deps.store.list();
|
|
6590
|
-
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
|
|
6591
|
-
}
|
|
6592
7052
|
const selected = this.#selectRecords(selector);
|
|
6593
7053
|
if (!Array.isArray(selected)) return selected;
|
|
6594
|
-
|
|
7054
|
+
const record = selected[0];
|
|
7055
|
+
let removed = [];
|
|
7056
|
+
let marked = [];
|
|
7057
|
+
let restored = [];
|
|
7058
|
+
try {
|
|
7059
|
+
if (action === "forget") ({removed, marked} = this.#deps.store.forgetEndpoints(record.deviceId, hosts));
|
|
7060
|
+
else ({restored} = this.#deps.store.rememberEndpoints(record.deviceId, hosts));
|
|
7061
|
+
} catch (error) {
|
|
7062
|
+
if (error instanceof LastCandidateHostError) return {
|
|
7063
|
+
error: "last-address",
|
|
7064
|
+
device: record.deviceId,
|
|
7065
|
+
message: error.message
|
|
7066
|
+
};
|
|
7067
|
+
throw error;
|
|
7068
|
+
}
|
|
7069
|
+
if (removed.length > 0 || marked.length > 0 || restored.length > 0) await this.#deps.evict(record.deviceId);
|
|
7070
|
+
const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId);
|
|
7071
|
+
if (fresh === void 0) return this.#notFoundVerdict(selector);
|
|
7072
|
+
const device = await this.#deviceView(fresh);
|
|
7073
|
+
return action === "forget" ? {
|
|
7074
|
+
device,
|
|
7075
|
+
removed,
|
|
7076
|
+
marked
|
|
7077
|
+
} : {
|
|
7078
|
+
device,
|
|
7079
|
+
restored
|
|
7080
|
+
};
|
|
6595
7081
|
}
|
|
6596
7082
|
});
|
|
6597
7083
|
}
|
|
@@ -6645,7 +7131,8 @@ var CherryToolEngine = class {
|
|
|
6645
7131
|
state,
|
|
6646
7132
|
enabled: record.enabled,
|
|
6647
7133
|
hosts: record.hosts,
|
|
6648
|
-
port: record.port
|
|
7134
|
+
port: record.port,
|
|
7135
|
+
forgottenHosts: record.forgottenHosts ?? []
|
|
6649
7136
|
};
|
|
6650
7137
|
}
|
|
6651
7138
|
#agentsTool() {
|
|
@@ -10900,7 +11387,12 @@ function parsePatch(body) {
|
|
|
10900
11387
|
if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
|
|
10901
11388
|
return normalizeHost(entry);
|
|
10902
11389
|
});
|
|
10903
|
-
|
|
11390
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11391
|
+
for (const host of patch.hosts) {
|
|
11392
|
+
const key = host.toLowerCase();
|
|
11393
|
+
if (seen.has(key)) throw new PatchError("hosts must not contain duplicates");
|
|
11394
|
+
seen.add(key);
|
|
11395
|
+
}
|
|
10904
11396
|
}
|
|
10905
11397
|
const port = body["port"];
|
|
10906
11398
|
if (port !== void 0) {
|
|
@@ -11256,6 +11748,23 @@ async function handleRequest(req, res, route) {
|
|
|
11256
11748
|
writeError(res, 405, "method-not-allowed", "DELETE or PATCH only");
|
|
11257
11749
|
return;
|
|
11258
11750
|
}
|
|
11751
|
+
if (first === "devices" && match.segments.length === 3 && second !== void 0) {
|
|
11752
|
+
const deviceId = decodeSegment(second);
|
|
11753
|
+
if (deviceId === void 0) {
|
|
11754
|
+
writeError(res, 400, "invalid-request", "device id is not valid URL encoding");
|
|
11755
|
+
return;
|
|
11756
|
+
}
|
|
11757
|
+
const action = match.segments[2];
|
|
11758
|
+
if (action !== "forget" && action !== "remember") {
|
|
11759
|
+
writeError(res, 404, "not-found", "unknown route");
|
|
11760
|
+
return;
|
|
11761
|
+
}
|
|
11762
|
+
if (method !== "POST") {
|
|
11763
|
+
writeError(res, 405, "method-not-allowed", "POST only");
|
|
11764
|
+
return;
|
|
11765
|
+
}
|
|
11766
|
+
return await changeEndpoints(req, res, route, deviceId, action);
|
|
11767
|
+
}
|
|
11259
11768
|
if (first === "pair" && match.segments.length === 1) {
|
|
11260
11769
|
if (method !== "POST") {
|
|
11261
11770
|
writeError(res, 405, "method-not-allowed", "POST only");
|
|
@@ -11293,7 +11802,18 @@ async function handleRequest(req, res, route) {
|
|
|
11293
11802
|
}
|
|
11294
11803
|
}
|
|
11295
11804
|
function listDevices(res, route) {
|
|
11296
|
-
writeJson(res, 200, route.deps.store.list().map((device) => (
|
|
11805
|
+
writeJson(res, 200, route.deps.store.list().map((device) => deviceSummary(route, device)));
|
|
11806
|
+
}
|
|
11807
|
+
/**
|
|
11808
|
+
* One device as the browser sees it: the public archive projection, never a
|
|
11809
|
+
* credential.
|
|
11810
|
+
*
|
|
11811
|
+
* ONE builder, because FOUR faces answer this shape — `GET /devices`, the
|
|
11812
|
+
* `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
|
|
11813
|
+
* per face is how a field ends up on one and not the others.
|
|
11814
|
+
*/
|
|
11815
|
+
function deviceSummary(route, device) {
|
|
11816
|
+
return {
|
|
11297
11817
|
deviceId: device.deviceId,
|
|
11298
11818
|
displayName: device.displayName,
|
|
11299
11819
|
state: projectState(device, route.deps.connect),
|
|
@@ -11301,8 +11821,9 @@ function listDevices(res, route) {
|
|
|
11301
11821
|
hosts: [...device.hosts],
|
|
11302
11822
|
port: device.port,
|
|
11303
11823
|
security: { ...device.security },
|
|
11304
|
-
ports: { ...device.ports }
|
|
11305
|
-
|
|
11824
|
+
ports: { ...device.ports },
|
|
11825
|
+
forgottenHosts: [...device.forgottenHosts ?? []]
|
|
11826
|
+
};
|
|
11306
11827
|
}
|
|
11307
11828
|
/**
|
|
11308
11829
|
* The `GET /diagnostics` body (spec §5).
|
|
@@ -11530,8 +12051,10 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
11530
12051
|
const previousSecurity = existing.security;
|
|
11531
12052
|
const previousPorts = existing.ports;
|
|
11532
12053
|
const nextPorts = patch.port !== void 0 && patch.port !== previousPort ? portsFor(patch.hosts ?? previousHosts, {}, patch.port) : patch.hosts === void 0 ? previousPorts : portsFor(patch.hosts, previousPorts, previousPort);
|
|
12054
|
+
const nextForgotten = patch.hosts === void 0 || existing.forgottenHosts === void 0 ? void 0 : existing.forgottenHosts.filter((host) => !patch.hosts.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()));
|
|
11533
12055
|
const next = {
|
|
11534
12056
|
...existing,
|
|
12057
|
+
...nextForgotten === void 0 ? {} : { forgottenHosts: nextForgotten },
|
|
11535
12058
|
...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
|
|
11536
12059
|
...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
|
|
11537
12060
|
...patch.hosts === void 0 ? {} : {
|
|
@@ -11557,21 +12080,12 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
11557
12080
|
} catch (error) {
|
|
11558
12081
|
console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error);
|
|
11559
12082
|
}
|
|
11560
|
-
const
|
|
12083
|
+
const summary = deviceSummary(route, next);
|
|
11561
12084
|
if (patch.enabled !== void 0 && patch.enabled !== previousEnabled) route.hub.publish("device-state", {
|
|
11562
12085
|
deviceId,
|
|
11563
|
-
state
|
|
11564
|
-
});
|
|
11565
|
-
writeJson(res, 200, {
|
|
11566
|
-
deviceId: next.deviceId,
|
|
11567
|
-
displayName: next.displayName,
|
|
11568
|
-
state,
|
|
11569
|
-
enabled: next.enabled,
|
|
11570
|
-
hosts: [...next.hosts],
|
|
11571
|
-
port: next.port,
|
|
11572
|
-
security: { ...next.security },
|
|
11573
|
-
ports: { ...next.ports }
|
|
12086
|
+
state: summary.state
|
|
11574
12087
|
});
|
|
12088
|
+
writeJson(res, 200, summary);
|
|
11575
12089
|
}
|
|
11576
12090
|
function warnConnect(deviceId) {
|
|
11577
12091
|
return (error) => {
|
|
@@ -11583,14 +12097,128 @@ function sameHosts(a, b) {
|
|
|
11583
12097
|
return a.length === b.length && a.every((host, index) => host === b[index]);
|
|
11584
12098
|
}
|
|
11585
12099
|
/**
|
|
12100
|
+
* `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
|
|
12101
|
+
* settings page's door onto #185's endpoint maintenance, which until now only
|
|
12102
|
+
* the `cherry_devices` tool could open.
|
|
12103
|
+
*
|
|
12104
|
+
* One action per request, and the action IS the request: there is no composite
|
|
12105
|
+
* "patch the record AND forget a host" body whose ordering would have to be
|
|
12106
|
+
* defined. Every judgement is the store's — the last-candidate refusal, the
|
|
12107
|
+
* case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
|
|
12108
|
+
* the two doors share ONE set of STORE rules; this route only maps the verdicts
|
|
12109
|
+
* onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
|
|
12110
|
+
* differ on purpose — see `parseEndpointHosts`.)
|
|
12111
|
+
*
|
|
12112
|
+
* EVICTION on a change, exactly like the tool's own arm and a PATCH's address
|
|
12113
|
+
* maintenance, and for a DIFFERENT reason on each action — which is why both
|
|
12114
|
+
* are stated here rather than waved through as "parity":
|
|
12115
|
+
*
|
|
12116
|
+
* - FORGET: a pooled connection CAPTURES its record at construction, so the
|
|
12117
|
+
* socket built before this write would keep dialing the host we just forgot.
|
|
12118
|
+
* (The mark itself is honoured by the store's learn merge, so a reconnect is
|
|
12119
|
+
* about the DIAL LIST, not about suppressing the address.)
|
|
12120
|
+
* - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
|
|
12121
|
+
* stored record. Without the eviction a desktop that stays connected would
|
|
12122
|
+
* not re-handshake for as long as the link holds, so the restored address
|
|
12123
|
+
* would come back only after some later flap — the button would look inert.
|
|
12124
|
+
*
|
|
12125
|
+
* The cost is real and inherited from the tool's arm: an eviction disposes the
|
|
12126
|
+
* subscription layer and closes a healthy socket, so a write can drop an
|
|
12127
|
+
* in-flight command's link. One operator click now reaches that churn; that is
|
|
12128
|
+
* the price of "the archive is the truth" for a live connection.
|
|
12129
|
+
*/
|
|
12130
|
+
async function changeEndpoints(req, res, route, deviceId, action) {
|
|
12131
|
+
const existing = route.deps.store.list().find((device) => device.deviceId === deviceId);
|
|
12132
|
+
if (existing === void 0) {
|
|
12133
|
+
writeError(res, 404, "not-found", `no device ${deviceId}`);
|
|
12134
|
+
return;
|
|
12135
|
+
}
|
|
12136
|
+
let hosts;
|
|
12137
|
+
try {
|
|
12138
|
+
hosts = parseEndpointHosts(await readJsonBody(req));
|
|
12139
|
+
} catch (error) {
|
|
12140
|
+
writeError(res, 400, "invalid-request", error instanceof Error ? error.message : String(error));
|
|
12141
|
+
return;
|
|
12142
|
+
}
|
|
12143
|
+
try {
|
|
12144
|
+
if (action === "forget") {
|
|
12145
|
+
const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts);
|
|
12146
|
+
if (removed.length > 0 || marked.length > 0) evict(route, deviceId);
|
|
12147
|
+
} else {
|
|
12148
|
+
const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts);
|
|
12149
|
+
if (restored.length > 0) evict(route, deviceId);
|
|
12150
|
+
}
|
|
12151
|
+
} catch (error) {
|
|
12152
|
+
if (writeQuarantineRefusal(res, route, error)) return;
|
|
12153
|
+
if (error instanceof LastCandidateHostError) {
|
|
12154
|
+
writeError(res, 400, "last-address", error.message);
|
|
12155
|
+
return;
|
|
12156
|
+
}
|
|
12157
|
+
writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
|
|
12158
|
+
return;
|
|
12159
|
+
}
|
|
12160
|
+
writeJson(res, 200, deviceSummary(route, route.deps.store.list().find((device) => device.deviceId === deviceId) ?? existing));
|
|
12161
|
+
}
|
|
12162
|
+
/**
|
|
12163
|
+
* The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
|
|
12164
|
+
* normalized the same way the PATCH normalizes an address.
|
|
12165
|
+
*
|
|
12166
|
+
* The normalization is not cosmetic: an IPv6 literal is stored bare but a
|
|
12167
|
+
* browser copy-pastes it bracketed, and the store matches by host spelling —
|
|
12168
|
+
* so `[::1]` would be treated as an address the record does not dial and merely
|
|
12169
|
+
* marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
|
|
12170
|
+
* spelling (same `normalizeHost`), and deliberately NOT for its duplicate
|
|
12171
|
+
* refusal: a repeated host in an action body names one host twice, which the
|
|
12172
|
+
* store's own sets collapse, while the PATCH's duplicates would build an
|
|
12173
|
+
* ambiguous record (`hosts`, `security` and `ports` are keyed by host).
|
|
12174
|
+
*/
|
|
12175
|
+
function parseEndpointHosts(body) {
|
|
12176
|
+
const hosts = body["hosts"];
|
|
12177
|
+
if (!Array.isArray(hosts) || hosts.length === 0) throw new PatchError("hosts must be a non-empty array");
|
|
12178
|
+
return hosts.map((entry) => {
|
|
12179
|
+
if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
|
|
12180
|
+
return normalizeHost(entry);
|
|
12181
|
+
});
|
|
12182
|
+
}
|
|
12183
|
+
/**
|
|
11586
12184
|
* The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
|
|
11587
12185
|
* keeps the scheme it was dialed with, a host the list just gained defaults to
|
|
11588
12186
|
* `ws`, and hosts the list dropped leave no entry behind. An edit is an
|
|
11589
12187
|
* address edit, not a scheme edit — re-typing the same host must not silently
|
|
11590
12188
|
* demote a TLS-proxied desktop to a plain dial.
|
|
12189
|
+
*
|
|
12190
|
+
* "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
|
|
12191
|
+
* learn merge, the forget/remember actions and the mark clearing already use:
|
|
12192
|
+
* `NAS.example` and `nas.example` are one address, so a PATCH that re-types
|
|
12193
|
+
* the case keeps the remembered scheme instead of resetting it to `ws`.
|
|
12194
|
+
* `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
|
|
12195
|
+
* address edit happened at all, and a new spelling IS a change to persist.
|
|
11591
12196
|
*/
|
|
11592
12197
|
function securityFor(hosts, previous) {
|
|
11593
|
-
|
|
12198
|
+
const remembered = indexByHost(previous);
|
|
12199
|
+
return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? "ws"]));
|
|
12200
|
+
}
|
|
12201
|
+
/**
|
|
12202
|
+
* A case-insensitive index over a per-host map (issue #189).
|
|
12203
|
+
*
|
|
12204
|
+
* FIRST spelling wins on a collision. A hand-edited archive CAN hold two
|
|
12205
|
+
* spellings of one host (the store does not reject it, and this face does not
|
|
12206
|
+
* touch `previous`), so callers look their exact spelling up FIRST and consult
|
|
12207
|
+
* this index only as the fallback: a surviving spelling therefore keeps its
|
|
12208
|
+
* OWN entry, and the collision rule only decides what a THIRD spelling
|
|
12209
|
+
* inherits.
|
|
12210
|
+
*/
|
|
12211
|
+
function indexByHost(previous) {
|
|
12212
|
+
const index = /* @__PURE__ */ new Map();
|
|
12213
|
+
for (const [host, value] of Object.entries(previous)) {
|
|
12214
|
+
const key = host.toLowerCase();
|
|
12215
|
+
if (!index.has(key)) index.set(key, value);
|
|
12216
|
+
}
|
|
12217
|
+
return index;
|
|
12218
|
+
}
|
|
12219
|
+
/** The exact spelling's entry, else the case-insensitive one, else nothing. */
|
|
12220
|
+
function exactOrRemembered(previous, remembered, host) {
|
|
12221
|
+
return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase());
|
|
11594
12222
|
}
|
|
11595
12223
|
/**
|
|
11596
12224
|
* The port map for a host list (issue #135): each named host keeps the port it
|
|
@@ -11599,11 +12227,13 @@ function securityFor(hosts, previous) {
|
|
|
11599
12227
|
*
|
|
11600
12228
|
* `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
|
|
11601
12229
|
* `port` patch): there is no remembered entry to keep, by design — one port
|
|
11602
|
-
* field on this face must move the whole list. Same `Object.hasOwn
|
|
11603
|
-
*
|
|
12230
|
+
* field on this face must move the whole list. Same `Object.hasOwn`/
|
|
12231
|
+
* case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
|
|
12232
|
+
* keeps its own port, and an inherited function is never a remembered port.
|
|
11604
12233
|
*/
|
|
11605
12234
|
function portsFor(hosts, previous, fallback) {
|
|
11606
|
-
|
|
12235
|
+
const remembered = indexByHost(previous);
|
|
12236
|
+
return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]));
|
|
11607
12237
|
}
|
|
11608
12238
|
/** Order-insensitive comparison for two scheme maps. */
|
|
11609
12239
|
function sameSecurity(a, b) {
|
|
@@ -11933,7 +12563,10 @@ function connectionFor(deviceId) {
|
|
|
11933
12563
|
identity: requireIdentity(),
|
|
11934
12564
|
record,
|
|
11935
12565
|
rateBudgetPerSecond: state.rateBudgetPerSecond,
|
|
11936
|
-
...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls }
|
|
12566
|
+
...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
|
|
12567
|
+
onAdvertisedEndpoints: (endpoints) => {
|
|
12568
|
+
state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity);
|
|
12569
|
+
}
|
|
11937
12570
|
});
|
|
11938
12571
|
state.connectionsBuilt += 1;
|
|
11939
12572
|
connection.onState((deviceState) => {
|