@zhihand/mcp 0.28.0 → 0.30.0
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 +254 -158
- package/dist/core/command.d.ts +5 -5
- package/dist/core/command.js +2 -5
- package/dist/core/config.d.ts +32 -20
- package/dist/core/config.js +102 -40
- package/dist/core/device.d.ts +41 -16
- package/dist/core/device.js +199 -79
- package/dist/core/pair.d.ts +9 -9
- package/dist/core/pair.js +54 -30
- package/dist/core/registry.d.ts +67 -0
- package/dist/core/registry.js +288 -0
- package/dist/core/screenshot.d.ts +13 -2
- package/dist/core/screenshot.js +43 -3
- package/dist/core/sse.d.ts +13 -16
- package/dist/core/sse.js +46 -54
- package/dist/daemon/dispatcher.d.ts +3 -1
- package/dist/daemon/dispatcher.js +3 -2
- package/dist/daemon/heartbeat.d.ts +4 -4
- package/dist/daemon/index.js +8 -6
- package/dist/daemon/prompt-listener.d.ts +3 -1
- package/dist/daemon/prompt-listener.js +2 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.js +102 -40
- package/dist/openclaw.adapter.js +10 -2
- package/dist/tools/control.d.ts +10 -3
- package/dist/tools/control.js +58 -29
- package/dist/tools/pair.js +15 -18
- package/dist/tools/resolve.d.ts +7 -0
- package/dist/tools/resolve.js +22 -0
- package/dist/tools/schemas.d.ts +9 -1
- package/dist/tools/schemas.js +10 -8
- package/dist/tools/screenshot.d.ts +3 -2
- package/dist/tools/screenshot.js +2 -2
- package/dist/tools/system.d.ts +3 -5
- package/dist/tools/system.js +18 -5
- package/package.json +1 -1
package/bin/zhihand
CHANGED
|
@@ -5,12 +5,22 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
import { startStdioServer } from "../dist/index.js";
|
|
6
6
|
import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
|
|
7
7
|
import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
loadConfig,
|
|
10
|
+
listDeviceRecords,
|
|
11
|
+
removeDevice,
|
|
12
|
+
renameDevice,
|
|
13
|
+
setDefaultDevice,
|
|
14
|
+
loadBackendConfig,
|
|
15
|
+
saveBackendConfig,
|
|
16
|
+
DEFAULT_MODELS,
|
|
17
|
+
resolveConfig,
|
|
18
|
+
} from "../dist/core/config.js";
|
|
10
19
|
import { executePairing } from "../dist/core/pair.js";
|
|
11
20
|
import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
|
|
12
21
|
|
|
13
22
|
const DEFAULT_ENDPOINT = "https://api.zhihand.com";
|
|
23
|
+
const VERSION = "0.30.0";
|
|
14
24
|
|
|
15
25
|
const CLI_TOOL_MAP = {
|
|
16
26
|
claude: "claudecode",
|
|
@@ -23,48 +33,58 @@ const { positionals, values } = parseArgs({
|
|
|
23
33
|
strict: false,
|
|
24
34
|
options: {
|
|
25
35
|
device: { type: "string" },
|
|
36
|
+
label: { type: "string" },
|
|
26
37
|
model: { type: "string", short: "m" },
|
|
27
38
|
help: { type: "boolean", short: "h", default: false },
|
|
39
|
+
version: { type: "boolean", short: "v", default: false },
|
|
28
40
|
detach: { type: "boolean", short: "d", default: false },
|
|
29
41
|
debug: { type: "boolean", default: false },
|
|
42
|
+
force: { type: "boolean", default: false },
|
|
30
43
|
port: { type: "string" },
|
|
31
44
|
},
|
|
32
45
|
});
|
|
33
46
|
|
|
34
|
-
const command = positionals[0] ?? "
|
|
47
|
+
const command = positionals[0] ?? "mcp";
|
|
48
|
+
|
|
49
|
+
if (values.version) {
|
|
50
|
+
console.log(VERSION);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
35
53
|
|
|
36
54
|
if (values.help) {
|
|
37
55
|
console.log(`
|
|
38
|
-
zhihand — MCP Server and Relay for phone control
|
|
56
|
+
zhihand v${VERSION} — MCP Server and Relay for phone control
|
|
39
57
|
|
|
40
58
|
Usage:
|
|
41
59
|
zhihand start Start daemon (MCP Server + Relay, foreground)
|
|
42
60
|
zhihand start -d Start daemon in background (detach)
|
|
43
|
-
zhihand start --debug Start daemon with verbose debug logging
|
|
44
61
|
zhihand stop Stop daemon
|
|
45
62
|
zhihand status Show status (pairing, backend, brain)
|
|
46
63
|
|
|
47
|
-
zhihand gemini Switch backend to Gemini CLI
|
|
48
|
-
zhihand claude Switch backend to Claude Code
|
|
49
|
-
zhihand codex Switch backend to Codex CLI
|
|
50
|
-
zhihand gemini --model pro Switch backend with custom model
|
|
64
|
+
zhihand gemini Switch backend to Gemini CLI
|
|
65
|
+
zhihand claude Switch backend to Claude Code
|
|
66
|
+
zhihand codex Switch backend to Codex CLI
|
|
51
67
|
|
|
52
68
|
zhihand setup Interactive setup: pair + configure + start
|
|
53
|
-
zhihand pair
|
|
69
|
+
zhihand pair [--label X] Pair with a phone device
|
|
70
|
+
zhihand list List paired devices
|
|
71
|
+
zhihand unpair <cred_id> Revoke + remove a device
|
|
72
|
+
zhihand rename <cred> <n> Rename a device
|
|
73
|
+
zhihand default <cred_id> Set default device
|
|
54
74
|
zhihand detect Detect available CLI tools
|
|
55
75
|
|
|
56
|
-
zhihand
|
|
57
|
-
zhihand
|
|
58
|
-
zhihand test <ids> Run specific test(s), e.g. 'zhihand test 4' or '4,9,20'
|
|
59
|
-
zhihand test all Run ALL tests (including unsafe, e.g. power button)
|
|
60
|
-
zhihand serve Start MCP Server (stdio mode, backward compat)
|
|
76
|
+
zhihand test [cred] [ids] Run device tests (all, specific ids, or for a credential)
|
|
77
|
+
zhihand mcp Start stdio MCP server (for Claude/Codex/Gemini hosts)
|
|
61
78
|
|
|
62
79
|
Options:
|
|
63
|
-
--device <name> Use a specific paired
|
|
64
|
-
--
|
|
80
|
+
--device <name> Use a specific paired credential_id (test only)
|
|
81
|
+
--label <label> Label for new device (pair only)
|
|
82
|
+
--model, -m <name> Backend model alias
|
|
65
83
|
--port <port> Override daemon port (default: 18686)
|
|
66
84
|
-d, --detach Run daemon in background
|
|
67
|
-
--debug
|
|
85
|
+
--debug Verbose debug logging
|
|
86
|
+
--force (test only) Run tests even if capability not ready
|
|
87
|
+
-v, --version Print version
|
|
68
88
|
-h, --help Show this help
|
|
69
89
|
`);
|
|
70
90
|
process.exit(0);
|
|
@@ -87,7 +107,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
87
107
|
console.error(`Warning: ${command} is installed but not logged in.`);
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
// Check if daemon is running — if so, notify it via HTTP
|
|
91
110
|
const daemonPid = isAlreadyRunning();
|
|
92
111
|
const config = loadBackendConfig();
|
|
93
112
|
const previous = config.activeBackend;
|
|
@@ -102,11 +121,9 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
102
121
|
|
|
103
122
|
console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
|
|
104
123
|
|
|
105
|
-
// Configure MCP (HTTP transport)
|
|
106
124
|
const { configured, removed } = configureMCP(backendName, previous);
|
|
107
125
|
|
|
108
126
|
if (configured) {
|
|
109
|
-
// Notify daemon if running
|
|
110
127
|
if (daemonPid) {
|
|
111
128
|
try {
|
|
112
129
|
const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -120,7 +137,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
120
137
|
console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
|
|
121
138
|
}
|
|
122
139
|
} catch {
|
|
123
|
-
// Daemon not responding, just save config
|
|
124
140
|
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
125
141
|
console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
|
|
126
142
|
}
|
|
@@ -150,10 +166,8 @@ switch (command) {
|
|
|
150
166
|
|
|
151
167
|
const args = [process.argv[1], "start"];
|
|
152
168
|
if (values.port) args.push("--port", values.port);
|
|
153
|
-
if (values.device) args.push("--device", values.device);
|
|
154
169
|
if (values.debug) args.push("--debug");
|
|
155
170
|
|
|
156
|
-
// Write daemon logs to ~/.zhihand/daemon.log
|
|
157
171
|
const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
|
|
158
172
|
fsSync.default.mkdirSync(zhihandDir, { recursive: true });
|
|
159
173
|
const logPath = pathMod.default.join(zhihandDir, "daemon.log");
|
|
@@ -171,48 +185,121 @@ switch (command) {
|
|
|
171
185
|
process.exit(0);
|
|
172
186
|
}
|
|
173
187
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
174
|
-
await startDaemon({
|
|
175
|
-
port,
|
|
176
|
-
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
177
|
-
debug: values.debug,
|
|
178
|
-
});
|
|
188
|
+
await startDaemon({ port, debug: values.debug });
|
|
179
189
|
break;
|
|
180
190
|
}
|
|
181
191
|
|
|
182
192
|
case "stop": {
|
|
183
193
|
const stopped = stopDaemon();
|
|
184
|
-
|
|
185
|
-
console.log("Daemon stopped.");
|
|
186
|
-
} else {
|
|
187
|
-
console.log("No daemon is running.");
|
|
188
|
-
}
|
|
194
|
+
console.log(stopped ? "Daemon stopped." : "No daemon is running.");
|
|
189
195
|
break;
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
case "mcp":
|
|
192
199
|
case "serve": {
|
|
193
|
-
|
|
194
|
-
await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
|
|
200
|
+
await startStdioServer();
|
|
195
201
|
break;
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
case "pair": {
|
|
199
205
|
const edgeId = `mcp-${Date.now().toString(36)}`;
|
|
200
|
-
const
|
|
201
|
-
await executePairing(DEFAULT_ENDPOINT, edgeId,
|
|
206
|
+
const label = values.label ?? undefined;
|
|
207
|
+
await executePairing(DEFAULT_ENDPOINT, edgeId, label);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case "list": {
|
|
212
|
+
const records = listDeviceRecords();
|
|
213
|
+
const cfg = loadConfig();
|
|
214
|
+
if (records.length === 0) {
|
|
215
|
+
console.log("No paired devices. Run: zhihand pair");
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
const header = ["credential_id", "label", "platform", "default", "paired_at", "last_seen"];
|
|
219
|
+
const rows = records.map((r) => [
|
|
220
|
+
r.credential_id,
|
|
221
|
+
r.label,
|
|
222
|
+
r.platform,
|
|
223
|
+
cfg.default_credential_id === r.credential_id ? "*" : "",
|
|
224
|
+
r.paired_at,
|
|
225
|
+
r.last_seen_at,
|
|
226
|
+
]);
|
|
227
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
|
|
228
|
+
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(" ");
|
|
229
|
+
console.log(fmt(header));
|
|
230
|
+
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
231
|
+
for (const r of rows) console.log(fmt(r));
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case "unpair": {
|
|
236
|
+
const credId = positionals[1];
|
|
237
|
+
if (!credId) {
|
|
238
|
+
console.error("Usage: zhihand unpair <credential_id>");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const cfg = loadConfig();
|
|
242
|
+
const record = cfg.devices[credId];
|
|
243
|
+
if (!record) {
|
|
244
|
+
console.error(`Device '${credId}' not found.`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
// Best-effort revoke
|
|
248
|
+
try {
|
|
249
|
+
const url = `${record.endpoint}/v1/credentials/${encodeURIComponent(credId)}/revoke`;
|
|
250
|
+
const res = await fetch(url, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: { "x-zhihand-controller-token": record.controller_token },
|
|
253
|
+
signal: AbortSignal.timeout(5000),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
console.warn(`Warning: server revoke returned ${res.status} (continuing to remove locally)`);
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.warn(`Warning: server revoke failed: ${err.message} (continuing to remove locally)`);
|
|
260
|
+
}
|
|
261
|
+
removeDevice(credId);
|
|
262
|
+
console.log(`Unpaired: ${credId}`);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case "rename": {
|
|
267
|
+
const credId = positionals[1];
|
|
268
|
+
const newLabel = positionals[2];
|
|
269
|
+
if (!credId || !newLabel) {
|
|
270
|
+
console.error("Usage: zhihand rename <credential_id> <new_label>");
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
renameDevice(credId, newLabel);
|
|
274
|
+
console.log(`Renamed ${credId} to '${newLabel}'`);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case "default": {
|
|
279
|
+
const credId = positionals[1];
|
|
280
|
+
if (!credId) {
|
|
281
|
+
console.error("Usage: zhihand default <credential_id>");
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
setDefaultDevice(credId);
|
|
285
|
+
console.log(`Default device set to ${credId}`);
|
|
202
286
|
break;
|
|
203
287
|
}
|
|
204
288
|
|
|
205
289
|
case "status": {
|
|
206
|
-
const
|
|
290
|
+
const records = listDeviceRecords();
|
|
291
|
+
const cfg = loadConfig();
|
|
207
292
|
const backend = loadBackendConfig();
|
|
208
293
|
const daemonPid = isAlreadyRunning();
|
|
209
294
|
|
|
210
|
-
if (
|
|
211
|
-
console.log(
|
|
212
|
-
console.log(`Credential ID: ${cred.credentialId}`);
|
|
213
|
-
console.log(`Endpoint: ${cred.endpoint}`);
|
|
295
|
+
if (records.length === 0) {
|
|
296
|
+
console.log("No paired devices. Run: zhihand setup");
|
|
214
297
|
} else {
|
|
215
|
-
console.log(
|
|
298
|
+
console.log(`Paired devices: ${records.length}`);
|
|
299
|
+
for (const r of records) {
|
|
300
|
+
const mark = cfg.default_credential_id === r.credential_id ? " *" : "";
|
|
301
|
+
console.log(` ${r.credential_id} (${r.label}, ${r.platform})${mark}`);
|
|
302
|
+
}
|
|
216
303
|
}
|
|
217
304
|
const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
|
|
218
305
|
const modelLabel = backend.activeBackend
|
|
@@ -221,7 +308,6 @@ switch (command) {
|
|
|
221
308
|
console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
|
|
222
309
|
console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
|
|
223
310
|
|
|
224
|
-
// If daemon running, get live status
|
|
225
311
|
if (daemonPid) {
|
|
226
312
|
try {
|
|
227
313
|
const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -245,20 +331,19 @@ switch (command) {
|
|
|
245
331
|
}
|
|
246
332
|
|
|
247
333
|
case "setup": {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (!cred) {
|
|
334
|
+
const records = listDeviceRecords();
|
|
335
|
+
if (records.length === 0) {
|
|
251
336
|
console.log("No paired device found. Starting pairing...\n");
|
|
252
337
|
const edgeId = `mcp-${Date.now().toString(36)}`;
|
|
253
|
-
|
|
254
|
-
await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
|
|
255
|
-
cred = loadDefaultCredential();
|
|
338
|
+
await executePairing(DEFAULT_ENDPOINT, edgeId, values.label);
|
|
256
339
|
}
|
|
257
|
-
|
|
258
|
-
|
|
340
|
+
const updated = listDeviceRecords();
|
|
341
|
+
if (updated.length > 0) {
|
|
342
|
+
const cfg = loadConfig();
|
|
343
|
+
const def = updated.find((r) => r.credential_id === cfg.default_credential_id) ?? updated[0];
|
|
344
|
+
console.log(`\nPaired: ${def.label} (${def.credential_id})\n`);
|
|
259
345
|
}
|
|
260
346
|
|
|
261
|
-
// 2. Detect tools
|
|
262
347
|
const tools = await detectCLITools();
|
|
263
348
|
console.log(formatDetectedTools(tools));
|
|
264
349
|
|
|
@@ -267,70 +352,51 @@ switch (command) {
|
|
|
267
352
|
break;
|
|
268
353
|
}
|
|
269
354
|
|
|
270
|
-
// 3. Auto-select best tool and configure MCP (HTTP transport)
|
|
271
355
|
const best = tools.find((t) => t.loggedIn) ?? tools[0];
|
|
272
356
|
const config = loadBackendConfig();
|
|
273
357
|
|
|
274
358
|
console.log(`\nAuto-selecting backend: ${displayName(best.name)}...`);
|
|
275
|
-
|
|
276
|
-
if (values.port) {
|
|
277
|
-
process.env.ZHIHAND_PORT = values.port;
|
|
278
|
-
}
|
|
359
|
+
if (values.port) process.env.ZHIHAND_PORT = values.port;
|
|
279
360
|
configureMCP(best.name, config.activeBackend);
|
|
280
361
|
saveBackendConfig({ activeBackend: best.name });
|
|
281
362
|
|
|
282
|
-
// 4. Start daemon
|
|
283
363
|
console.log(`\nStarting daemon...\n`);
|
|
284
364
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
285
|
-
await startDaemon({
|
|
286
|
-
port,
|
|
287
|
-
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
288
|
-
});
|
|
365
|
+
await startDaemon({ port });
|
|
289
366
|
break;
|
|
290
367
|
}
|
|
291
368
|
|
|
292
|
-
case "list":
|
|
293
369
|
case "test": {
|
|
294
|
-
const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
|
|
295
370
|
const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
|
|
296
371
|
const { waitForCommandAck } = await import("../dist/core/sse.js");
|
|
297
|
-
const {
|
|
298
|
-
const {
|
|
372
|
+
const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
|
|
373
|
+
const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
|
|
299
374
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// Unsafe: won't run in full-suite unless explicitly requested
|
|
375
|
+
const KIND_CAPABILITY = {
|
|
376
|
+
profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
|
|
377
|
+
};
|
|
304
378
|
const REGISTRY = [
|
|
305
|
-
// Phase A — Device Info API
|
|
306
379
|
{ id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
|
|
307
380
|
{ id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
|
|
308
|
-
// Phase B — Screenshot
|
|
309
381
|
{ id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
|
|
310
|
-
// Phase C — Tap / Touch
|
|
311
382
|
{ id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
|
|
312
383
|
{ id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
313
384
|
{ id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
|
|
314
385
|
{ id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
315
386
|
{ id: 8, phase: "Tap/Touch", label: "Middle click", kind: "hid", params: { action: "middleclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
316
|
-
// Phase D — Swipe / Scroll
|
|
317
387
|
{ id: 9, phase: "Swipe/Scroll", label: "Swipe up", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 } },
|
|
318
388
|
{ id: 10, phase: "Swipe/Scroll", label: "Swipe down", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
|
|
319
389
|
{ id: 11, phase: "Swipe/Scroll", label: "Swipe left", kind: "hid", params: { action: "swipe", startXRatio: 0.7, startYRatio: 0.5, endXRatio: 0.3, endYRatio: 0.5, durationMs: 300 } },
|
|
320
390
|
{ id: 12, phase: "Swipe/Scroll", label: "Swipe right", kind: "hid", params: { action: "swipe", startXRatio: 0.3, startYRatio: 0.5, endXRatio: 0.7, endYRatio: 0.5, durationMs: 300 } },
|
|
321
391
|
{ id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
|
|
322
392
|
{ id: 14, phase: "Swipe/Scroll", label: "Scroll up", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 } },
|
|
323
|
-
// Phase E — Text + Keys
|
|
324
393
|
{ id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
|
|
325
394
|
{ id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
|
|
326
395
|
{ id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
|
|
327
|
-
// Phase F — App Navigation
|
|
328
396
|
{ id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
|
|
329
397
|
{ id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
|
|
330
398
|
{ id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
|
|
331
|
-
// Phase G — Clipboard
|
|
332
399
|
{ id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
|
|
333
|
-
// Phase H — System Navigation
|
|
334
400
|
{ id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
|
|
335
401
|
{ id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
|
|
336
402
|
{ id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
|
|
@@ -339,7 +405,6 @@ switch (command) {
|
|
|
339
405
|
{ id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
|
|
340
406
|
{ id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
|
|
341
407
|
{ id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
|
|
342
|
-
// Phase I — Media
|
|
343
408
|
{ id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
|
|
344
409
|
{ id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
|
|
345
410
|
{ id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
|
|
@@ -349,49 +414,36 @@ switch (command) {
|
|
|
349
414
|
{ id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
|
|
350
415
|
{ id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
|
|
351
416
|
{ id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
|
|
352
|
-
// Phase J — Hardware
|
|
353
417
|
{ id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
|
|
354
418
|
{ id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
|
|
355
419
|
{ id: 41, phase: "Hardware", label: "Power button (⚠️ may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
|
|
356
420
|
];
|
|
357
421
|
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (t.unsafe) tags.push("unsafe");
|
|
370
|
-
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
371
|
-
console.log(` ${String(t.id).padStart(2)}. ${t.label}${tagStr}`);
|
|
422
|
+
// Parse first positional: credential_id (crd_*) or test ids
|
|
423
|
+
let credentialArg = values.device ?? process.env.ZHIHAND_DEVICE;
|
|
424
|
+
let filterArg = null;
|
|
425
|
+
const arg1 = positionals[1];
|
|
426
|
+
const arg2 = positionals[2];
|
|
427
|
+
if (arg1) {
|
|
428
|
+
if (/^crd_/.test(arg1)) {
|
|
429
|
+
credentialArg = arg1;
|
|
430
|
+
filterArg = arg2 ?? null;
|
|
431
|
+
} else {
|
|
432
|
+
filterArg = arg1;
|
|
372
433
|
}
|
|
373
|
-
console.log(`\n Total: ${REGISTRY.length} tests`);
|
|
374
|
-
console.log("\nUsage:");
|
|
375
|
-
console.log(" zhihand test # run all safe tests");
|
|
376
|
-
console.log(" zhihand test 4 # run test #4 only");
|
|
377
|
-
console.log(" zhihand test 4,9,20 # run tests #4, #9, #20");
|
|
378
|
-
console.log(" zhihand test all # run ALL tests (including unsafe)");
|
|
379
|
-
process.exit(0);
|
|
380
434
|
}
|
|
381
435
|
|
|
382
|
-
// ── "test" sub-command ───────────────────────────────────
|
|
383
436
|
let testConfig;
|
|
384
437
|
try {
|
|
385
|
-
testConfig =
|
|
438
|
+
testConfig = resolveConfig(credentialArg);
|
|
386
439
|
} catch (err) {
|
|
387
440
|
console.error(`Error: ${err.message}`);
|
|
388
|
-
console.error("Run 'zhihand
|
|
441
|
+
console.error("Run 'zhihand pair' to add a device first.");
|
|
389
442
|
process.exit(1);
|
|
390
443
|
}
|
|
391
444
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
let selectedIds = null; // null = default (all safe)
|
|
445
|
+
const forceRun = values.force === true;
|
|
446
|
+
let selectedIds = null;
|
|
395
447
|
let includeUnsafe = false;
|
|
396
448
|
if (filterArg) {
|
|
397
449
|
if (filterArg === "all") {
|
|
@@ -401,16 +453,13 @@ switch (command) {
|
|
|
401
453
|
filterArg.split(",").map((s) => {
|
|
402
454
|
const trimmed = s.trim();
|
|
403
455
|
const n = Number(trimmed);
|
|
404
|
-
// Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
|
|
405
456
|
return Number.isInteger(n) && n > 0 ? n : NaN;
|
|
406
457
|
}).filter((n) => !isNaN(n))
|
|
407
458
|
);
|
|
408
459
|
if (selectedIds.size === 0) {
|
|
409
460
|
console.error(`Invalid test IDs: ${filterArg}`);
|
|
410
|
-
console.error("Run 'zhihand list' to see available tests.");
|
|
411
461
|
process.exit(1);
|
|
412
462
|
}
|
|
413
|
-
// Explicit selection implies user knows what they're doing
|
|
414
463
|
includeUnsafe = true;
|
|
415
464
|
}
|
|
416
465
|
}
|
|
@@ -419,20 +468,40 @@ switch (command) {
|
|
|
419
468
|
console.log(` Device: ${testConfig.credentialId}`);
|
|
420
469
|
console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
|
|
421
470
|
|
|
422
|
-
// Pre-fetch device profile
|
|
423
|
-
|
|
424
|
-
|
|
471
|
+
// Pre-fetch device profile
|
|
472
|
+
let currentRawAttrs = {};
|
|
473
|
+
let currentProfile = null;
|
|
474
|
+
let currentCaps = null;
|
|
475
|
+
let profileReceivedAtMs = 0;
|
|
425
476
|
try {
|
|
426
|
-
await
|
|
427
|
-
|
|
428
|
-
|
|
477
|
+
const fetched = await fetchDeviceProfileOnce(testConfig);
|
|
478
|
+
if (fetched) {
|
|
479
|
+
currentRawAttrs = fetched.rawAttrs;
|
|
480
|
+
profileReceivedAtMs = fetched.receivedAtMs;
|
|
481
|
+
currentProfile = extractStatic(currentRawAttrs);
|
|
482
|
+
currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
|
|
483
|
+
}
|
|
484
|
+
} catch { /* non-fatal */ }
|
|
485
|
+
const getDevicePlatform = () => currentProfile?.platform ?? "unknown";
|
|
486
|
+
|
|
487
|
+
console.log(" ── Capability readiness ──");
|
|
488
|
+
if (!currentCaps) {
|
|
489
|
+
console.log(" ⚠️ Device profile not loaded — all capability gates will allow tests through.");
|
|
490
|
+
} else {
|
|
491
|
+
const fmt = (name, cap) => ` ${cap.ready ? "✅" : "⚠️"} ${name.padEnd(14)} ${cap.ready ? "ready" : "NOT ready"} — ${cap.reason}`;
|
|
492
|
+
console.log(fmt("screen_sharing", currentCaps.screen_sharing));
|
|
493
|
+
console.log(fmt("hid", currentCaps.hid));
|
|
494
|
+
console.log(fmt("live_session", currentCaps.live_session));
|
|
495
|
+
const ageStr = currentCaps.profile.age_ms >= 0 ? `${(currentCaps.profile.age_ms / 1000).toFixed(1)}s` : "unknown";
|
|
496
|
+
console.log(` ${currentCaps.profile.stale ? "⚠️" : "✅"} profile age=${ageStr}${currentCaps.profile.stale ? " (STALE)" : ""}`);
|
|
497
|
+
if (forceRun) {
|
|
498
|
+
console.log(" --force passed: capability gates disabled.");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
console.log("");
|
|
429
502
|
|
|
430
|
-
let passed = 0;
|
|
431
|
-
let failed = 0;
|
|
432
|
-
let skipped = 0;
|
|
433
|
-
let totalSteps = 0;
|
|
503
|
+
let passed = 0, failed = 0, skipped = 0, totalSteps = 0;
|
|
434
504
|
|
|
435
|
-
// ── Resolve platform-aware params (evaluated at test time) ──
|
|
436
505
|
function resolvePlatformAwareParams(variant) {
|
|
437
506
|
const platform = getDevicePlatform();
|
|
438
507
|
if (variant === "open_wechat") {
|
|
@@ -450,7 +519,6 @@ switch (command) {
|
|
|
450
519
|
return null;
|
|
451
520
|
}
|
|
452
521
|
|
|
453
|
-
// ── Test runners (shared ACK logic) ──
|
|
454
522
|
async function runCommandTest(t, command) {
|
|
455
523
|
totalSteps++;
|
|
456
524
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
@@ -480,14 +548,23 @@ switch (command) {
|
|
|
480
548
|
}
|
|
481
549
|
|
|
482
550
|
async function runSingleTest(t) {
|
|
483
|
-
// Platform skip (evaluated at test time — Test 1 may have just populated the profile)
|
|
484
551
|
const currentPlatform = getDevicePlatform();
|
|
485
552
|
if (t.platform && t.platform !== currentPlatform) {
|
|
486
|
-
totalSteps++;
|
|
487
|
-
skipped++;
|
|
553
|
+
totalSteps++; skipped++;
|
|
488
554
|
console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
|
|
489
555
|
return;
|
|
490
556
|
}
|
|
557
|
+
if (!forceRun && currentCaps) {
|
|
558
|
+
const requiredCap = KIND_CAPABILITY[t.kind] ?? "none";
|
|
559
|
+
if (requiredCap !== "none") {
|
|
560
|
+
const gate = requiredCap === "screen" ? currentCaps.screen_sharing : currentCaps.hid;
|
|
561
|
+
if (!gate.ready) {
|
|
562
|
+
totalSteps++; skipped++;
|
|
563
|
+
console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${requiredCap} not ready: ${gate.reason})`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
491
568
|
|
|
492
569
|
switch (t.kind) {
|
|
493
570
|
case "profile": {
|
|
@@ -495,10 +572,14 @@ switch (command) {
|
|
|
495
572
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
496
573
|
const t0 = Date.now();
|
|
497
574
|
try {
|
|
498
|
-
await
|
|
575
|
+
const fetched = await fetchDeviceProfileOnce(testConfig);
|
|
499
576
|
const ms = Date.now() - t0;
|
|
500
|
-
if (
|
|
501
|
-
|
|
577
|
+
if (fetched) {
|
|
578
|
+
currentRawAttrs = fetched.rawAttrs;
|
|
579
|
+
profileReceivedAtMs = fetched.receivedAtMs;
|
|
580
|
+
currentProfile = extractStatic(currentRawAttrs);
|
|
581
|
+
currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
|
|
582
|
+
const s = currentProfile;
|
|
502
583
|
console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
|
|
503
584
|
passed++;
|
|
504
585
|
} else {
|
|
@@ -515,15 +596,31 @@ switch (command) {
|
|
|
515
596
|
totalSteps++;
|
|
516
597
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
517
598
|
try {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
599
|
+
const state = {
|
|
600
|
+
credentialId: testConfig.credentialId,
|
|
601
|
+
label: "(test)",
|
|
602
|
+
platform: getDevicePlatform(),
|
|
603
|
+
online: true,
|
|
604
|
+
lastSeenAtMs: Date.now(),
|
|
605
|
+
profile: currentProfile,
|
|
606
|
+
capabilities: currentCaps,
|
|
607
|
+
profileReceivedAtMs,
|
|
608
|
+
rawAttributes: currentRawAttrs,
|
|
609
|
+
sseController: null,
|
|
610
|
+
sseConnected: false,
|
|
611
|
+
heartbeatTimer: null,
|
|
612
|
+
record: { credential_id: testConfig.credentialId, controller_token: "", endpoint: "", label: "", platform: "unknown", paired_at: "", last_seen_at: "" },
|
|
613
|
+
};
|
|
614
|
+
const status = formatDeviceStatus(state);
|
|
615
|
+
const topLevel = Object.keys(status).filter((k) => k !== "raw" && k !== "capabilities");
|
|
616
|
+
const rawKeys = Object.keys(status.raw ?? {});
|
|
617
|
+
const caps = status.capabilities ?? {};
|
|
618
|
+
const capReadySummary = ["screen_sharing", "hid", "live_session"]
|
|
619
|
+
.map((k) => `${k}=${caps[k]?.ready ? "ready" : "not-ready"}`)
|
|
620
|
+
.join(", ");
|
|
621
|
+
console.log(`✅ ${topLevel.length} curated + ${rawKeys.length} raw attributes; ${capReadySummary}`);
|
|
622
|
+
console.log(` curated: ${topLevel.join(", ")}`);
|
|
623
|
+
console.log(` raw: ${rawKeys.join(", ")}`);
|
|
527
624
|
passed++;
|
|
528
625
|
} catch (err) {
|
|
529
626
|
console.log(`❌ ${err.message}`);
|
|
@@ -539,13 +636,21 @@ switch (command) {
|
|
|
539
636
|
const cmd = createControlCommand({ action: "screenshot" });
|
|
540
637
|
const queued = await enqueueCommand(testConfig, cmd);
|
|
541
638
|
const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
|
|
542
|
-
if (ack.acked) {
|
|
543
|
-
const buf = await fetchScreenshotBinary(testConfig);
|
|
544
|
-
console.log(`✅ ${(buf.length / 1024).toFixed(0)}KB (${Date.now() - t0}ms)`);
|
|
545
|
-
passed++;
|
|
546
|
-
} else {
|
|
639
|
+
if (!ack.acked) {
|
|
547
640
|
console.log(`⏱️ Timeout (${Date.now() - t0}ms)`);
|
|
548
641
|
failed++;
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
const shot = await fetchScreenshot(testConfig);
|
|
645
|
+
const kb = (shot.buffer.length / 1024).toFixed(0);
|
|
646
|
+
const ms = Date.now() - t0;
|
|
647
|
+
if (shot.stale) {
|
|
648
|
+
const threshold = getSnapshotStaleThresholdMs();
|
|
649
|
+
console.log(`❌ Stale (${kb}KB, age=${(shot.ageMs / 1000).toFixed(1)}s > ${(threshold / 1000).toFixed(1)}s) ${shot.width}x${shot.height} seq=${shot.sequence} — phone may not be screen-sharing (${ms}ms)`);
|
|
650
|
+
failed++;
|
|
651
|
+
} else {
|
|
652
|
+
console.log(`✅ ${kb}KB, ${shot.width}x${shot.height}, age=${shot.ageMs >= 0 ? `${shot.ageMs}ms` : "?"}, seq=${shot.sequence} (${ms}ms)`);
|
|
653
|
+
passed++;
|
|
549
654
|
}
|
|
550
655
|
} catch (err) {
|
|
551
656
|
console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
|
|
@@ -555,11 +660,11 @@ switch (command) {
|
|
|
555
660
|
}
|
|
556
661
|
case "hid": {
|
|
557
662
|
const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
|
|
558
|
-
await runCommandTest(t, createControlCommand(params));
|
|
663
|
+
await runCommandTest(t, createControlCommand(params, getDevicePlatform()));
|
|
559
664
|
break;
|
|
560
665
|
}
|
|
561
666
|
case "system": {
|
|
562
|
-
await runCommandTest(t, createSystemCommand(t.params));
|
|
667
|
+
await runCommandTest(t, createSystemCommand(t.params, getDevicePlatform()));
|
|
563
668
|
break;
|
|
564
669
|
}
|
|
565
670
|
}
|
|
@@ -567,7 +672,6 @@ switch (command) {
|
|
|
567
672
|
|
|
568
673
|
const pause = () => new Promise((r) => setTimeout(r, 1500));
|
|
569
674
|
|
|
570
|
-
// Select tests to run
|
|
571
675
|
const toRun = REGISTRY.filter((t) => {
|
|
572
676
|
if (selectedIds) return selectedIds.has(t.id);
|
|
573
677
|
if (t.unsafe && !includeUnsafe) return false;
|
|
@@ -576,17 +680,13 @@ switch (command) {
|
|
|
576
680
|
|
|
577
681
|
if (toRun.length === 0) {
|
|
578
682
|
console.error("No matching tests.");
|
|
579
|
-
console.error("Run 'zhihand list' to see available tests.");
|
|
580
683
|
process.exit(1);
|
|
581
684
|
}
|
|
582
685
|
|
|
583
|
-
// Warn about missing IDs
|
|
584
686
|
if (selectedIds) {
|
|
585
687
|
const foundIds = new Set(toRun.map((t) => t.id));
|
|
586
688
|
const missing = [...selectedIds].filter((id) => !foundIds.has(id));
|
|
587
|
-
if (missing.length) {
|
|
588
|
-
console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
|
|
589
|
-
}
|
|
689
|
+
if (missing.length) console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
|
|
590
690
|
}
|
|
591
691
|
|
|
592
692
|
let currentPhase = "";
|
|
@@ -600,13 +700,9 @@ switch (command) {
|
|
|
600
700
|
if (i < toRun.length - 1) await pause();
|
|
601
701
|
}
|
|
602
702
|
|
|
603
|
-
// ── Summary ──────────────────────────────────────────────
|
|
604
703
|
console.log(`\n Result: ${passed}/${totalSteps} passed, ${failed} failed, ${skipped} skipped`);
|
|
605
|
-
if (failed === 0)
|
|
606
|
-
|
|
607
|
-
} else {
|
|
608
|
-
console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
|
|
609
|
-
}
|
|
704
|
+
if (failed === 0) console.log(" ✅ All tests passed! Device is fully responsive.");
|
|
705
|
+
else console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
|
|
610
706
|
process.exit(failed > 0 ? 1 : 0);
|
|
611
707
|
}
|
|
612
708
|
|