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/src/httpApi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
2
|
+
import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
3
3
|
import type { DeviceRecord } from './deviceStore.js'
|
|
4
4
|
import type { DeviceStore } from './deviceStore.js'
|
|
5
5
|
import type { DeviceState, EffectiveLimits } from './connection.js'
|
|
@@ -119,6 +119,14 @@ export interface DeviceSummary {
|
|
|
119
119
|
security: Record<string, 'ws' | 'wss'>
|
|
120
120
|
/** The port each host is dialed with (issue #135); see `DeviceRecord`. */
|
|
121
121
|
ports: Record<string, number>
|
|
122
|
+
/**
|
|
123
|
+
* The addresses the operator has forgotten (issue #185): not dialed, and not
|
|
124
|
+
* re-learned until `cherry_devices` remembers them (or a PATCH puts one back
|
|
125
|
+
* into `hosts`, which clears its mark). Reported beside the address maps for
|
|
126
|
+
* the same reason they are: it is archive state an operator edits, not a
|
|
127
|
+
* credential.
|
|
128
|
+
*/
|
|
129
|
+
forgottenHosts: string[]
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
/** The `device-state` SSE payload. */
|
|
@@ -592,11 +600,17 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
|
|
|
592
600
|
if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
|
|
593
601
|
return normalizeHost(entry)
|
|
594
602
|
})
|
|
595
|
-
// Checked AFTER normalization: `[::1]` and `::1` collapse to one host
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
603
|
+
// Checked AFTER normalization: `[::1]` and `::1` collapse to one host, and
|
|
604
|
+
// host comparison is CASE-INSENSITIVE everywhere else on this face (the
|
|
605
|
+
// learn merge, the forget/remember matching, the mark clearing), so
|
|
606
|
+
// `NAS.example` and `nas.example` are one address too. The scheme/port maps
|
|
607
|
+
// are keyed by host, so a duplicate has no single entry and silently
|
|
608
|
+
// last-wins — an ambiguous record, not an edit.
|
|
609
|
+
const seen = new Set<string>()
|
|
610
|
+
for (const host of patch.hosts) {
|
|
611
|
+
const key = host.toLowerCase()
|
|
612
|
+
if (seen.has(key)) throw new PatchError('hosts must not contain duplicates')
|
|
613
|
+
seen.add(key)
|
|
600
614
|
}
|
|
601
615
|
}
|
|
602
616
|
|
|
@@ -1017,6 +1031,28 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
|
|
|
1017
1031
|
return
|
|
1018
1032
|
}
|
|
1019
1033
|
|
|
1034
|
+
// The two endpoint actions (issue #204) sit UNDER the device resource
|
|
1035
|
+
// because that is what they act on: they are the page's door onto #185's
|
|
1036
|
+
// forget/remember, one action per request, so a "patch AND forget" body
|
|
1037
|
+
// with a contradictory ordering never exists.
|
|
1038
|
+
if (first === 'devices' && match.segments.length === 3 && second !== undefined) {
|
|
1039
|
+
const deviceId = decodeSegment(second)
|
|
1040
|
+
if (deviceId === undefined) {
|
|
1041
|
+
writeError(res, 400, 'invalid-request', 'device id is not valid URL encoding')
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
const action = match.segments[2]
|
|
1045
|
+
if (action !== 'forget' && action !== 'remember') {
|
|
1046
|
+
writeError(res, 404, 'not-found', 'unknown route')
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
if (method !== 'POST') {
|
|
1050
|
+
writeError(res, 405, 'method-not-allowed', 'POST only')
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
return await changeEndpoints(req, res, route, deviceId, action)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1020
1056
|
if (first === 'pair' && match.segments.length === 1) {
|
|
1021
1057
|
if (method !== 'POST') {
|
|
1022
1058
|
writeError(res, 405, 'method-not-allowed', 'POST only')
|
|
@@ -1071,7 +1107,23 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
|
|
|
1071
1107
|
}
|
|
1072
1108
|
|
|
1073
1109
|
function listDevices(res: HttpResponseLike, route: RouteContext): void {
|
|
1074
|
-
|
|
1110
|
+
writeJson(
|
|
1111
|
+
res,
|
|
1112
|
+
200,
|
|
1113
|
+
route.deps.store.list().map((device) => deviceSummary(route, device)),
|
|
1114
|
+
)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* One device as the browser sees it: the public archive projection, never a
|
|
1119
|
+
* credential.
|
|
1120
|
+
*
|
|
1121
|
+
* ONE builder, because FOUR faces answer this shape — `GET /devices`, the
|
|
1122
|
+
* `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
|
|
1123
|
+
* per face is how a field ends up on one and not the others.
|
|
1124
|
+
*/
|
|
1125
|
+
function deviceSummary(route: RouteContext, device: DeviceRecord): DeviceSummary {
|
|
1126
|
+
return {
|
|
1075
1127
|
deviceId: device.deviceId,
|
|
1076
1128
|
displayName: device.displayName,
|
|
1077
1129
|
state: projectState(device, route.deps.connect),
|
|
@@ -1080,8 +1132,10 @@ function listDevices(res: HttpResponseLike, route: RouteContext): void {
|
|
|
1080
1132
|
port: device.port,
|
|
1081
1133
|
security: { ...device.security },
|
|
1082
1134
|
ports: { ...device.ports },
|
|
1083
|
-
|
|
1084
|
-
|
|
1135
|
+
// An empty list is the honest "none", not an absent key: the page renders
|
|
1136
|
+
// the forgotten row only when it is non-empty.
|
|
1137
|
+
forgottenHosts: [...(device.forgottenHosts ?? [])],
|
|
1138
|
+
}
|
|
1085
1139
|
}
|
|
1086
1140
|
|
|
1087
1141
|
/**
|
|
@@ -1407,8 +1461,20 @@ async function patchDevice(
|
|
|
1407
1461
|
: patch.hosts === undefined
|
|
1408
1462
|
? previousPorts
|
|
1409
1463
|
: portsFor(patch.hosts, previousPorts, previousPort)
|
|
1464
|
+
// A host the operator puts BACK into `hosts` is dialed again, so its forget
|
|
1465
|
+
// mark must go: `hosts` and `forgottenHosts` are disjoint by contract, and an
|
|
1466
|
+
// explicit list is intent to dial. A PATCH that does not touch `hosts`
|
|
1467
|
+
// (displayName/enabled/port) leaves every mark alone. Case-insensitive, the
|
|
1468
|
+
// same comparison the learn merge uses.
|
|
1469
|
+
const nextForgotten =
|
|
1470
|
+
patch.hosts === undefined || existing.forgottenHosts === undefined
|
|
1471
|
+
? undefined
|
|
1472
|
+
: existing.forgottenHosts.filter(
|
|
1473
|
+
(host) => !patch.hosts!.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()),
|
|
1474
|
+
)
|
|
1410
1475
|
const next: DeviceRecord = {
|
|
1411
1476
|
...existing,
|
|
1477
|
+
...(nextForgotten === undefined ? {} : { forgottenHosts: nextForgotten }),
|
|
1412
1478
|
...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
|
|
1413
1479
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
1414
1480
|
// hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
|
|
@@ -1473,22 +1539,14 @@ async function patchDevice(
|
|
|
1473
1539
|
|
|
1474
1540
|
// The response and the SSE frame always report the projection of the record
|
|
1475
1541
|
// we just wrote: a disabled device is `disabled` regardless of what its
|
|
1476
|
-
// connection last said, and an enabled one reports its live state.
|
|
1477
|
-
|
|
1542
|
+
// connection last said, and an enabled one reports its live state. ONE
|
|
1543
|
+
// projection feeds both, so the frame and the body cannot disagree.
|
|
1544
|
+
const summary = deviceSummary(route, next)
|
|
1478
1545
|
if (patch.enabled !== undefined && patch.enabled !== previousEnabled) {
|
|
1479
|
-
route.hub.publish('device-state', { deviceId, state })
|
|
1546
|
+
route.hub.publish('device-state', { deviceId, state: summary.state })
|
|
1480
1547
|
}
|
|
1481
1548
|
|
|
1482
|
-
writeJson(res, 200,
|
|
1483
|
-
deviceId: next.deviceId,
|
|
1484
|
-
displayName: next.displayName,
|
|
1485
|
-
state,
|
|
1486
|
-
enabled: next.enabled,
|
|
1487
|
-
hosts: [...next.hosts],
|
|
1488
|
-
port: next.port,
|
|
1489
|
-
security: { ...next.security },
|
|
1490
|
-
ports: { ...next.ports },
|
|
1491
|
-
})
|
|
1549
|
+
writeJson(res, 200, summary)
|
|
1492
1550
|
}
|
|
1493
1551
|
|
|
1494
1552
|
function warnConnect(deviceId: string): (error: unknown) => void {
|
|
@@ -1511,12 +1569,129 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
|
|
|
1511
1569
|
return a.length === b.length && a.every((host, index) => host === b[index])
|
|
1512
1570
|
}
|
|
1513
1571
|
|
|
1572
|
+
/**
|
|
1573
|
+
* `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
|
|
1574
|
+
* settings page's door onto #185's endpoint maintenance, which until now only
|
|
1575
|
+
* the `cherry_devices` tool could open.
|
|
1576
|
+
*
|
|
1577
|
+
* One action per request, and the action IS the request: there is no composite
|
|
1578
|
+
* "patch the record AND forget a host" body whose ordering would have to be
|
|
1579
|
+
* defined. Every judgement is the store's — the last-candidate refusal, the
|
|
1580
|
+
* case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
|
|
1581
|
+
* the two doors share ONE set of STORE rules; this route only maps the verdicts
|
|
1582
|
+
* onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
|
|
1583
|
+
* differ on purpose — see `parseEndpointHosts`.)
|
|
1584
|
+
*
|
|
1585
|
+
* EVICTION on a change, exactly like the tool's own arm and a PATCH's address
|
|
1586
|
+
* maintenance, and for a DIFFERENT reason on each action — which is why both
|
|
1587
|
+
* are stated here rather than waved through as "parity":
|
|
1588
|
+
*
|
|
1589
|
+
* - FORGET: a pooled connection CAPTURES its record at construction, so the
|
|
1590
|
+
* socket built before this write would keep dialing the host we just forgot.
|
|
1591
|
+
* (The mark itself is honoured by the store's learn merge, so a reconnect is
|
|
1592
|
+
* about the DIAL LIST, not about suppressing the address.)
|
|
1593
|
+
* - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
|
|
1594
|
+
* stored record. Without the eviction a desktop that stays connected would
|
|
1595
|
+
* not re-handshake for as long as the link holds, so the restored address
|
|
1596
|
+
* would come back only after some later flap — the button would look inert.
|
|
1597
|
+
*
|
|
1598
|
+
* The cost is real and inherited from the tool's arm: an eviction disposes the
|
|
1599
|
+
* subscription layer and closes a healthy socket, so a write can drop an
|
|
1600
|
+
* in-flight command's link. One operator click now reaches that churn; that is
|
|
1601
|
+
* the price of "the archive is the truth" for a live connection.
|
|
1602
|
+
*/
|
|
1603
|
+
async function changeEndpoints(
|
|
1604
|
+
req: HttpRequestLike,
|
|
1605
|
+
res: HttpResponseLike,
|
|
1606
|
+
route: RouteContext,
|
|
1607
|
+
deviceId: string,
|
|
1608
|
+
action: 'forget' | 'remember',
|
|
1609
|
+
): Promise<void> {
|
|
1610
|
+
const existing = route.deps.store.list().find((device) => device.deviceId === deviceId)
|
|
1611
|
+
if (existing === undefined) {
|
|
1612
|
+
writeError(res, 404, 'not-found', `no device ${deviceId}`)
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
let hosts: string[]
|
|
1617
|
+
try {
|
|
1618
|
+
hosts = parseEndpointHosts(await readJsonBody(req))
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
writeError(res, 400, 'invalid-request', error instanceof Error ? error.message : String(error))
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
try {
|
|
1625
|
+
if (action === 'forget') {
|
|
1626
|
+
const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts)
|
|
1627
|
+
if (removed.length > 0 || marked.length > 0) evict(route, deviceId)
|
|
1628
|
+
} else {
|
|
1629
|
+
const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts)
|
|
1630
|
+
if (restored.length > 0) evict(route, deviceId)
|
|
1631
|
+
}
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
// Quarantine first, and distinctly: it is not the request's fault (the 400s
|
|
1634
|
+
// below) and the browser cannot fix it — the archive is READ-ONLY until a
|
|
1635
|
+
// human moves the bad file aside. A store that was ALREADY quarantined
|
|
1636
|
+
// never reaches this arm: its view is empty, so the `list()` above answered
|
|
1637
|
+
// 404 before any write — the same precedence PATCH and DELETE have. This is
|
|
1638
|
+
// the defence for a store that begins refusing after that read, and it is
|
|
1639
|
+
// what keeps a damaged archive from being reported as a bad request.
|
|
1640
|
+
if (writeQuarantineRefusal(res, route, error)) return
|
|
1641
|
+
// The tool's own code for the same refusal, so the page can translate it by
|
|
1642
|
+
// code rather than by prose.
|
|
1643
|
+
if (error instanceof LastCandidateHostError) {
|
|
1644
|
+
writeError(res, 400, 'last-address', error.message)
|
|
1645
|
+
return
|
|
1646
|
+
}
|
|
1647
|
+
writeError(res, 400, 'invalid-device', error instanceof Error ? error.message : String(error))
|
|
1648
|
+
return
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// The projection of the record we just wrote, never the object we read before
|
|
1652
|
+
// the write: `REMOVED from a declined call` and `UNCHANGED by a no-op` are
|
|
1653
|
+
// both answered from the archive's current row.
|
|
1654
|
+
const fresh = route.deps.store.list().find((device) => device.deviceId === deviceId)
|
|
1655
|
+
writeJson(res, 200, deviceSummary(route, fresh ?? existing))
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
|
|
1660
|
+
* normalized the same way the PATCH normalizes an address.
|
|
1661
|
+
*
|
|
1662
|
+
* The normalization is not cosmetic: an IPv6 literal is stored bare but a
|
|
1663
|
+
* browser copy-pastes it bracketed, and the store matches by host spelling —
|
|
1664
|
+
* so `[::1]` would be treated as an address the record does not dial and merely
|
|
1665
|
+
* marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
|
|
1666
|
+
* spelling (same `normalizeHost`), and deliberately NOT for its duplicate
|
|
1667
|
+
* refusal: a repeated host in an action body names one host twice, which the
|
|
1668
|
+
* store's own sets collapse, while the PATCH's duplicates would build an
|
|
1669
|
+
* ambiguous record (`hosts`, `security` and `ports` are keyed by host).
|
|
1670
|
+
*/
|
|
1671
|
+
function parseEndpointHosts(body: Record<string, unknown>): string[] {
|
|
1672
|
+
const hosts = body['hosts']
|
|
1673
|
+
if (!Array.isArray(hosts) || hosts.length === 0) {
|
|
1674
|
+
throw new PatchError('hosts must be a non-empty array')
|
|
1675
|
+
}
|
|
1676
|
+
return hosts.map((entry) => {
|
|
1677
|
+
if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
|
|
1678
|
+
return normalizeHost(entry)
|
|
1679
|
+
})
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1514
1682
|
/**
|
|
1515
1683
|
* The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
|
|
1516
1684
|
* keeps the scheme it was dialed with, a host the list just gained defaults to
|
|
1517
1685
|
* `ws`, and hosts the list dropped leave no entry behind. An edit is an
|
|
1518
1686
|
* address edit, not a scheme edit — re-typing the same host must not silently
|
|
1519
1687
|
* demote a TLS-proxied desktop to a plain dial.
|
|
1688
|
+
*
|
|
1689
|
+
* "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
|
|
1690
|
+
* learn merge, the forget/remember actions and the mark clearing already use:
|
|
1691
|
+
* `NAS.example` and `nas.example` are one address, so a PATCH that re-types
|
|
1692
|
+
* the case keeps the remembered scheme instead of resetting it to `ws`.
|
|
1693
|
+
* `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
|
|
1694
|
+
* address edit happened at all, and a new spelling IS a change to persist.
|
|
1520
1695
|
*/
|
|
1521
1696
|
function securityFor(
|
|
1522
1697
|
hosts: readonly string[],
|
|
@@ -1524,12 +1699,42 @@ function securityFor(
|
|
|
1524
1699
|
): Record<string, 'ws' | 'wss'> {
|
|
1525
1700
|
// `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
|
|
1526
1701
|
// (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
|
|
1527
|
-
// Object.prototype and poison the map the store then rejects.
|
|
1702
|
+
// Object.prototype and poison the map the store then rejects. The index is
|
|
1703
|
+
// built from OWN entries only, so it is safe for the same reason.
|
|
1704
|
+
const remembered = indexByHost(previous)
|
|
1528
1705
|
return Object.fromEntries(
|
|
1529
|
-
hosts.map((host) => [host,
|
|
1706
|
+
hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? 'ws']),
|
|
1530
1707
|
)
|
|
1531
1708
|
}
|
|
1532
1709
|
|
|
1710
|
+
/**
|
|
1711
|
+
* A case-insensitive index over a per-host map (issue #189).
|
|
1712
|
+
*
|
|
1713
|
+
* FIRST spelling wins on a collision. A hand-edited archive CAN hold two
|
|
1714
|
+
* spellings of one host (the store does not reject it, and this face does not
|
|
1715
|
+
* touch `previous`), so callers look their exact spelling up FIRST and consult
|
|
1716
|
+
* this index only as the fallback: a surviving spelling therefore keeps its
|
|
1717
|
+
* OWN entry, and the collision rule only decides what a THIRD spelling
|
|
1718
|
+
* inherits.
|
|
1719
|
+
*/
|
|
1720
|
+
function indexByHost<T>(previous: Record<string, T>): Map<string, T> {
|
|
1721
|
+
const index = new Map<string, T>()
|
|
1722
|
+
for (const [host, value] of Object.entries(previous)) {
|
|
1723
|
+
const key = host.toLowerCase()
|
|
1724
|
+
if (!index.has(key)) index.set(key, value)
|
|
1725
|
+
}
|
|
1726
|
+
return index
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** The exact spelling's entry, else the case-insensitive one, else nothing. */
|
|
1730
|
+
function exactOrRemembered<T>(
|
|
1731
|
+
previous: Record<string, T>,
|
|
1732
|
+
remembered: Map<string, T>,
|
|
1733
|
+
host: string,
|
|
1734
|
+
): T | undefined {
|
|
1735
|
+
return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase())
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1533
1738
|
/**
|
|
1534
1739
|
* The port map for a host list (issue #135): each named host keeps the port it
|
|
1535
1740
|
* was dialed with, a host the list just gained defaults to `fallback` (the
|
|
@@ -1537,16 +1742,18 @@ function securityFor(
|
|
|
1537
1742
|
*
|
|
1538
1743
|
* `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
|
|
1539
1744
|
* `port` patch): there is no remembered entry to keep, by design — one port
|
|
1540
|
-
* field on this face must move the whole list. Same `Object.hasOwn
|
|
1541
|
-
*
|
|
1745
|
+
* field on this face must move the whole list. Same `Object.hasOwn`/
|
|
1746
|
+
* case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
|
|
1747
|
+
* keeps its own port, and an inherited function is never a remembered port.
|
|
1542
1748
|
*/
|
|
1543
1749
|
function portsFor(
|
|
1544
1750
|
hosts: readonly string[],
|
|
1545
1751
|
previous: Record<string, number>,
|
|
1546
1752
|
fallback: number,
|
|
1547
1753
|
): Record<string, number> {
|
|
1754
|
+
const remembered = indexByHost(previous)
|
|
1548
1755
|
return Object.fromEntries(
|
|
1549
|
-
hosts.map((host) => [host,
|
|
1756
|
+
hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]),
|
|
1550
1757
|
)
|
|
1551
1758
|
}
|
|
1552
1759
|
|
package/src/index.ts
CHANGED
|
@@ -489,6 +489,19 @@ function connectionFor(deviceId: string): DeviceConnection {
|
|
|
489
489
|
// Spread-if-present, never `wsOptions: undefined`: the option is optional,
|
|
490
490
|
// and an absent key is the honest "no override" (Node's trust store).
|
|
491
491
|
...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
|
|
492
|
+
// The archive write-back (issue #143). The connection has already merged the
|
|
493
|
+
// addresses into its OWN record — this instance's next reconnect dials them
|
|
494
|
+
// — so the only job here is durability. It goes through
|
|
495
|
+
// `DeviceStore.learnEndpoints`, NOT `upsert(record)`: the store's record
|
|
496
|
+
// carries the token of the PAIRING while the connection's mirrors the live
|
|
497
|
+
// one, and an `upsert` of the connection's copy would put a
|
|
498
|
+
// connection-scoped credential into the archive.
|
|
499
|
+
onAdvertisedEndpoints: (endpoints) => {
|
|
500
|
+
// `record` is THIS connection's clone: its `desktopIdentity` is the pin the
|
|
501
|
+
// answer was validated against, so a re-pair under the same deviceId cannot
|
|
502
|
+
// receive an address learned from the previous pairing.
|
|
503
|
+
state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity)
|
|
504
|
+
},
|
|
492
505
|
})
|
|
493
506
|
state.connectionsBuilt += 1
|
|
494
507
|
connection.onState((deviceState: DeviceState) => {
|
package/src/tools.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { DeviceUnavailableError, RpcDeadlineExceededError } from './connection.js'
|
|
3
3
|
import type { DeviceConnection, DeviceState } from './connection.js'
|
|
4
4
|
import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
|
|
5
|
-
import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
5
|
+
import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
6
6
|
import {
|
|
7
7
|
DEVICE_SETTLE_WAIT_MS,
|
|
8
8
|
INTERACTIONS_LIST_PAGE_CLAMP,
|
|
@@ -111,6 +111,13 @@ export interface CherryDeviceView {
|
|
|
111
111
|
enabled: boolean
|
|
112
112
|
hosts: string[]
|
|
113
113
|
port: number
|
|
114
|
+
/**
|
|
115
|
+
* The addresses the operator has FORGOTTEN (issue #185): not dialed, and not
|
|
116
|
+
* re-learned from the desktop's advertisements until they are remembered.
|
|
117
|
+
* Always present on a view (empty when nothing was ever forgotten) so a
|
|
118
|
+
* caller never has to distinguish "absent" from "none".
|
|
119
|
+
*/
|
|
120
|
+
forgottenHosts: string[]
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
/** The shared `{device, state}` verdict (spec §6: 设备离线). */
|
|
@@ -1216,8 +1223,11 @@ export interface CherryToolsDeps {
|
|
|
1216
1223
|
* `DeviceConnection` captures its record at construction, so a pooled
|
|
1217
1224
|
* instance can never observe an edited address or a flipped `enabled` flag.
|
|
1218
1225
|
*
|
|
1219
|
-
* `cherry_unpair`
|
|
1220
|
-
*
|
|
1226
|
+
* `cherry_unpair` evicts (drop + close) and then removes the archive entry,
|
|
1227
|
+
* so no live socket outlives its record, and `cherry_devices`' `forget` /
|
|
1228
|
+
* `remember` actions evict ON A CHANGE for the same reason: a pooled
|
|
1229
|
+
* connection captured the pre-write record, so a forget that only rewrote the
|
|
1230
|
+
* archive would keep dialing the address (and locally re-learning it).
|
|
1221
1231
|
*/
|
|
1222
1232
|
evict(deviceId: string): void | Promise<void>
|
|
1223
1233
|
/**
|
|
@@ -1642,6 +1652,59 @@ function optionalString(value: unknown): string | undefined {
|
|
|
1642
1652
|
return typeof value === 'string' && value !== '' ? value : undefined
|
|
1643
1653
|
}
|
|
1644
1654
|
|
|
1655
|
+
/**
|
|
1656
|
+
* The `hosts` argument of `cherry_devices`' write actions (issue #185): a
|
|
1657
|
+
* non-empty list of non-empty host names, with no case-variant duplicate.
|
|
1658
|
+
*
|
|
1659
|
+
* Each rule is a state the store would have to interpret: an empty name can
|
|
1660
|
+
* never match a dialed host, and two spellings of one host would make "is this
|
|
1661
|
+
* address forgotten?" depend on which entry a comparison hit first. Values are
|
|
1662
|
+
* TRIMMED here (the model-facing surface is where a stray space is caught);
|
|
1663
|
+
* the archive then stores the spelling it already had for a dialed host.
|
|
1664
|
+
*/
|
|
1665
|
+
function hostListArgument(value: unknown): string[] | Record<string, unknown> {
|
|
1666
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1667
|
+
return { error: 'invalid-hosts', message: '`hosts` must be a non-empty array of host names' }
|
|
1668
|
+
}
|
|
1669
|
+
const hosts: string[] = []
|
|
1670
|
+
const seen = new Set<string>()
|
|
1671
|
+
for (const entry of value) {
|
|
1672
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
1673
|
+
return { error: 'invalid-hosts', message: '`hosts` entries must be non-empty strings' }
|
|
1674
|
+
}
|
|
1675
|
+
let host = entry.trim()
|
|
1676
|
+
// The archive stores BARE hosts, and the PATCH path unwraps a bracketed
|
|
1677
|
+
// IPv6 literal (`normalizeHost`). This face must agree: a mark for
|
|
1678
|
+
// `[fd00::1]` would never match the `fd00::1` a dial or an advertisement
|
|
1679
|
+
// carries, so it would be a mark with no effect. The ceiling mirrors the
|
|
1680
|
+
// PATCH path's `HOST_MAX`.
|
|
1681
|
+
if (host.startsWith('[') && host.endsWith(']')) {
|
|
1682
|
+
try {
|
|
1683
|
+
new URL(`http://${host}/`)
|
|
1684
|
+
} catch {
|
|
1685
|
+
return {
|
|
1686
|
+
error: 'invalid-hosts',
|
|
1687
|
+
message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`,
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
host = host.slice(1, -1)
|
|
1691
|
+
}
|
|
1692
|
+
if (host.length > 256) {
|
|
1693
|
+
return { error: 'invalid-hosts', message: '`hosts` entry exceeds 256 characters' }
|
|
1694
|
+
}
|
|
1695
|
+
const key = host.toLowerCase()
|
|
1696
|
+
if (seen.has(key)) {
|
|
1697
|
+
return {
|
|
1698
|
+
error: 'invalid-hosts',
|
|
1699
|
+
message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`,
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
seen.add(key)
|
|
1703
|
+
hosts.push(host)
|
|
1704
|
+
}
|
|
1705
|
+
return hosts
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1645
1708
|
/**
|
|
1646
1709
|
* The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
|
|
1647
1710
|
* in the exact shape Cherry Studio answers it: a `rejected` receipt whose
|
|
@@ -2729,25 +2792,100 @@ export class CherryToolEngine {
|
|
|
2729
2792
|
description:
|
|
2730
2793
|
'List every paired Cherry Studio desktop with its connection state ' +
|
|
2731
2794
|
'(connected/connecting/offline/disabled/identity-mismatch). An optional `device` ' +
|
|
2732
|
-
'narrows the list to one device (display name prefix or deviceId).'
|
|
2795
|
+
'narrows the list to one device (display name prefix or deviceId). ' +
|
|
2796
|
+
'`action: "forget"` removes the named `hosts` from that device\'s dial candidates AND ' +
|
|
2797
|
+
'stops the desktop\'s advertisements from adding them back; `action: "remember"` lifts ' +
|
|
2798
|
+
'that mark, so the next handshake may learn them again. Both write actions require ' +
|
|
2799
|
+
'`device` and `hosts`.',
|
|
2733
2800
|
parameters: {
|
|
2734
2801
|
type: 'object',
|
|
2735
2802
|
properties: {
|
|
2736
2803
|
device: { type: 'string', description: 'Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name.' },
|
|
2804
|
+
action: {
|
|
2805
|
+
type: 'string',
|
|
2806
|
+
enum: ['list', 'forget', 'remember'],
|
|
2807
|
+
description:
|
|
2808
|
+
'What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE ' +
|
|
2809
|
+
"device's forgotten addresses and require `device` and `hosts`.",
|
|
2810
|
+
},
|
|
2811
|
+
hosts: {
|
|
2812
|
+
type: 'array',
|
|
2813
|
+
items: { type: 'string' },
|
|
2814
|
+
description:
|
|
2815
|
+
'For `forget`/`remember`: the host names to act on, matched case-insensitively. ' +
|
|
2816
|
+
'`forget` refuses to remove the last remaining address.',
|
|
2817
|
+
},
|
|
2737
2818
|
},
|
|
2738
2819
|
additionalProperties: false,
|
|
2739
2820
|
},
|
|
2740
2821
|
execute: async (args) => {
|
|
2822
|
+
const action = optionalString(args['action']) ?? 'list'
|
|
2823
|
+
if (action !== 'list' && action !== 'forget' && action !== 'remember') {
|
|
2824
|
+
return {
|
|
2825
|
+
error: 'invalid-action',
|
|
2826
|
+
message: '`action` accepts exactly "list", "forget" or "remember"',
|
|
2827
|
+
} satisfies Record<string, unknown>
|
|
2828
|
+
}
|
|
2741
2829
|
const selector = optionalString(args['device'])
|
|
2742
|
-
|
|
2743
|
-
|
|
2830
|
+
if (action === 'list') {
|
|
2831
|
+
const quarantined = this.#quarantineVerdict()
|
|
2832
|
+
if (quarantined !== undefined) return quarantined
|
|
2833
|
+
if (selector === undefined) {
|
|
2834
|
+
const records = this.#deps.store.list()
|
|
2835
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
|
|
2836
|
+
}
|
|
2837
|
+
const selected = this.#selectRecords(selector)
|
|
2838
|
+
if (!Array.isArray(selected)) return selected
|
|
2839
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
|
|
2840
|
+
}
|
|
2744
2841
|
if (selector === undefined) {
|
|
2745
|
-
|
|
2746
|
-
|
|
2842
|
+
return {
|
|
2843
|
+
error: 'device-required',
|
|
2844
|
+
message:
|
|
2845
|
+
'`action` "forget"/"remember" needs `device`: a selector that names exactly one device',
|
|
2846
|
+
} satisfies Record<string, unknown>
|
|
2747
2847
|
}
|
|
2848
|
+
const hosts = hostListArgument(args['hosts'])
|
|
2849
|
+
if (!Array.isArray(hosts)) return hosts
|
|
2850
|
+
const quarantined = this.#quarantineVerdict()
|
|
2851
|
+
if (quarantined !== undefined) return quarantined
|
|
2748
2852
|
const selected = this.#selectRecords(selector)
|
|
2749
2853
|
if (!Array.isArray(selected)) return selected
|
|
2750
|
-
|
|
2854
|
+
const record = selected[0]!
|
|
2855
|
+
let removed: string[] = []
|
|
2856
|
+
let marked: string[] = []
|
|
2857
|
+
let restored: string[] = []
|
|
2858
|
+
try {
|
|
2859
|
+
if (action === 'forget') {
|
|
2860
|
+
;({ removed, marked } = this.#deps.store.forgetEndpoints(record.deviceId, hosts))
|
|
2861
|
+
} else {
|
|
2862
|
+
;({ restored } = this.#deps.store.rememberEndpoints(record.deviceId, hosts))
|
|
2863
|
+
}
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
if (error instanceof LastCandidateHostError) {
|
|
2866
|
+
return {
|
|
2867
|
+
error: 'last-address',
|
|
2868
|
+
device: record.deviceId,
|
|
2869
|
+
message: error.message,
|
|
2870
|
+
} satisfies Record<string, unknown>
|
|
2871
|
+
}
|
|
2872
|
+
throw error
|
|
2873
|
+
}
|
|
2874
|
+
const changed = removed.length > 0 || marked.length > 0 || restored.length > 0
|
|
2875
|
+
// ADDRESS MAINTENANCE reaches the LIVE connection, exactly like the HTTP
|
|
2876
|
+
// PATCH's eviction: a pooled `DeviceConnection` captures its record at
|
|
2877
|
+
// construction, so an instance built before this write would keep
|
|
2878
|
+
// dialing the forgotten host and locally re-adding it on every
|
|
2879
|
+
// reconnect. Evicting drops that socket and makes the next `connect()`
|
|
2880
|
+
// rebuild from the archive we just wrote.
|
|
2881
|
+
if (changed) await this.#deps.evict(record.deviceId)
|
|
2882
|
+
// The resolved record is STALE (hosts/forgottenHosts moved), so the
|
|
2883
|
+
// view is built from the archive's current row, never from the object
|
|
2884
|
+
// the selector handed us.
|
|
2885
|
+
const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId)
|
|
2886
|
+
if (fresh === undefined) return this.#notFoundVerdict(selector)
|
|
2887
|
+
const device = await this.#deviceView(fresh)
|
|
2888
|
+
return action === 'forget' ? { device, removed, marked } : { device, restored }
|
|
2751
2889
|
},
|
|
2752
2890
|
})
|
|
2753
2891
|
}
|
|
@@ -2814,6 +2952,7 @@ export class CherryToolEngine {
|
|
|
2814
2952
|
enabled: record.enabled,
|
|
2815
2953
|
hosts: record.hosts,
|
|
2816
2954
|
port: record.port,
|
|
2955
|
+
forgottenHosts: record.forgottenHosts ?? [],
|
|
2817
2956
|
}
|
|
2818
2957
|
}
|
|
2819
2958
|
|
package/ui/AGENTS.md
CHANGED
|
@@ -18,6 +18,27 @@ with its source. Loaded automatically when a file tool touches `ui/**`.
|
|
|
18
18
|
- **HTTP lives in `api.ts`** (same-origin `/cherry-remote/api`); a component
|
|
19
19
|
never fetches directly. Both seams are injected for tests
|
|
20
20
|
(`eventSourceFactory`, `scopeClient`), so no test stands up a server.
|
|
21
|
+
- **A run always ends, and the deadline is not a verdict.** The server publishes
|
|
22
|
+
a terminal stage on every outcome, but the stream has no replay, so one lost
|
|
23
|
+
frame would otherwise park the wizard on 「配对进行中」 with the submit button
|
|
24
|
+
disabled and no cancel. The run deadline (`runLifetimeMs`, same injection
|
|
25
|
+
style as `codeLifetimeMs`) restores the form and re-reads the device list;
|
|
26
|
+
its copy points at that list instead of claiming failure, and it keeps
|
|
27
|
+
`claimId` so a terminal frame that arrives late still wins. Arm it on the
|
|
28
|
+
`waiting` phase only — never at mount.
|
|
29
|
+
- **The bundle carries the SOURCE COMMENTS.** `ui/vite.config.ts` sets
|
|
30
|
+
`minify: false`, so the docblocks that hold most of this page's reasoning ship
|
|
31
|
+
inside `lib/client.js`: a comment-only edit in `ui/src/**` changes the
|
|
32
|
+
artifact's bytes and needs the rebuild in the same commit. Two follow-ons from
|
|
33
|
+
that: `artifactFreshness` compares against `git show HEAD:<path>`, so it only
|
|
34
|
+
goes green once the rebuild is COMMITTED — running the full suite before
|
|
35
|
+
committing reports it red for the same reason its sibling case ("neither check
|
|
36
|
+
mutates the committed artifacts") is red. Neither red is a real failure.
|
|
37
|
+
- **A per-host action is INERT while a whole-list editor is open.** The address
|
|
38
|
+
editor's draft is sacred (the card does not reseed it while an editor is
|
|
39
|
+
open), so a 忘记/恢复 chosen under it would be re-sent by the next 保存地址 — and
|
|
40
|
+
that PATCH clears the mark for any host it names. Hence
|
|
41
|
+
`disabled={busy || editor === 'address'}` on both controls, pinned by `#204m`.
|
|
21
42
|
- **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
|
|
22
43
|
page; a browser-only API needs a fake in the test, not a guard in the page.
|
|
23
44
|
|