@zhihand/mcp 0.30.0 → 0.32.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/bin/zhihand +316 -129
- package/dist/core/command.js +4 -3
- package/dist/core/config.d.ts +35 -20
- package/dist/core/config.js +129 -55
- package/dist/core/device.d.ts +3 -3
- package/dist/core/device.js +22 -14
- package/dist/core/logger.d.ts +17 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/pair.d.ts +39 -31
- package/dist/core/pair.js +188 -84
- package/dist/core/registry.d.ts +23 -30
- package/dist/core/registry.js +321 -194
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +32 -7
- package/dist/core/sse.js +90 -22
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.js +1 -1
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +4 -4
- package/dist/daemon/prompt-listener.d.ts +5 -6
- package/dist/daemon/prompt-listener.js +58 -94
- package/dist/index.d.ts +1 -1
- package/dist/index.js +18 -16
- package/dist/tools/control.js +1 -1
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -25
- package/dist/tools/system.js +1 -1
- package/package.json +3 -1
- package/README.md +0 -359
package/dist/core/registry.js
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Device registry — the single source of truth for all paired devices,
|
|
3
|
-
* their live state
|
|
4
|
-
* device routing.
|
|
3
|
+
* their live state, and multi-user WebSocket streams.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Groups devices under users. Each user has one UserEventWebSocket.
|
|
6
|
+
* Online detection is server-authoritative (no local heartbeat polling).
|
|
7
|
+
* Config hot-reload via fs.watchFile.
|
|
8
8
|
*/
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const ONLINE_PROFILE_TTL_MS = 60_000;
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { loadConfig, getConfigPath, resolveDefaultEndpoint, addDeviceToUser as configAddDeviceToUser, updateDeviceLastSeen as configUpdateDeviceLastSeen, } from "./config.js";
|
|
11
|
+
import { extractStatic, computeCapabilities, normalizeProfilePayload, } from "./device.js";
|
|
12
|
+
import { UserEventWebSocket, fetchUserCredentials, } from "./ws.js";
|
|
13
|
+
import { log } from "./logger.js";
|
|
15
14
|
const LIST_CHANGED_DEBOUNCE_MS = 2500;
|
|
15
|
+
const CONFIG_WATCH_INTERVAL_MS = 1000;
|
|
16
|
+
const CONFIG_RECONCILE_DEBOUNCE_MS = 300;
|
|
16
17
|
class Registry {
|
|
17
|
-
|
|
18
|
+
userStates = new Map();
|
|
18
19
|
listChangedSubs = new Set();
|
|
19
20
|
debounceTimer = null;
|
|
20
21
|
lastOnlineSet = new Set();
|
|
21
22
|
initialized = false;
|
|
23
|
+
configWatchActive = false;
|
|
24
|
+
reconcileTimer = null;
|
|
25
|
+
// ── Public API ──────────────────────────────────────────
|
|
22
26
|
get(credentialId) {
|
|
23
|
-
|
|
27
|
+
for (const us of this.userStates.values()) {
|
|
28
|
+
const d = us.devices.get(credentialId);
|
|
29
|
+
if (d)
|
|
30
|
+
return d;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
24
33
|
}
|
|
25
34
|
list() {
|
|
26
|
-
|
|
35
|
+
const all = [];
|
|
36
|
+
for (const us of this.userStates.values()) {
|
|
37
|
+
for (const d of us.devices.values()) {
|
|
38
|
+
all.push(d);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return all;
|
|
27
42
|
}
|
|
28
43
|
listOnline() {
|
|
29
44
|
return this.list()
|
|
@@ -31,38 +46,82 @@ class Registry {
|
|
|
31
46
|
.sort((a, b) => b.lastSeenAtMs - a.lastSeenAtMs);
|
|
32
47
|
}
|
|
33
48
|
/**
|
|
34
|
-
*
|
|
35
|
-
* 1. If the user has explicitly set a default via `zhihand default <id>`
|
|
36
|
-
* AND that device is online → return it. Honoring an explicit user
|
|
37
|
-
* preference is the least-surprising UX.
|
|
38
|
-
* 2. Otherwise → most-recently-active online device (online[0] is sorted
|
|
39
|
-
* desc by lastSeenAtMs).
|
|
40
|
-
* 3. No online devices → null.
|
|
49
|
+
* Most-recently-active online device across all users.
|
|
41
50
|
*/
|
|
42
51
|
resolveDefault() {
|
|
43
52
|
const online = this.listOnline();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const d = this.devices.get(cfg.default_credential_id);
|
|
49
|
-
if (d && d.online)
|
|
50
|
-
return d;
|
|
51
|
-
}
|
|
52
|
-
return online[0];
|
|
53
|
+
return online.length > 0 ? online[0] : null;
|
|
54
|
+
}
|
|
55
|
+
isMultiUser() {
|
|
56
|
+
return this.userStates.size > 1;
|
|
53
57
|
}
|
|
54
58
|
toRuntimeConfig(state) {
|
|
55
|
-
|
|
59
|
+
const us = this.userStates.get(state.userId);
|
|
60
|
+
return {
|
|
61
|
+
controlPlaneEndpoint: us?.endpoint ?? resolveDefaultEndpoint(),
|
|
62
|
+
credentialId: state.credentialId,
|
|
63
|
+
controllerToken: us?.controllerToken ?? "",
|
|
64
|
+
timeoutMs: 10_000,
|
|
65
|
+
};
|
|
56
66
|
}
|
|
57
67
|
subscribe(cb) {
|
|
58
68
|
this.listChangedSubs.add(cb);
|
|
59
69
|
return () => this.listChangedSubs.delete(cb);
|
|
60
70
|
}
|
|
71
|
+
// ── Init / Shutdown ────────────────────────────────────
|
|
72
|
+
async init() {
|
|
73
|
+
if (this.initialized)
|
|
74
|
+
return;
|
|
75
|
+
this.initialized = true;
|
|
76
|
+
const cfg = loadConfig();
|
|
77
|
+
const endpoint = resolveDefaultEndpoint();
|
|
78
|
+
const users = Object.values(cfg.users);
|
|
79
|
+
// Create user states and start WS streams
|
|
80
|
+
const fetchPromises = [];
|
|
81
|
+
for (const userRec of users) {
|
|
82
|
+
const us = this.createUserState(userRec, endpoint);
|
|
83
|
+
this.userStates.set(userRec.user_id, us);
|
|
84
|
+
this.startUserStream(us);
|
|
85
|
+
// Fetch initial credentials with online status
|
|
86
|
+
fetchPromises.push(this.fetchAndPopulateDevices(us, userRec).catch(() => {
|
|
87
|
+
// Non-fatal: populate from config alone
|
|
88
|
+
this.populateDevicesFromConfig(us, userRec);
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
// Overall 5s cap on initial fetches
|
|
92
|
+
await Promise.race([
|
|
93
|
+
Promise.all(fetchPromises),
|
|
94
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
95
|
+
]);
|
|
96
|
+
// Start config hot-reload watcher
|
|
97
|
+
this.startConfigWatch();
|
|
98
|
+
}
|
|
99
|
+
shutdown() {
|
|
100
|
+
for (const us of this.userStates.values()) {
|
|
101
|
+
us.stream?.stop();
|
|
102
|
+
us.stream = null;
|
|
103
|
+
}
|
|
104
|
+
this.userStates.clear();
|
|
105
|
+
if (this.debounceTimer) {
|
|
106
|
+
clearTimeout(this.debounceTimer);
|
|
107
|
+
this.debounceTimer = null;
|
|
108
|
+
}
|
|
109
|
+
if (this.reconcileTimer) {
|
|
110
|
+
clearTimeout(this.reconcileTimer);
|
|
111
|
+
this.reconcileTimer = null;
|
|
112
|
+
}
|
|
113
|
+
this.stopConfigWatch();
|
|
114
|
+
this.listChangedSubs.clear();
|
|
115
|
+
this.initialized = false;
|
|
116
|
+
}
|
|
117
|
+
// ── Online set change detection ─────────────────────────
|
|
61
118
|
computeOnlineSet() {
|
|
62
119
|
const out = new Set();
|
|
63
|
-
for (const
|
|
64
|
-
|
|
65
|
-
|
|
120
|
+
for (const us of this.userStates.values()) {
|
|
121
|
+
for (const d of us.devices.values()) {
|
|
122
|
+
if (d.online)
|
|
123
|
+
out.add(d.credentialId);
|
|
124
|
+
}
|
|
66
125
|
}
|
|
67
126
|
return out;
|
|
68
127
|
}
|
|
@@ -76,9 +135,8 @@ class Registry {
|
|
|
76
135
|
}
|
|
77
136
|
scheduleListChanged() {
|
|
78
137
|
const now = this.computeOnlineSet();
|
|
79
|
-
if (this.setsEqual(now, this.lastOnlineSet))
|
|
138
|
+
if (this.setsEqual(now, this.lastOnlineSet))
|
|
80
139
|
return;
|
|
81
|
-
}
|
|
82
140
|
if (this.debounceTimer)
|
|
83
141
|
return;
|
|
84
142
|
this.debounceTimer = setTimeout(() => {
|
|
@@ -95,194 +153,263 @@ class Registry {
|
|
|
95
153
|
}
|
|
96
154
|
}, LIST_CHANGED_DEBOUNCE_MS);
|
|
97
155
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
// ── User/Device state management ───────────────────────
|
|
157
|
+
createUserState(userRec, endpoint) {
|
|
158
|
+
return {
|
|
159
|
+
userId: userRec.user_id,
|
|
160
|
+
controllerToken: userRec.controller_token,
|
|
161
|
+
label: userRec.label,
|
|
162
|
+
endpoint,
|
|
163
|
+
stream: null,
|
|
164
|
+
devices: new Map(),
|
|
165
|
+
};
|
|
107
166
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
167
|
+
makeDeviceState(record, userId, userLabel, online = false) {
|
|
168
|
+
return {
|
|
169
|
+
credentialId: record.credential_id,
|
|
170
|
+
userId,
|
|
171
|
+
userLabel,
|
|
172
|
+
label: record.label,
|
|
173
|
+
platform: record.platform,
|
|
174
|
+
online,
|
|
175
|
+
lastSeenAtMs: 0,
|
|
176
|
+
profile: null,
|
|
177
|
+
capabilities: null,
|
|
178
|
+
profileReceivedAtMs: 0,
|
|
179
|
+
rawAttributes: {},
|
|
180
|
+
record,
|
|
181
|
+
};
|
|
118
182
|
}
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.scheduleListChanged();
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
state.rawAttributes = result.rawAttrs;
|
|
128
|
-
state.profileReceivedAtMs = result.receivedAtMs;
|
|
129
|
-
state.profile = extractStatic(result.rawAttrs);
|
|
130
|
-
state.capabilities = computeCapabilities(result.rawAttrs, result.receivedAtMs);
|
|
131
|
-
// Infer platform from profile
|
|
132
|
-
const plat = state.profile.platform;
|
|
133
|
-
if (plat === "ios" || plat === "android") {
|
|
134
|
-
state.platform = plat;
|
|
135
|
-
state.record.platform = plat;
|
|
183
|
+
populateDevicesFromConfig(us, userRec) {
|
|
184
|
+
for (const dev of userRec.devices) {
|
|
185
|
+
if (!us.devices.has(dev.credential_id)) {
|
|
186
|
+
us.devices.set(dev.credential_id, this.makeDeviceState(dev, us.userId, us.label));
|
|
187
|
+
}
|
|
136
188
|
}
|
|
137
|
-
this.touchLastSeen(state);
|
|
138
|
-
this.updateOnlineFlag(state);
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
startHeartbeat(state) {
|
|
142
|
-
if (state.heartbeatTimer)
|
|
143
|
-
return;
|
|
144
|
-
state.heartbeatTimer = setInterval(() => {
|
|
145
|
-
this.refreshProfile(state).catch(() => { });
|
|
146
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
147
189
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
190
|
+
async fetchAndPopulateDevices(us, userRec) {
|
|
191
|
+
const creds = await fetchUserCredentials(us.endpoint, us.userId, us.controllerToken);
|
|
192
|
+
// Populate from server response (has online status)
|
|
193
|
+
for (const cred of creds) {
|
|
194
|
+
const existing = us.devices.get(cred.credential_id);
|
|
195
|
+
if (existing) {
|
|
196
|
+
existing.online = cred.online ?? false;
|
|
197
|
+
if (cred.online) {
|
|
198
|
+
existing.lastSeenAtMs = Date.now();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const record = {
|
|
203
|
+
credential_id: cred.credential_id,
|
|
204
|
+
label: cred.label ?? cred.credential_id,
|
|
205
|
+
platform: cred.platform ?? "unknown",
|
|
206
|
+
paired_at: cred.paired_at ?? new Date().toISOString(),
|
|
207
|
+
last_seen_at: cred.last_seen_at ?? new Date().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
const state = this.makeDeviceState(record, us.userId, us.label, cred.online ?? false);
|
|
210
|
+
if (cred.online)
|
|
211
|
+
state.lastSeenAtMs = Date.now();
|
|
212
|
+
us.devices.set(cred.credential_id, state);
|
|
213
|
+
}
|
|
152
214
|
}
|
|
215
|
+
// Also ensure all config devices are present
|
|
216
|
+
this.populateDevicesFromConfig(us, userRec);
|
|
153
217
|
}
|
|
154
|
-
|
|
155
|
-
|
|
218
|
+
// ── WS stream management ──────────────────────────────
|
|
219
|
+
startUserStream(us) {
|
|
220
|
+
if (us.stream)
|
|
156
221
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
222
|
+
us.stream = new UserEventWebSocket(us.userId, us.controllerToken, us.endpoint, {
|
|
223
|
+
onDeviceOnline: (credentialId) => {
|
|
224
|
+
const d = us.devices.get(credentialId);
|
|
225
|
+
if (d) {
|
|
226
|
+
d.online = true;
|
|
227
|
+
d.lastSeenAtMs = Date.now();
|
|
228
|
+
this.touchLastSeen(d);
|
|
229
|
+
log.debug(`[registry] ${credentialId} online`);
|
|
230
|
+
this.scheduleListChanged();
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
onDeviceOffline: (credentialId) => {
|
|
234
|
+
const d = us.devices.get(credentialId);
|
|
235
|
+
if (d) {
|
|
236
|
+
d.online = false;
|
|
237
|
+
log.debug(`[registry] ${credentialId} offline`);
|
|
238
|
+
this.scheduleListChanged();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
onDeviceProfileUpdated: (credentialId, profile) => {
|
|
242
|
+
const d = us.devices.get(credentialId);
|
|
243
|
+
if (d) {
|
|
244
|
+
const attrs = normalizeProfilePayload(profile);
|
|
245
|
+
d.rawAttributes = attrs;
|
|
246
|
+
d.profileReceivedAtMs = Date.now();
|
|
247
|
+
d.profile = extractStatic(attrs);
|
|
248
|
+
d.capabilities = computeCapabilities(attrs, d.profileReceivedAtMs);
|
|
249
|
+
const plat = d.profile.platform;
|
|
169
250
|
if (plat === "ios" || plat === "android") {
|
|
170
|
-
|
|
171
|
-
|
|
251
|
+
d.platform = plat;
|
|
252
|
+
d.record.platform = plat;
|
|
172
253
|
}
|
|
173
|
-
this.touchLastSeen(
|
|
174
|
-
this.updateOnlineFlag(state);
|
|
254
|
+
this.touchLastSeen(d);
|
|
175
255
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
256
|
+
},
|
|
257
|
+
onCommandAcked: (event) => {
|
|
258
|
+
// Already handled by handleWSEvent in the stream dispatch
|
|
259
|
+
},
|
|
260
|
+
onCredentialAdded: (credential) => {
|
|
261
|
+
const credId = credential.credential_id;
|
|
262
|
+
if (credId && !us.devices.has(credId)) {
|
|
263
|
+
const record = {
|
|
264
|
+
credential_id: credId,
|
|
265
|
+
label: credential.label ?? credId,
|
|
266
|
+
platform: (credential.platform ?? "unknown"),
|
|
267
|
+
paired_at: credential.paired_at ?? new Date().toISOString(),
|
|
268
|
+
last_seen_at: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
const state = this.makeDeviceState(record, us.userId, us.label, true);
|
|
271
|
+
state.lastSeenAtMs = Date.now();
|
|
272
|
+
us.devices.set(credId, state);
|
|
273
|
+
// Persist to config immediately so fs.watchFile reconciliation
|
|
274
|
+
// doesn't wipe this device before the pairing CLI writes it.
|
|
275
|
+
try {
|
|
276
|
+
configAddDeviceToUser(us.userId, record);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// non-fatal — pairing CLI will persist shortly
|
|
280
|
+
}
|
|
179
281
|
this.scheduleListChanged();
|
|
180
282
|
}
|
|
181
283
|
},
|
|
284
|
+
onCredentialRemoved: (credentialId) => {
|
|
285
|
+
us.devices.delete(credentialId);
|
|
286
|
+
this.scheduleListChanged();
|
|
287
|
+
},
|
|
182
288
|
onConnected: () => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
289
|
+
log.debug(`[registry] WS connected for user ${us.userId}`);
|
|
290
|
+
// Re-fetch credentials to reconcile missed events
|
|
291
|
+
fetchUserCredentials(us.endpoint, us.userId, us.controllerToken)
|
|
292
|
+
.then((creds) => {
|
|
293
|
+
const onlineIds = new Set(creds.filter((c) => c.online).map((c) => c.credential_id));
|
|
294
|
+
for (const d of us.devices.values()) {
|
|
295
|
+
const wasOnline = d.online;
|
|
296
|
+
d.online = onlineIds.has(d.credentialId);
|
|
297
|
+
if (d.online)
|
|
298
|
+
d.lastSeenAtMs = Date.now();
|
|
299
|
+
if (wasOnline !== d.online) {
|
|
300
|
+
this.scheduleListChanged();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
.catch(() => { });
|
|
186
305
|
},
|
|
187
306
|
onDisconnected: () => {
|
|
188
|
-
|
|
189
|
-
state.sseConnected = false;
|
|
190
|
-
this.updateOnlineFlag(state);
|
|
307
|
+
log.debug(`[registry] WS disconnected for user ${us.userId}`);
|
|
191
308
|
},
|
|
192
309
|
});
|
|
310
|
+
us.stream.start();
|
|
193
311
|
}
|
|
194
|
-
|
|
195
|
-
state.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
platform: record.platform,
|
|
204
|
-
online: false,
|
|
205
|
-
lastSeenAtMs: 0,
|
|
206
|
-
profile: null,
|
|
207
|
-
capabilities: null,
|
|
208
|
-
profileReceivedAtMs: 0,
|
|
209
|
-
rawAttributes: {},
|
|
210
|
-
sseController: null,
|
|
211
|
-
sseConnected: false,
|
|
212
|
-
heartbeatTimer: null,
|
|
213
|
-
record,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
async init() {
|
|
217
|
-
if (this.initialized)
|
|
218
|
-
return;
|
|
219
|
-
this.initialized = true;
|
|
220
|
-
const cfg = loadConfig();
|
|
221
|
-
const records = Object.values(cfg.devices);
|
|
222
|
-
for (const r of records) {
|
|
223
|
-
const s = this.makeState(r);
|
|
224
|
-
this.devices.set(r.credential_id, s);
|
|
225
|
-
this.startSSE(s);
|
|
226
|
-
this.startHeartbeat(s);
|
|
312
|
+
touchLastSeen(state) {
|
|
313
|
+
state.lastSeenAtMs = Date.now();
|
|
314
|
+
const iso = new Date(state.lastSeenAtMs).toISOString();
|
|
315
|
+
try {
|
|
316
|
+
configUpdateDeviceLastSeen(state.userId, state.credentialId, iso);
|
|
317
|
+
state.record.last_seen_at = iso;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// non-fatal
|
|
227
321
|
}
|
|
228
|
-
// Fire off initial profile fetches in parallel, with overall ~5s cap
|
|
229
|
-
const fetches = records.map((r) => {
|
|
230
|
-
const s = this.devices.get(r.credential_id);
|
|
231
|
-
return this.refreshProfile(s).catch(() => false);
|
|
232
|
-
});
|
|
233
|
-
await Promise.race([
|
|
234
|
-
Promise.all(fetches),
|
|
235
|
-
new Promise((r) => setTimeout(r, 5000)),
|
|
236
|
-
]);
|
|
237
322
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
323
|
+
// ── Config hot-reload ─────────────────────────────────
|
|
324
|
+
startConfigWatch() {
|
|
325
|
+
if (this.configWatchActive)
|
|
326
|
+
return;
|
|
327
|
+
this.configWatchActive = true;
|
|
328
|
+
const configPath = getConfigPath();
|
|
329
|
+
try {
|
|
330
|
+
fs.watchFile(configPath, { interval: CONFIG_WATCH_INTERVAL_MS }, () => {
|
|
331
|
+
// Debounce reconciliation
|
|
332
|
+
if (this.reconcileTimer)
|
|
333
|
+
clearTimeout(this.reconcileTimer);
|
|
334
|
+
this.reconcileTimer = setTimeout(() => {
|
|
335
|
+
this.reconcileTimer = null;
|
|
336
|
+
this.reconcileConfig();
|
|
337
|
+
}, CONFIG_RECONCILE_DEBOUNCE_MS);
|
|
338
|
+
});
|
|
244
339
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
s.label = record.label;
|
|
340
|
+
catch {
|
|
341
|
+
// watchFile not available — skip hot-reload
|
|
248
342
|
}
|
|
249
|
-
this.startSSE(s);
|
|
250
|
-
this.startHeartbeat(s);
|
|
251
|
-
await this.refreshProfile(s).catch(() => false);
|
|
252
343
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
344
|
+
stopConfigWatch() {
|
|
345
|
+
if (!this.configWatchActive)
|
|
346
|
+
return;
|
|
347
|
+
this.configWatchActive = false;
|
|
348
|
+
try {
|
|
349
|
+
fs.unwatchFile(getConfigPath());
|
|
259
350
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
renameDevice(credentialId, label) {
|
|
264
|
-
configRenameDevice(credentialId, label);
|
|
265
|
-
const s = this.devices.get(credentialId);
|
|
266
|
-
if (s) {
|
|
267
|
-
s.label = label;
|
|
268
|
-
s.record.label = label;
|
|
351
|
+
catch {
|
|
352
|
+
// ignore
|
|
269
353
|
}
|
|
270
354
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
355
|
+
reconcileConfig() {
|
|
356
|
+
const cfg = loadConfig();
|
|
357
|
+
const endpoint = resolveDefaultEndpoint();
|
|
358
|
+
const configUserIds = new Set(Object.keys(cfg.users));
|
|
359
|
+
// Remove users no longer in config
|
|
360
|
+
for (const [userId, us] of this.userStates) {
|
|
361
|
+
if (!configUserIds.has(userId)) {
|
|
362
|
+
us.stream?.stop();
|
|
363
|
+
us.stream = null;
|
|
364
|
+
this.userStates.delete(userId);
|
|
365
|
+
log.debug(`[registry] Removed user ${userId} (config hot-reload)`);
|
|
366
|
+
}
|
|
278
367
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
368
|
+
// Add new users / reconcile devices
|
|
369
|
+
for (const userRec of Object.values(cfg.users)) {
|
|
370
|
+
let us = this.userStates.get(userRec.user_id);
|
|
371
|
+
if (!us) {
|
|
372
|
+
// New user
|
|
373
|
+
us = this.createUserState(userRec, endpoint);
|
|
374
|
+
this.userStates.set(userRec.user_id, us);
|
|
375
|
+
this.populateDevicesFromConfig(us, userRec);
|
|
376
|
+
this.startUserStream(us);
|
|
377
|
+
log.debug(`[registry] Added user ${userRec.user_id} (config hot-reload)`);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
// Update token/label if changed
|
|
381
|
+
us.controllerToken = userRec.controller_token;
|
|
382
|
+
us.label = userRec.label;
|
|
383
|
+
// Reconcile devices
|
|
384
|
+
const configDevIds = new Set(userRec.devices.map((d) => d.credential_id));
|
|
385
|
+
// Remove devices no longer in config — but keep online devices
|
|
386
|
+
// that were added via WS credential.added but not yet persisted
|
|
387
|
+
// by the pairing CLI (race window).
|
|
388
|
+
for (const credId of us.devices.keys()) {
|
|
389
|
+
if (!configDevIds.has(credId)) {
|
|
390
|
+
const d = us.devices.get(credId);
|
|
391
|
+
if (d && d.online) {
|
|
392
|
+
log.debug(`[registry] Keeping online device ${credId} despite missing from config`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
us.devices.delete(credId);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Add new devices
|
|
399
|
+
for (const dev of userRec.devices) {
|
|
400
|
+
if (!us.devices.has(dev.credential_id)) {
|
|
401
|
+
us.devices.set(dev.credential_id, this.makeDeviceState(dev, us.userId, us.label));
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
// Update label/platform from config
|
|
405
|
+
const d = us.devices.get(dev.credential_id);
|
|
406
|
+
d.label = dev.label;
|
|
407
|
+
d.userLabel = us.label;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
283
411
|
}
|
|
284
|
-
this.
|
|
285
|
-
this.initialized = false;
|
|
412
|
+
this.scheduleListChanged();
|
|
286
413
|
}
|
|
287
414
|
}
|
|
288
415
|
export const registry = new Registry();
|
package/dist/core/screenshot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
const dbg = (msg) => log.debug(msg);
|
|
2
3
|
// Snapshot is considered stale if the server-reported age exceeds this
|
|
3
4
|
// threshold. Configurable via env ZHIHAND_SNAPSHOT_MAX_AGE_MS.
|
|
4
5
|
// Default 5s: typical HID command + capture + upload is well under 2s;
|
|
@@ -29,7 +30,7 @@ export async function fetchScreenshot(config) {
|
|
|
29
30
|
const response = await fetch(url, {
|
|
30
31
|
method: "GET",
|
|
31
32
|
headers: {
|
|
32
|
-
"
|
|
33
|
+
"Authorization": `Bearer ${config.controllerToken}`,
|
|
33
34
|
"Accept": "image/jpeg",
|
|
34
35
|
},
|
|
35
36
|
signal: controller.signal,
|
package/dist/core/sse.d.ts
CHANGED
|
@@ -7,20 +7,45 @@ export interface SSEEvent {
|
|
|
7
7
|
credential_id: string;
|
|
8
8
|
command?: QueuedCommandRecord;
|
|
9
9
|
device_profile?: Record<string, unknown>;
|
|
10
|
+
credential?: Record<string, unknown>;
|
|
10
11
|
sequence: number;
|
|
11
12
|
}
|
|
12
13
|
export declare function handleSSEEvent(event: SSEEvent): void;
|
|
13
14
|
export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
|
|
14
|
-
export interface
|
|
15
|
-
|
|
15
|
+
export interface UserEventStreamHandlers {
|
|
16
|
+
onDeviceOnline: (credentialId: string) => void;
|
|
17
|
+
onDeviceOffline: (credentialId: string) => void;
|
|
18
|
+
onDeviceProfileUpdated: (credentialId: string, profile: Record<string, unknown>) => void;
|
|
19
|
+
onCommandAcked: (event: SSEEvent) => void;
|
|
20
|
+
onCredentialAdded: (credential: Record<string, unknown>) => void;
|
|
21
|
+
onCredentialRemoved: (credentialId: string) => void;
|
|
16
22
|
onConnected: () => void;
|
|
17
23
|
onDisconnected: () => void;
|
|
18
24
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
export declare class UserEventStream {
|
|
26
|
+
private userId;
|
|
27
|
+
private controllerToken;
|
|
28
|
+
private endpoint;
|
|
29
|
+
private handlers;
|
|
30
|
+
private abortController;
|
|
31
|
+
private _connected;
|
|
32
|
+
constructor(userId: string, controllerToken: string, endpoint: string, handlers: UserEventStreamHandlers);
|
|
33
|
+
get connected(): boolean;
|
|
34
|
+
start(): void;
|
|
35
|
+
stop(): void;
|
|
36
|
+
private runLoop;
|
|
37
|
+
private dispatchEvent;
|
|
38
|
+
}
|
|
39
|
+
export interface CredentialResponse {
|
|
40
|
+
credential_id: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
platform?: string;
|
|
43
|
+
online?: boolean;
|
|
44
|
+
paired_at?: string;
|
|
45
|
+
last_seen_at?: string;
|
|
46
|
+
device_profile?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
export declare function fetchUserCredentials(endpoint: string, userId: string, controllerToken: string, onlineFilter?: boolean): Promise<CredentialResponse[]>;
|
|
24
49
|
/**
|
|
25
50
|
* Wait for command ACK via SSE push (which should already be connected by the
|
|
26
51
|
* registry). Falls back to polling.
|