dsh-cindy-host 0.1.20
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/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings card's Host API.
|
|
3
|
+
*
|
|
4
|
+
* One prefix route registered on `ctx.webServer`, so the card — served from the
|
|
5
|
+
* same origin as the GUI — reads status and drives login with plain `fetch`.
|
|
6
|
+
* This is the pattern DSH's own plugins use for browser-facing Host surfaces
|
|
7
|
+
* (see `dsh-client-connection`'s `/api` route), and it avoids inventing a
|
|
8
|
+
* second transport for four small JSON calls.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here decides state: every handler asks the runtime and answers with
|
|
11
|
+
* what the runtime says. The card is a renderer.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { loadSession } from './credential-store.js';
|
|
15
|
+
import { rememberedDeviceId } from './host-settings.js';
|
|
16
|
+
import { requestLoginCode, verifyLoginCode, selectLoginAccount } from './cindy-login-flow.js';
|
|
17
|
+
import { adoptSession, forgetSession } from './auth-session.js';
|
|
18
|
+
|
|
19
|
+
/** Route prefix; the card hardcodes the same value. */
|
|
20
|
+
export const API_PREFIX = '/api/dsh-cindy-host';
|
|
21
|
+
|
|
22
|
+
/** Largest accepted JSON body. Four short scalars never come close. */
|
|
23
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
24
|
+
|
|
25
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
26
|
+
|
|
27
|
+
/** Whether a request arrived over the loopback interface. */
|
|
28
|
+
export function isLoopbackRequest(req) {
|
|
29
|
+
const address = req?.socket?.remoteAddress;
|
|
30
|
+
// A socket with no address (an injected test double) is treated as local.
|
|
31
|
+
return address === undefined || address === null || LOOPBACK.has(address);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendJson(res, status, body) {
|
|
35
|
+
const payload = Buffer.from(JSON.stringify(body), 'utf8');
|
|
36
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': payload.byteLength, 'cache-control': 'no-store' });
|
|
37
|
+
res.end(payload);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read and parse a JSON request body, refusing anything oversized or non-JSON. */
|
|
41
|
+
export async function readJsonBody(req) {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
for await (const chunk of req) {
|
|
45
|
+
size += chunk.length;
|
|
46
|
+
if (size > MAX_BODY_BYTES) throw new Error('BODY_TOO_LARGE');
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
}
|
|
49
|
+
if (size === 0) return {};
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
52
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {};
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error('BODY_NOT_JSON');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the handlers for one running runtime.
|
|
60
|
+
*
|
|
61
|
+
* The runtime is read lazily through `getRuntime`, because the plugin replaces
|
|
62
|
+
* it whenever settings restart the transport.
|
|
63
|
+
* @param options - the runtime accessor and an optional diagnostics producer.
|
|
64
|
+
* @returns one handler per route, each owning its own response.
|
|
65
|
+
*/
|
|
66
|
+
export function createHostRoutes({ getRuntime, getDiagnostics, getSettings, rememberDeviceId, auth = {} }) {
|
|
67
|
+
const loadCredential = auth.loadSession ?? loadSession;
|
|
68
|
+
const saveCredential = auth.adoptSession ?? adoptSession;
|
|
69
|
+
const forgetCredential = auth.forgetSession ?? forgetSession;
|
|
70
|
+
const verifyCredential = auth.verifyLoginCode ?? verifyLoginCode;
|
|
71
|
+
const selectCredential = auth.selectLoginAccount ?? selectLoginAccount;
|
|
72
|
+
/**
|
|
73
|
+
* The stable Cindy device handle for this Host.
|
|
74
|
+
*
|
|
75
|
+
* The credential is preferred because that is the identity the relay has already
|
|
76
|
+
* seen, but it is not the only source: a credential can legitimately vanish while
|
|
77
|
+
* the phone still holds a link to this Host's device id, so the id is also kept in
|
|
78
|
+
* settings. Generating a fresh one in that situation re-pairs the Host as a
|
|
79
|
+
* different device and makes the phone's existing link dangle.
|
|
80
|
+
* @returns the device id to log in with.
|
|
81
|
+
*/
|
|
82
|
+
async function hostDeviceId() {
|
|
83
|
+
const stored = await loadCredential().catch(() => null);
|
|
84
|
+
const fromCredential = typeof stored?.deviceId === 'string' ? stored.deviceId.trim() : '';
|
|
85
|
+
const remembered = typeof getSettings === 'function'
|
|
86
|
+
? (() => { try { return rememberedDeviceId(getSettings()); } catch { return ''; } })()
|
|
87
|
+
: '';
|
|
88
|
+
const deviceId = fromCredential !== '' ? fromCredential : (remembered !== '' ? remembered : randomUUID());
|
|
89
|
+
if (deviceId !== remembered && typeof rememberDeviceId === 'function') {
|
|
90
|
+
// Remembering is best effort: a settings write that fails must not cost the
|
|
91
|
+
// login the user is in the middle of.
|
|
92
|
+
try {
|
|
93
|
+
await rememberDeviceId(deviceId);
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignored on purpose.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return deviceId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Why the Host has (or has not) any data to serve — cheap, and only for diagnosis. */
|
|
102
|
+
function diagnostics() {
|
|
103
|
+
if (typeof getDiagnostics !== 'function') return undefined;
|
|
104
|
+
try {
|
|
105
|
+
return getDiagnostics();
|
|
106
|
+
} catch {
|
|
107
|
+
// Diagnostics must never be the reason a status read fails.
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleStatus(req, res) {
|
|
113
|
+
const runtime = getRuntime();
|
|
114
|
+
if (!runtime) {
|
|
115
|
+
sendJson(res, 200, { ok: true, installed: true, status: null, diagnostics: diagnostics() });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
sendJson(res, 200, { ok: true, installed: true, status: runtime.getStatus(), diagnostics: diagnostics() });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleReconnect(req, res) {
|
|
122
|
+
const runtime = getRuntime();
|
|
123
|
+
if (!runtime) {
|
|
124
|
+
sendJson(res, 503, { ok: false, message: 'Cindy Host 运行时尚未就绪' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// The card's button is an escape hatch, not the mechanism: the Host retries on its
|
|
128
|
+
// own with backoff (`host-reconnect.js`). This path exists so a person can skip the
|
|
129
|
+
// wait — it drops any queued retry and forgets the ladder.
|
|
130
|
+
if (typeof runtime.reconnectNow === 'function') await runtime.reconnectNow();
|
|
131
|
+
else await runtime.connect();
|
|
132
|
+
sendJson(res, 200, { ok: true, status: runtime.getStatus() });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handleRequestCode(req, res, body) {
|
|
136
|
+
const result = await requestLoginCode({ kind: body.kind, identifier: body.identifier });
|
|
137
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function handleVerifyCode(req, res, body) {
|
|
141
|
+
const result = await verifyCredential({
|
|
142
|
+
kind: body.kind,
|
|
143
|
+
identifier: body.identifier,
|
|
144
|
+
code: body.code,
|
|
145
|
+
deviceId: await hostDeviceId(),
|
|
146
|
+
});
|
|
147
|
+
if (!result.ok) {
|
|
148
|
+
sendJson(res, 400, result);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const runtime = getRuntime();
|
|
152
|
+
await runtime?.beginIdentityChange?.();
|
|
153
|
+
await saveCredential(result.session);
|
|
154
|
+
// A login while the switch is already on must finish the connect the user
|
|
155
|
+
// asked for; with the switch off it only stores the session.
|
|
156
|
+
if (runtime) {
|
|
157
|
+
runtime.status.setLogin({ authenticated: true, required: false, kind: result.session.kind ?? null, identifier: result.session.identifier ?? null });
|
|
158
|
+
await runtime.connect();
|
|
159
|
+
}
|
|
160
|
+
sendJson(res, 200, { ok: true, deviceId: result.session.deviceId, status: runtime ? runtime.getStatus() : null });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function handleSelectAccount(req, res, body) {
|
|
164
|
+
const result = await selectCredential({ loginTicket: body.loginTicket, accountId: body.accountId, deviceId: await hostDeviceId() });
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
sendJson(res, 400, result);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const runtime = getRuntime();
|
|
170
|
+
await runtime?.beginIdentityChange?.();
|
|
171
|
+
await saveCredential(result.session);
|
|
172
|
+
if (runtime) {
|
|
173
|
+
runtime.status.setLogin({ authenticated: true, required: false });
|
|
174
|
+
await runtime.connect();
|
|
175
|
+
}
|
|
176
|
+
sendJson(res, 200, { ok: true, deviceId: result.session.deviceId, status: runtime ? runtime.getStatus() : null });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function handleLogout(req, res) {
|
|
180
|
+
const runtime = getRuntime();
|
|
181
|
+
await runtime?.beginIdentityChange?.();
|
|
182
|
+
await forgetCredential();
|
|
183
|
+
if (runtime) await runtime.disconnect();
|
|
184
|
+
sendJson(res, 200, { ok: true, status: runtime ? runtime.getStatus() : null });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Run one channel against the real services, from this process.
|
|
189
|
+
*
|
|
190
|
+
* This exists because "the phone shows nothing" is otherwise unverifiable
|
|
191
|
+
* without a handset: two bugs were found only after a round trip, and both
|
|
192
|
+
* looked identical from here. It is a diagnostic, not a feature — it carries
|
|
193
|
+
* the same loopback-only trust boundary as every other route on this prefix,
|
|
194
|
+
* and it performs whatever the channel performs.
|
|
195
|
+
*/
|
|
196
|
+
async function handleSelfTest(req, res, body) {
|
|
197
|
+
const runtime = getRuntime();
|
|
198
|
+
if (!runtime || typeof runtime.invokeForTest !== 'function') {
|
|
199
|
+
sendJson(res, 503, { ok: false, message: 'Cindy Host 运行时尚未就绪' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const channel = typeof body.channel === 'string' ? body.channel : '';
|
|
203
|
+
if (channel === '') {
|
|
204
|
+
sendJson(res, 400, { ok: false, message: 'self-test needs a channel' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const args = Array.isArray(body.args) ? body.args : [];
|
|
208
|
+
sendJson(res, 200, await runtime.invokeForTest(channel, args));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The route table: one entry per accepted method+path.
|
|
213
|
+
* @returns handlers keyed by method and pathname.
|
|
214
|
+
*/
|
|
215
|
+
const table = new Map([
|
|
216
|
+
['GET /status', handleStatus],
|
|
217
|
+
['POST /reconnect', handleReconnect],
|
|
218
|
+
['POST /login/request-code', handleRequestCode],
|
|
219
|
+
['POST /login/verify-code', handleVerifyCode],
|
|
220
|
+
['POST /login/select-account', handleSelectAccount],
|
|
221
|
+
['POST /logout', handleLogout],
|
|
222
|
+
['POST /selftest', handleSelfTest],
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Paths that take a JSON body. */
|
|
226
|
+
const BODY_PATHS = new Set(['/reconnect', '/login/request-code', '/login/verify-code', '/login/select-account', '/logout', '/selftest']);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The one handler registered on the web server.
|
|
230
|
+
* @param req - incoming request.
|
|
231
|
+
* @param res - response owned by this handler.
|
|
232
|
+
*/
|
|
233
|
+
async function handle(req, res) {
|
|
234
|
+
if (!isLoopbackRequest(req)) {
|
|
235
|
+
sendJson(res, 403, { ok: false, message: 'Cindy Host API is loopback-only' });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
240
|
+
const suffix = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : pathname;
|
|
241
|
+
const handler = table.get(`${req.method ?? 'GET'} ${suffix}`);
|
|
242
|
+
if (handler === undefined) {
|
|
243
|
+
sendJson(res, 404, { ok: false, message: `Unknown Cindy Host route: ${req.method ?? 'GET'} ${pathname}` });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let body = {};
|
|
248
|
+
if (BODY_PATHS.has(suffix)) {
|
|
249
|
+
try {
|
|
250
|
+
body = await readJsonBody(req);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
sendJson(res, error instanceof Error && error.message === 'BODY_TOO_LARGE' ? 413 : 400, { ok: false, message: '请求体无效' });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await handler(req, res, body);
|
|
259
|
+
} catch {
|
|
260
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, message: 'Cindy Host 处理请求失败' });
|
|
261
|
+
else res.end();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { handle, API_PREFIX };
|
|
266
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const SETTINGS_NAMESPACE = 'dsh-cindy-host';
|
|
2
|
+
|
|
3
|
+
// Network access is opt-in. A newly mounted bundle only projects local DSH data.
|
|
4
|
+
//
|
|
5
|
+
// `sessionFlags` is the one section a *controller* writes: DSH has no archived,
|
|
6
|
+
// deleted or pinned session state, so the phone's 删除/归档/置顶 are this Host's own
|
|
7
|
+
// bookkeeping and they have to survive a restart (see `session-flags.js`).
|
|
8
|
+
export const DEFAULT_HOST_SETTINGS = Object.freeze({ transportEnabled: false, remoteControlEnabled: false, controllers: {}, deviceId: '', sessionFlags: {} });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The Host's own relay identity, when it has one.
|
|
12
|
+
*
|
|
13
|
+
* It is stored here rather than derived from the credential because the credential
|
|
14
|
+
* can legitimately disappear — an expired token, a fresh install, or (as happened
|
|
15
|
+
* once) a refresh failure that used to delete it — while the identity is what a
|
|
16
|
+
* phone linked to. Losing the credential must not silently re-pair the Host as a
|
|
17
|
+
* different device.
|
|
18
|
+
* @param value - the settings section.
|
|
19
|
+
* @returns the device id, or an empty string when none was ever recorded.
|
|
20
|
+
*/
|
|
21
|
+
export function rememberedDeviceId(value) {
|
|
22
|
+
return typeof value?.deviceId === 'string' ? value.deviceId.trim() : '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validateHostSettings(value) {
|
|
26
|
+
if (!value || typeof value !== 'object' || typeof value.transportEnabled !== 'boolean' || typeof value.remoteControlEnabled !== 'boolean' || !value.controllers || typeof value.controllers !== 'object') throw new Error('Invalid DSH Cindy Host settings');
|
|
27
|
+
if (value.deviceId !== undefined && typeof value.deviceId !== 'string') throw new Error('Invalid DSH Cindy Host settings');
|
|
28
|
+
for (const [id, entry] of Object.entries(value.controllers)) {
|
|
29
|
+
if (!id || !entry || !['authorized', 'revoked'].includes(entry.state)) throw new Error('Invalid controller authorization');
|
|
30
|
+
}
|
|
31
|
+
// An absent section is valid — every settings file written before this field
|
|
32
|
+
// existed has none, and rejecting it would fail the load instead of the flag.
|
|
33
|
+
if (value.sessionFlags !== undefined) {
|
|
34
|
+
if (value.sessionFlags === null || typeof value.sessionFlags !== 'object') throw new Error('Invalid session flags');
|
|
35
|
+
for (const [sessionId, flags] of Object.entries(value.sessionFlags)) {
|
|
36
|
+
if (sessionId === '' || flags === null || typeof flags !== 'object') throw new Error('Invalid session flags');
|
|
37
|
+
if (flags.status !== undefined && !['active', 'archived', 'deleted'].includes(flags.status)) throw new Error('Invalid session status flag');
|
|
38
|
+
if (flags.pinnedAt !== undefined && typeof flags.pinnedAt !== 'string') throw new Error('Invalid session pin flag');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cindy Host's connection status — the single source of truth the settings
|
|
3
|
+
* card renders.
|
|
4
|
+
*
|
|
5
|
+
* The card never decides whether the Host is connected: it polls this snapshot
|
|
6
|
+
* and draws it. Every field here is produced by an observed event (an HTTP
|
|
7
|
+
* answer from Cindy, a relay frame, a socket close), never by a UI guess.
|
|
8
|
+
*
|
|
9
|
+
* State vocabulary is the six states the Settings card must show:
|
|
10
|
+
* disconnected the 连接手机 switch is off, or nothing has started
|
|
11
|
+
* authenticating a Cindy session is being read, refreshed, or logged in
|
|
12
|
+
* connecting a connection was lost and is being retried automatically
|
|
13
|
+
* waiting the relay is online; no phone has reached this Host yet
|
|
14
|
+
* connected a phone opened a link or invoked this Host
|
|
15
|
+
* failed a concrete failure; `message` says which
|
|
16
|
+
*
|
|
17
|
+
* `connecting` is the state that makes a dropped socket survivable. Without it a Host
|
|
18
|
+
* that lost the relay had nowhere honest to put "retrying" and reported `failed`, which
|
|
19
|
+
* on a phone-controlled Host reads as "DSH is gone" until somebody presses reconnect.
|
|
20
|
+
* The Cindy client has exactly this middle state (`stopped | connecting | online`) and
|
|
21
|
+
* never shows a terminal failure for an ordinary network loss.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Ordered state vocabulary; also the wire vocabulary of the status route. */
|
|
25
|
+
export const CONNECTION_STATES = Object.freeze(['disconnected', 'authenticating', 'connecting', 'waiting', 'connected', 'failed']);
|
|
26
|
+
|
|
27
|
+
/** Chinese labels for the card. Kept beside the states so both move together. */
|
|
28
|
+
export const STATE_LABELS = Object.freeze({
|
|
29
|
+
disconnected: '未连接',
|
|
30
|
+
authenticating: '登录中',
|
|
31
|
+
connecting: '正在重连',
|
|
32
|
+
waiting: '等待手机连接',
|
|
33
|
+
connected: '已连接',
|
|
34
|
+
failed: '连接失败',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** English labels, used when the page language is not Chinese. */
|
|
38
|
+
export const STATE_LABELS_EN = Object.freeze({
|
|
39
|
+
disconnected: 'Not connected',
|
|
40
|
+
authenticating: 'Signing in',
|
|
41
|
+
connecting: 'Reconnecting',
|
|
42
|
+
waiting: 'Waiting for phone',
|
|
43
|
+
connected: 'Connected',
|
|
44
|
+
failed: 'Connection failed',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** A device row the card may show; deviceId is the only required field. */
|
|
48
|
+
function normalizeDevice(deviceId, patch) {
|
|
49
|
+
return {
|
|
50
|
+
deviceId,
|
|
51
|
+
name: typeof patch.name === 'string' && patch.name !== '' ? patch.name : deviceId.slice(0, 8),
|
|
52
|
+
platform: typeof patch.platform === 'string' ? patch.platform : null,
|
|
53
|
+
online: patch.online === true,
|
|
54
|
+
isController: patch.isController === true,
|
|
55
|
+
linkedAt: patch.linkedAt ?? null,
|
|
56
|
+
lastSeenAt: patch.lastSeenAt ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a device row is a phone the user cares about. */
|
|
61
|
+
export function isMobilePlatform(platform) {
|
|
62
|
+
return platform === 'ios' || platform === 'android';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Holds the Host's current status and notifies subscribers on change.
|
|
67
|
+
*
|
|
68
|
+
* Notifications are coalesced by value: `publish` runs only when the snapshot
|
|
69
|
+
* actually differs, so a relay that repeats a presence frame does not wake the
|
|
70
|
+
* card's poll loop for nothing.
|
|
71
|
+
*/
|
|
72
|
+
export class HostStatus {
|
|
73
|
+
#listeners = new Set();
|
|
74
|
+
#devices = new Map();
|
|
75
|
+
#ownDeviceId = null;
|
|
76
|
+
#status;
|
|
77
|
+
|
|
78
|
+
/** @param options - injectable clock for deterministic tests. */
|
|
79
|
+
constructor({ now = () => new Date() } = {}) {
|
|
80
|
+
this.now = now;
|
|
81
|
+
this.#status = {
|
|
82
|
+
state: 'disconnected',
|
|
83
|
+
stateLabel: STATE_LABELS.disconnected,
|
|
84
|
+
message: null,
|
|
85
|
+
login: { authenticated: false, required: false, kind: null, identifier: null },
|
|
86
|
+
host: null,
|
|
87
|
+
devices: [],
|
|
88
|
+
updatedAt: this.now().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param listener - invoked after each replacement snapshot. @returns disposer. */
|
|
93
|
+
subscribe(listener) {
|
|
94
|
+
this.#listeners.add(listener);
|
|
95
|
+
return () => this.#listeners.delete(listener);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @returns a deep copy; callers may serialize it freely. */
|
|
99
|
+
snapshot() {
|
|
100
|
+
return {
|
|
101
|
+
...this.#status,
|
|
102
|
+
login: { ...this.#status.login },
|
|
103
|
+
host: this.#status.host === null ? null : { ...this.#status.host },
|
|
104
|
+
devices: this.#status.devices.map((device) => ({ ...device })),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The relay-assigned device id of this Host, excluded from the device list. */
|
|
109
|
+
get ownDeviceId() {
|
|
110
|
+
return this.#ownDeviceId;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#publish(patch) {
|
|
114
|
+
const previous = JSON.stringify({ ...this.#status, updatedAt: '' });
|
|
115
|
+
const next = { ...this.#status, ...patch };
|
|
116
|
+
next.devices = [...this.#devices.values()]
|
|
117
|
+
.filter((device) => device.deviceId !== this.#ownDeviceId)
|
|
118
|
+
.sort(compareDevices);
|
|
119
|
+
next.stateLabel = STATE_LABELS[next.state] ?? next.state;
|
|
120
|
+
if (JSON.stringify({ ...next, updatedAt: '' }) === previous) return;
|
|
121
|
+
next.updatedAt = this.now().toISOString();
|
|
122
|
+
this.#status = next;
|
|
123
|
+
const snapshot = this.snapshot();
|
|
124
|
+
for (const listener of [...this.#listeners]) {
|
|
125
|
+
try {
|
|
126
|
+
listener(snapshot);
|
|
127
|
+
} catch {
|
|
128
|
+
// A broken observer must not corrupt the Host's own status.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Move to a state, replacing the failure message (or clearing it). */
|
|
134
|
+
setState(state, message = null) {
|
|
135
|
+
if (!CONNECTION_STATES.includes(state)) throw new Error(`Unknown Cindy host state: ${String(state)}`);
|
|
136
|
+
this.#publish({ state, message });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Record what the Host knows about the Cindy account behind the relay. */
|
|
140
|
+
setLogin({ authenticated, required = false, kind = null, identifier = null }) {
|
|
141
|
+
this.#publish({ login: { authenticated: authenticated === true, required: required === true, kind, identifier } });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Record this Host's relay identity from `hello-ack`, or clear it. */
|
|
145
|
+
setHost(host) {
|
|
146
|
+
if (host === null) {
|
|
147
|
+
this.#ownDeviceId = null;
|
|
148
|
+
this.#publish({ host: null });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.#ownDeviceId = typeof host.deviceId === 'string' ? host.deviceId : null;
|
|
152
|
+
this.#publish({ host: { deviceName: 'DSH Host', platform: 'desktop', appVersion: '0.1.0', online: true, protocolVersion: null, userId: null, ...host } });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Insert or merge one device row, preserving fields the patch omits. */
|
|
156
|
+
upsertDevice(deviceId, patch = {}) {
|
|
157
|
+
if (typeof deviceId !== 'string' || deviceId === '') return;
|
|
158
|
+
const existing = this.#devices.get(deviceId);
|
|
159
|
+
this.#devices.set(deviceId, normalizeDevice(deviceId, { ...existing, ...patch }));
|
|
160
|
+
this.#publish({});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Drop one device row (e.g. it left the account). */
|
|
164
|
+
removeDevice(deviceId) {
|
|
165
|
+
if (!this.#devices.delete(deviceId)) return;
|
|
166
|
+
this.#publish({});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Forget every device and accepted controller — the switch-off reset. */
|
|
170
|
+
clearDevices() {
|
|
171
|
+
if (this.#devices.size === 0) return;
|
|
172
|
+
this.#devices.clear();
|
|
173
|
+
this.#publish({});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether any device has actually reached this Host. */
|
|
177
|
+
get hasLinkedController() {
|
|
178
|
+
for (const device of this.#devices.values()) if (device.isController) return true;
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Re-derive `connected`/`waiting` from the device ledger.
|
|
184
|
+
*
|
|
185
|
+
* Called after a controller arrives or leaves; never called while the Host is
|
|
186
|
+
* mid-handshake or failed, so a late frame cannot mask a real failure.
|
|
187
|
+
*/
|
|
188
|
+
refreshConnectionState() {
|
|
189
|
+
if (this.#status.state === 'disconnected' || this.#status.state === 'failed') return;
|
|
190
|
+
if (this.hasLinkedController) this.setState('connected');
|
|
191
|
+
else if (this.#ownDeviceId !== null) this.setState('waiting');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Reset everything for a switch-off, in one publish. */
|
|
195
|
+
reset() {
|
|
196
|
+
this.#devices.clear();
|
|
197
|
+
this.#ownDeviceId = null;
|
|
198
|
+
this.#publish({
|
|
199
|
+
state: 'disconnected',
|
|
200
|
+
message: null,
|
|
201
|
+
host: null,
|
|
202
|
+
login: { authenticated: false, required: false, kind: null, identifier: null },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Phones first, then controllers, then by name and id for a stable order. */
|
|
208
|
+
function compareDevices(left, right) {
|
|
209
|
+
return (
|
|
210
|
+
Number(isMobilePlatform(right.platform)) - Number(isMobilePlatform(left.platform)) ||
|
|
211
|
+
Number(right.isController) - Number(left.isController) ||
|
|
212
|
+
Number(right.online) - Number(left.online) ||
|
|
213
|
+
String(left.name).localeCompare(String(right.name)) ||
|
|
214
|
+
left.deviceId.localeCompare(right.deviceId)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Bounded observation only: never authority, work scheduling, credentials or file contents. */
|
|
4
|
+
export function createTransferDiagnostics({ now = Date.now, limit = 128 } = {}) {
|
|
5
|
+
const capacity = Number.isSafeInteger(limit) && limit > 0 ? Math.min(128, limit) : 128;
|
|
6
|
+
const events = [];
|
|
7
|
+
const counts = Object.create(null);
|
|
8
|
+
const progress = new Map();
|
|
9
|
+
let salt = randomBytes(32);
|
|
10
|
+
const digest = (kind, value) => createHmac('sha256', salt).update(JSON.stringify([kind, value])).digest('hex');
|
|
11
|
+
const identify = ({ actor, owner, real, info } = {}) => ({
|
|
12
|
+
...(typeof actor === 'string' && actor.length <= 256 ? { actorId: digest('actor', actor) } : {}),
|
|
13
|
+
...(owner ? { ownerId: digest('owner', [owner.runtimeId, owner.epoch, owner.realm, owner.userId]) } : {}),
|
|
14
|
+
...(typeof real === 'string' && real.length <= 32768 && info ?
|
|
15
|
+
{ sourceId: digest('source', [real, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs]) } : {}),
|
|
16
|
+
});
|
|
17
|
+
const names = new Set(['start', 'accepted', 'opening', 'presign', 'put', 'progress', 'cleanup', 'done', 'error', 'cancelled', 'offer-answer', 'dc-open', 'file-open', 'bytes-queued', 'eof-queued', 'closed']);
|
|
18
|
+
function record(input = {}) {
|
|
19
|
+
const transport = input.transport === 'peer' ? 'peer' : 'oss';
|
|
20
|
+
const event = names.has(input.event) ? input.event : 'other';
|
|
21
|
+
const key = transport + ':' + event;
|
|
22
|
+
counts[key] = Math.min(Number.MAX_SAFE_INTEGER, (counts[key] ?? 0) + 1);
|
|
23
|
+
const at = Number(now());
|
|
24
|
+
const identity = typeof input.transferId === 'string' ? input.transferId : input.connection;
|
|
25
|
+
if (event === 'progress' || event === 'bytes-queued') {
|
|
26
|
+
if (at - (progress.get(identity) ?? -Infinity) < 1000) return;
|
|
27
|
+
if (progress.size >= 64) progress.delete(progress.keys().next().value);
|
|
28
|
+
progress.set(identity, at);
|
|
29
|
+
}
|
|
30
|
+
const row = { transport, event, at };
|
|
31
|
+
const stages = new Set(['validate', 'validation', 'opening', 'verify', 'credential', 'presign', 'refresh', 'put', 'upload', 'close', 'cleanup', 'terminal']);
|
|
32
|
+
const codes = new Set(['INVALID_ARGUMENT', 'OVERSIZE', 'SOURCE_CHANGED', 'OWNER_UNVERIFIED', 'PRESIGN_FAILED', 'AUTH_FAILED', 'SOURCE_READ_FAILED', 'SIZE_MISMATCH', 'PUT_FAILED', 'INCOMPLETE_UPLOAD', 'NETWORK_ERROR', 'ABORTED', 'STAGE_TIMEOUT', 'IDLE_TIMEOUT', 'CANCELLED', 'UPLOAD_FAILED', 'FORBIDDEN', 'NOT_FOUND', 'FILE_PEER_CLOSED', 'FILE_PEER_TIMEOUT', 'FILE_PEER_CHANGED', 'FILE_PEER_REVOKED', 'FILE_PEER_BLOCK', 'FILE_PEER_DENIED']);
|
|
33
|
+
if (stages.has(input.stage)) row.stage = input.stage;
|
|
34
|
+
if (codes.has(input.code)) row.code = input.code;
|
|
35
|
+
for (const field of ['actorId', 'ownerId', 'sourceId']) {
|
|
36
|
+
if (typeof input[field] === 'string' && /^[a-f0-9]{64}$/.test(input[field])) row[field] = input[field];
|
|
37
|
+
}
|
|
38
|
+
// All caller-provided free text is excluded. IDs are opaque generated UUIDs, not actor IDs.
|
|
39
|
+
for (const field of ['transferId', 'connection']) {
|
|
40
|
+
if (typeof input[field] === 'string' && /^(exp_)?[a-f0-9-]{16,64}$/.test(input[field])) row[field] = input[field];
|
|
41
|
+
}
|
|
42
|
+
for (const field of ['size', 'uploaded', 'queuedBytes', 'status']) {
|
|
43
|
+
if (Number.isSafeInteger(input[field]) && input[field] >= 0) row[field] = input[field];
|
|
44
|
+
}
|
|
45
|
+
events.push(row);
|
|
46
|
+
if (events.length > capacity) events.splice(0, events.length - capacity);
|
|
47
|
+
}
|
|
48
|
+
return { record, identify, clear() { salt = randomBytes(32); events.length = 0; progress.clear(); for (const k of Object.keys(counts)) delete counts[k]; },
|
|
49
|
+
snapshot: () => ({ counters: { ...counts }, events: events.map(row => ({ ...row })) }) };
|
|
50
|
+
}
|