@linxin666/dsh-remote-web-ui 0.1.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/LICENSE +28 -0
- package/README.md +346 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +1455 -0
- package/lib/invariant.js +21 -0
- package/lib/types/client/PairFailedNotice.d.ts +12 -0
- package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
- package/lib/types/client/PhoneIcon.d.ts +19 -0
- package/lib/types/client/PhoneIcon.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/RemoteEntry.d.ts +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -0
- package/lib/types/client/RemotePanel.d.ts +50 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
- package/lib/types/client/deep-link.d.ts +40 -0
- package/lib/types/client/deep-link.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +144 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pair-api.d.ts +100 -0
- package/lib/types/client/pair-api.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/gate.d.ts +58 -0
- package/lib/types/gate.d.ts.map +1 -0
- package/lib/types/index.d.ts +78 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/lan.d.ts +15 -0
- package/lib/types/lan.d.ts.map +1 -0
- package/lib/types/mobile/api.d.ts +64 -0
- package/lib/types/mobile/api.d.ts.map +1 -0
- package/lib/types/mobile/index.d.ts +10 -0
- package/lib/types/mobile/index.d.ts.map +1 -0
- package/lib/types/mobile/messages.d.ts +103 -0
- package/lib/types/mobile/messages.d.ts.map +1 -0
- package/lib/types/mobile/messages.test.d.ts +2 -0
- package/lib/types/mobile/messages.test.d.ts.map +1 -0
- package/lib/types/mobile/mobile-styles.d.ts +6 -0
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.d.ts +22 -0
- package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
- package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.d.ts +52 -0
- package/lib/types/mobile/mux.d.ts.map +1 -0
- package/lib/types/mobile/rpc.d.ts +38 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -0
- package/lib/types/mobile/theme-toggle.d.ts +12 -0
- package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
- package/lib/types/mobile/views/App.d.ts +59 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.d.ts +39 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.d.ts +27 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
- package/lib/types/mobile-api.d.ts +41 -0
- package/lib/types/mobile-api.d.ts.map +1 -0
- package/lib/types/mobile-routes.d.ts +16 -0
- package/lib/types/mobile-routes.d.ts.map +1 -0
- package/lib/types/pairing.d.ts +215 -0
- package/lib/types/pairing.d.ts.map +1 -0
- package/lib/types/routes.d.ts +58 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/tunnel.d.ts +99 -0
- package/lib/types/tunnel.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/client/PairFailedNotice.tsx +33 -0
- package/src/client/PhoneIcon.tsx +31 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/RemoteEntry.tsx +202 -0
- package/src/client/RemotePanel.tsx +186 -0
- package/src/client/RemoteSettingsCard.tsx +220 -0
- package/src/client/deep-link.ts +137 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +149 -0
- package/src/client/pair-api.ts +176 -0
- package/src/client/remote.module.css +422 -0
- package/src/client/settings-card.module.css +296 -0
- package/src/client/settings-form.ts +294 -0
- package/src/css-modules.d.ts +6 -0
- package/src/gate.ts +121 -0
- package/src/index.ts +299 -0
- package/src/invariant.ts +32 -0
- package/src/lan.ts +21 -0
- package/src/mobile/api.ts +106 -0
- package/src/mobile/index.tsx +27 -0
- package/src/mobile/messages.test.ts +222 -0
- package/src/mobile/messages.ts +555 -0
- package/src/mobile/mobile-styles.ts +853 -0
- package/src/mobile/mobile-theme.test.ts +84 -0
- package/src/mobile/mobile-theme.ts +76 -0
- package/src/mobile/mux.ts +126 -0
- package/src/mobile/rpc.ts +90 -0
- package/src/mobile/theme-toggle.tsx +47 -0
- package/src/mobile/views/App.tsx +152 -0
- package/src/mobile/views/ChatView.test.tsx +243 -0
- package/src/mobile/views/ChatView.tsx +650 -0
- package/src/mobile/views/SessionListView.test.tsx +139 -0
- package/src/mobile/views/SessionListView.tsx +186 -0
- package/src/mobile/views/WorkspaceView.tsx +108 -0
- package/src/mobile-api.ts +271 -0
- package/src/mobile-routes.ts +77 -0
- package/src/pairing.ts +390 -0
- package/src/routes.ts +346 -0
- package/src/tunnel.ts +264 -0
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing state machine: one active one-time token, a device-session table,
|
|
3
|
+
* and presence tracking. Pure TypeScript with injected clock/randomness so
|
|
4
|
+
* the whole security semantics are unit-testable without cordis. The
|
|
5
|
+
* cordis-facing surfaces (routes, the api/gate listener) live next door.
|
|
6
|
+
*
|
|
7
|
+
* Security invariants:
|
|
8
|
+
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
9
|
+
* immediately invalidates the previous link.
|
|
10
|
+
* - A token is consumed by the first successful `accept()` — reuse is
|
|
11
|
+
* refused with `'used'`.
|
|
12
|
+
* - Tokens expire; `accept()` on an expired token is refused like an
|
|
13
|
+
* unknown one (no oracle for validity).
|
|
14
|
+
* - `stop()` revokes every device session and clears the token, so paired
|
|
15
|
+
* devices are cut off on their next gated request.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
/** The observable pairing phases the panel renders. */
|
|
21
|
+
export type PairingPhase =
|
|
22
|
+
/** The server is not bound all-interfaces: no usable QR exists. */
|
|
23
|
+
| 'lan-required'
|
|
24
|
+
/** Remote control was stopped; a fresh QR (issue) re-enables it. */
|
|
25
|
+
| 'stopped'
|
|
26
|
+
/** A token is live and no device has paired with it yet. */
|
|
27
|
+
| 'waiting'
|
|
28
|
+
/** At least one paired device was active recently. */
|
|
29
|
+
| 'connected'
|
|
30
|
+
/** Devices are paired but none has been active within the offline window. */
|
|
31
|
+
| 'disconnected'
|
|
32
|
+
|
|
33
|
+
/** One issued pairing token (keyed by its secret). */
|
|
34
|
+
export interface TokenRecord {
|
|
35
|
+
/** Monotonic issue time (ms epoch), drives refresh ordering. */
|
|
36
|
+
issuedAt: number
|
|
37
|
+
/** Absolute expiry (ms epoch); accept() past this is refused. */
|
|
38
|
+
expiresAt: number
|
|
39
|
+
/** Consumed by the first successful accept(). */
|
|
40
|
+
consumed: boolean
|
|
41
|
+
/** Opaque, non-secret identifier surfaced in snapshots (never the pairing secret). */
|
|
42
|
+
id: string
|
|
43
|
+
/** Workspace the QR link should land the phone in (optional). */
|
|
44
|
+
workspaceId?: string
|
|
45
|
+
/** LAN IP literal the QR link was built from (optional; default first). */
|
|
46
|
+
address?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One paired device session, keyed by the device id stored in its cookie. */
|
|
50
|
+
export interface DeviceSession {
|
|
51
|
+
/** Pairing time (ms epoch). */
|
|
52
|
+
createdAt: number
|
|
53
|
+
/** Last time the device passed a gated request or heartbeat. */
|
|
54
|
+
lastSeenAt: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One tunnel status frame (auto-tunnel only; undefined when disabled). */
|
|
58
|
+
export interface TunnelStatus {
|
|
59
|
+
/** starting: binary/process warming up; running: URL minted; failed: no URL. */
|
|
60
|
+
state: 'starting' | 'running' | 'failed'
|
|
61
|
+
/** The minted public URL, once the tunnel reports it. */
|
|
62
|
+
url?: string
|
|
63
|
+
/** Human-readable failure detail. */
|
|
64
|
+
error?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One snapshot frame pushed to desktop status streams. */
|
|
68
|
+
export interface PairingSnapshot {
|
|
69
|
+
phase: PairingPhase
|
|
70
|
+
/** Whether the server bind is all-interfaces (a QR is constructible). */
|
|
71
|
+
lanAvailable: boolean
|
|
72
|
+
/** The LAN IP literals a QR can be built from (interface order). */
|
|
73
|
+
lanAddresses: string[]
|
|
74
|
+
/** Configured public (tunneled) base URL, when present. */
|
|
75
|
+
publicUrl?: string
|
|
76
|
+
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
77
|
+
tunnel?: TunnelStatus
|
|
78
|
+
/** Opaque (non-secret) id of the active token (undefined when stopped/lan-required). */
|
|
79
|
+
tokenId?: string
|
|
80
|
+
/** Absolute expiry of the active token. */
|
|
81
|
+
tokenExpiresAt?: number
|
|
82
|
+
/** Count of ever-paired devices. */
|
|
83
|
+
deviceCount: number
|
|
84
|
+
/** Count of devices active within the offline window. */
|
|
85
|
+
onlineCount: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Service tunables (config-validated upstream; plain numbers here). */
|
|
89
|
+
export interface PairingConfig {
|
|
90
|
+
/** Token lifetime; the QR stops working after this. */
|
|
91
|
+
tokenTtlMs: number
|
|
92
|
+
/** A device is "online" while its lastSeenAt is newer than this. */
|
|
93
|
+
offlineAfterMs: number
|
|
94
|
+
/** Hard cap on paired device sessions (oldest-evicted when full). */
|
|
95
|
+
maxDevices: number
|
|
96
|
+
/** Cookie name carrying the device id. */
|
|
97
|
+
cookieName: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Result of one accept() attempt. */
|
|
101
|
+
export type AcceptResult =
|
|
102
|
+
| { ok: true; deviceId: string }
|
|
103
|
+
| { ok: false; code: 'invalid' | 'used' }
|
|
104
|
+
|
|
105
|
+
/** Thrown by issue() for an address outside the sampled LAN literals. */
|
|
106
|
+
export class UnknownLanAddressError extends Error {
|
|
107
|
+
/**
|
|
108
|
+
* @param address - the offending literal.
|
|
109
|
+
*/
|
|
110
|
+
constructor(address: string) {
|
|
111
|
+
super(`remote-web-ui: unknown LAN address ${JSON.stringify(address)}`)
|
|
112
|
+
this.name = 'UnknownLanAddressError'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Clock and entropy injection for tests. */
|
|
117
|
+
export interface PairingClock {
|
|
118
|
+
now(): number
|
|
119
|
+
randomToken(): string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Real clock/entropy: 32 random hex chars per token. */
|
|
123
|
+
export const defaultClock: PairingClock = {
|
|
124
|
+
now: () => Date.now(),
|
|
125
|
+
randomToken: () => randomBytes(16).toString('hex'),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The pairing state machine. All mutations notify state listeners after the
|
|
130
|
+
* commit point that makes them true, and notification dedupes against the
|
|
131
|
+
* last emitted snapshot — time-driven transitions (a device aging offline)
|
|
132
|
+
* surface on the next sweep without any mutation.
|
|
133
|
+
*/
|
|
134
|
+
export class PairingService {
|
|
135
|
+
private readonly tokens = new Map<string, TokenRecord>()
|
|
136
|
+
private readonly devices = new Map<string, DeviceSession>()
|
|
137
|
+
private readonly listeners = new Set<(snapshot: PairingSnapshot) => void>()
|
|
138
|
+
private lastEmitted: PairingSnapshot | undefined
|
|
139
|
+
private stopped = false
|
|
140
|
+
private tokenSerial = 0
|
|
141
|
+
/** LAN base URLs keyed by the advertised IP literal (interface order). */
|
|
142
|
+
private lanBases = new Map<string, string>()
|
|
143
|
+
/** Public (tunneled) base URL, e.g. a Cloudflare Tunnel quick URL. */
|
|
144
|
+
private publicBase: string | undefined
|
|
145
|
+
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
146
|
+
private tunnelStatus: TunnelStatus | undefined
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param config - tunables. The settings surface replaces the object (a
|
|
150
|
+
* fresh literal) when a committed section changes; every operation reads
|
|
151
|
+
* the current one.
|
|
152
|
+
* @param clock - clock/entropy source (injectable for tests).
|
|
153
|
+
*/
|
|
154
|
+
constructor(
|
|
155
|
+
public config: PairingConfig,
|
|
156
|
+
private readonly clock: PairingClock = defaultClock,
|
|
157
|
+
) {}
|
|
158
|
+
|
|
159
|
+
/** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
|
|
160
|
+
get lanBaseUrl(): string | undefined {
|
|
161
|
+
return this.lanBases.values().next().value as string | undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The LAN base URL for one specific literal (undefined when not constructible). */
|
|
165
|
+
lanBaseUrlFor(address: string): string | undefined {
|
|
166
|
+
return this.lanBases.get(address)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The LAN IP literals QR links can be built from (interface order). */
|
|
170
|
+
get lanAddresses(): string[] {
|
|
171
|
+
return [...this.lanBases.keys()]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Set the LAN base URLs once the server bind is known (interface order). */
|
|
175
|
+
setLanBases(entries: readonly { address: string; base: string }[]): void {
|
|
176
|
+
this.lanBases = new Map(entries.map(entry => [entry.address, entry.base]))
|
|
177
|
+
this.notify()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The configured public (tunneled) base URL, when present. */
|
|
181
|
+
get publicBaseUrl(): string | undefined {
|
|
182
|
+
return this.publicBase
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Set or clear the public base URL (a tunnel in front of this server). */
|
|
186
|
+
setPublicBaseUrl(url: string | undefined): void {
|
|
187
|
+
this.publicBase = url
|
|
188
|
+
this.notify()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
|
|
192
|
+
setTunnelStatus(status: TunnelStatus | undefined): void {
|
|
193
|
+
this.tunnelStatus = status
|
|
194
|
+
this.notify()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Issue a fresh token, replacing (invalidating) any previous one. A
|
|
199
|
+
* stopped service re-arms through this call (the panel's refresh button).
|
|
200
|
+
* @param workspaceId - optional workspace the QR link should land in.
|
|
201
|
+
* @param address - optional LAN IP literal the QR must be built from; the
|
|
202
|
+
* default is the public base (when configured) or the first interface.
|
|
203
|
+
* Unknown addresses are refused.
|
|
204
|
+
* @returns the token secret and its expiry.
|
|
205
|
+
* @throws {Error} when no reachable base exists (no all-interfaces bind and
|
|
206
|
+
* no public base) — callers surface this as the lan-required state instead
|
|
207
|
+
* of minting an unusable QR.
|
|
208
|
+
*/
|
|
209
|
+
issue(workspaceId?: string, address?: string): { token: string; expiresAt: number } {
|
|
210
|
+
if (this.lanBases.size === 0 && this.publicBase === undefined) {
|
|
211
|
+
throw new Error('remote-web-ui: pairing requires a reachable bind (--host 0.0.0.0 or publicBaseUrl)')
|
|
212
|
+
}
|
|
213
|
+
if (address !== undefined && !this.lanBases.has(address)) {
|
|
214
|
+
throw new UnknownLanAddressError(address)
|
|
215
|
+
}
|
|
216
|
+
const now = this.clock.now()
|
|
217
|
+
const token = this.clock.randomToken()
|
|
218
|
+
this.tokens.clear()
|
|
219
|
+
this.stopped = false
|
|
220
|
+
this.tokenSerial += 1
|
|
221
|
+
this.tokens.set(token, {
|
|
222
|
+
id: `t${this.tokenSerial}`,
|
|
223
|
+
issuedAt: now,
|
|
224
|
+
expiresAt: now + this.config.tokenTtlMs,
|
|
225
|
+
consumed: false,
|
|
226
|
+
...(workspaceId !== undefined ? { workspaceId } : {}),
|
|
227
|
+
...(address !== undefined ? { address } : {}),
|
|
228
|
+
})
|
|
229
|
+
this.notify()
|
|
230
|
+
return { token, expiresAt: now + this.config.tokenTtlMs }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Consume a token and bind a device session. One-time: the second
|
|
235
|
+
* successful call for the same token is impossible because the first
|
|
236
|
+
* consumes it.
|
|
237
|
+
* @param token - the token secret from the QR link.
|
|
238
|
+
* @returns the new device id, or a refusal code.
|
|
239
|
+
*/
|
|
240
|
+
accept(token: string): AcceptResult {
|
|
241
|
+
const record = this.tokens.get(token)
|
|
242
|
+
if (record === undefined || record.consumed || this.stopped || this.clock.now() > record.expiresAt) {
|
|
243
|
+
return { ok: false, code: record?.consumed === true ? 'used' : 'invalid' }
|
|
244
|
+
}
|
|
245
|
+
record.consumed = true
|
|
246
|
+
const deviceId = this.clock.randomToken()
|
|
247
|
+
const now = this.clock.now()
|
|
248
|
+
if (this.devices.size >= this.config.maxDevices) {
|
|
249
|
+
// Evict the oldest session (FIFO) before binding a new device.
|
|
250
|
+
let oldest: { id: string; createdAt: number } | undefined
|
|
251
|
+
for (const [id, session] of this.devices) {
|
|
252
|
+
if (oldest === undefined || session.createdAt < oldest.createdAt) oldest = { id, createdAt: session.createdAt }
|
|
253
|
+
}
|
|
254
|
+
if (oldest !== undefined) this.devices.delete(oldest.id)
|
|
255
|
+
}
|
|
256
|
+
this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
|
|
257
|
+
this.notify()
|
|
258
|
+
return { ok: true, deviceId }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Stop remote control: revoke every device session and clear the token.
|
|
263
|
+
* The phone's next gated /api request 403s; the panel falls back to
|
|
264
|
+
* stopped until a fresh QR is issued.
|
|
265
|
+
*/
|
|
266
|
+
stop(): void {
|
|
267
|
+
this.tokens.clear()
|
|
268
|
+
this.devices.clear()
|
|
269
|
+
this.stopped = true
|
|
270
|
+
this.notify()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The api/gate path: record activity for a device id and report whether
|
|
275
|
+
* the request may proceed. Unknown or revoked ids (including any device
|
|
276
|
+
* after stop()) are refused.
|
|
277
|
+
* @param deviceId - the cookie value of the requesting device.
|
|
278
|
+
* @returns true when the device session is live and was refreshed.
|
|
279
|
+
*/
|
|
280
|
+
touchDevice(deviceId: string): boolean {
|
|
281
|
+
const session = this.devices.get(deviceId)
|
|
282
|
+
if (session === undefined || this.stopped) return false
|
|
283
|
+
session.lastSeenAt = this.clock.now()
|
|
284
|
+
this.notify()
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Explicit presence heartbeat (the phone's client sends these). */
|
|
289
|
+
heartbeat(deviceId: string): boolean {
|
|
290
|
+
return this.touchDevice(deviceId)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Periodic sweep: re-evaluate the derived snapshot (a device aging past
|
|
295
|
+
* the offline window flips the phase to disconnected). Emits only when
|
|
296
|
+
* the snapshot actually changed.
|
|
297
|
+
*/
|
|
298
|
+
sweep(): void {
|
|
299
|
+
this.notify()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The current snapshot (fresh object per call — stable between emits). */
|
|
303
|
+
snapshot(): PairingSnapshot {
|
|
304
|
+
const now = this.clock.now()
|
|
305
|
+
const onlineCount = [...this.devices.values()].filter(session => this.isOnlineAt(session, now)).length
|
|
306
|
+
const token = this.activeToken()
|
|
307
|
+
return {
|
|
308
|
+
phase: this.derivePhase(onlineCount, token !== undefined),
|
|
309
|
+
lanAvailable: this.lanBases.size > 0,
|
|
310
|
+
lanAddresses: [...this.lanBases.keys()],
|
|
311
|
+
...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
|
|
312
|
+
...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
|
|
313
|
+
...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
|
|
314
|
+
deviceCount: this.devices.size,
|
|
315
|
+
onlineCount,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Whether a cookie value names a currently live device session. */
|
|
320
|
+
hasDevice(deviceId: string): boolean {
|
|
321
|
+
const session = this.devices.get(deviceId)
|
|
322
|
+
return session !== undefined && !this.stopped
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
|
|
326
|
+
onState(listener: (snapshot: PairingSnapshot) => void): () => void {
|
|
327
|
+
this.listeners.add(listener)
|
|
328
|
+
return () => { this.listeners.delete(listener) }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private activeToken(): { token: string; record: TokenRecord } | undefined {
|
|
332
|
+
for (const [token, record] of this.tokens) {
|
|
333
|
+
if (this.stopped) return undefined
|
|
334
|
+
if (this.clock.now() > record.expiresAt) continue
|
|
335
|
+
return { token, record }
|
|
336
|
+
}
|
|
337
|
+
return undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private derivePhase(onlineCount: number, hasToken: boolean): PairingPhase {
|
|
341
|
+
if (this.lanBases.size === 0 && this.publicBase === undefined) return 'lan-required'
|
|
342
|
+
if (this.stopped) return 'stopped'
|
|
343
|
+
if (onlineCount > 0) return 'connected'
|
|
344
|
+
if (this.devices.size > 0) return 'disconnected'
|
|
345
|
+
if (hasToken) return 'waiting'
|
|
346
|
+
return 'stopped'
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private isOnlineAt(session: DeviceSession, now: number): boolean {
|
|
350
|
+
return now - session.lastSeenAt <= this.config.offlineAfterMs
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private notify(): void {
|
|
354
|
+
const snapshot = this.snapshot()
|
|
355
|
+
if (this.lastEmitted !== undefined && snapshotsEqual(this.lastEmitted, snapshot)) return
|
|
356
|
+
this.lastEmitted = snapshot
|
|
357
|
+
for (const listener of this.listeners) {
|
|
358
|
+
try {
|
|
359
|
+
listener(snapshot)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
// A throwing subscriber must not break the emit loop or the caller.
|
|
362
|
+
console.error('remote-web-ui: pairing state listener failed', error)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Structural equality over the snapshot's wire fields. */
|
|
369
|
+
function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
|
|
370
|
+
return a.phase === b.phase
|
|
371
|
+
&& a.lanAvailable === b.lanAvailable
|
|
372
|
+
&& sameStrings(a.lanAddresses, b.lanAddresses)
|
|
373
|
+
&& a.publicUrl === b.publicUrl
|
|
374
|
+
&& tunnelEqual(a.tunnel, b.tunnel)
|
|
375
|
+
&& a.tokenId === b.tokenId
|
|
376
|
+
&& a.tokenExpiresAt === b.tokenExpiresAt
|
|
377
|
+
&& a.deviceCount === b.deviceCount
|
|
378
|
+
&& a.onlineCount === b.onlineCount
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
|
|
382
|
+
function tunnelEqual(a: TunnelStatus | undefined, b: TunnelStatus | undefined): boolean {
|
|
383
|
+
return a === b || (a !== undefined && b !== undefined
|
|
384
|
+
&& a.state === b.state && a.url === b.url && a.error === b.error)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Element-wise string list equality (interface order is meaningful). */
|
|
388
|
+
function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
389
|
+
return a.length === b.length && a.every((value, index) => value === b[index])
|
|
390
|
+
}
|