@zhihand/mcp 0.29.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 +209 -185
- 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 +28 -19
- package/dist/core/device.js +155 -140
- 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 +3 -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 +17 -23
- 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,8 +33,10 @@ 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 },
|
|
30
42
|
force: { type: "boolean", default: false },
|
|
@@ -32,42 +44,47 @@ const { positionals, values } = parseArgs({
|
|
|
32
44
|
},
|
|
33
45
|
});
|
|
34
46
|
|
|
35
|
-
const command = positionals[0] ?? "
|
|
47
|
+
const command = positionals[0] ?? "mcp";
|
|
48
|
+
|
|
49
|
+
if (values.version) {
|
|
50
|
+
console.log(VERSION);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
36
53
|
|
|
37
54
|
if (values.help) {
|
|
38
55
|
console.log(`
|
|
39
|
-
zhihand — MCP Server and Relay for phone control
|
|
56
|
+
zhihand v${VERSION} — MCP Server and Relay for phone control
|
|
40
57
|
|
|
41
58
|
Usage:
|
|
42
59
|
zhihand start Start daemon (MCP Server + Relay, foreground)
|
|
43
60
|
zhihand start -d Start daemon in background (detach)
|
|
44
|
-
zhihand start --debug Start daemon with verbose debug logging
|
|
45
61
|
zhihand stop Stop daemon
|
|
46
62
|
zhihand status Show status (pairing, backend, brain)
|
|
47
63
|
|
|
48
|
-
zhihand gemini Switch backend to Gemini CLI
|
|
49
|
-
zhihand claude Switch backend to Claude Code
|
|
50
|
-
zhihand codex Switch backend to Codex CLI
|
|
51
|
-
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
|
|
52
67
|
|
|
53
68
|
zhihand setup Interactive setup: pair + configure + start
|
|
54
|
-
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
|
|
55
74
|
zhihand detect Detect available CLI tools
|
|
56
75
|
|
|
57
|
-
zhihand
|
|
58
|
-
zhihand
|
|
59
|
-
zhihand test <ids> Run specific test(s), e.g. 'zhihand test 4' or '4,9,20'
|
|
60
|
-
zhihand test all Run ALL tests (including unsafe, e.g. power button)
|
|
61
|
-
zhihand test --force Bypass capability gates (run anyway even if NOT ready)
|
|
62
|
-
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)
|
|
63
78
|
|
|
64
79
|
Options:
|
|
65
|
-
--device <name> Use a specific paired
|
|
66
|
-
--
|
|
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
|
|
67
83
|
--port <port> Override daemon port (default: 18686)
|
|
68
84
|
-d, --detach Run daemon in background
|
|
69
|
-
--debug
|
|
85
|
+
--debug Verbose debug logging
|
|
70
86
|
--force (test only) Run tests even if capability not ready
|
|
87
|
+
-v, --version Print version
|
|
71
88
|
-h, --help Show this help
|
|
72
89
|
`);
|
|
73
90
|
process.exit(0);
|
|
@@ -90,7 +107,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
90
107
|
console.error(`Warning: ${command} is installed but not logged in.`);
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
// Check if daemon is running — if so, notify it via HTTP
|
|
94
110
|
const daemonPid = isAlreadyRunning();
|
|
95
111
|
const config = loadBackendConfig();
|
|
96
112
|
const previous = config.activeBackend;
|
|
@@ -105,11 +121,9 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
105
121
|
|
|
106
122
|
console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
|
|
107
123
|
|
|
108
|
-
// Configure MCP (HTTP transport)
|
|
109
124
|
const { configured, removed } = configureMCP(backendName, previous);
|
|
110
125
|
|
|
111
126
|
if (configured) {
|
|
112
|
-
// Notify daemon if running
|
|
113
127
|
if (daemonPid) {
|
|
114
128
|
try {
|
|
115
129
|
const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -123,7 +137,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
123
137
|
console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
|
|
124
138
|
}
|
|
125
139
|
} catch {
|
|
126
|
-
// Daemon not responding, just save config
|
|
127
140
|
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
128
141
|
console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
|
|
129
142
|
}
|
|
@@ -153,10 +166,8 @@ switch (command) {
|
|
|
153
166
|
|
|
154
167
|
const args = [process.argv[1], "start"];
|
|
155
168
|
if (values.port) args.push("--port", values.port);
|
|
156
|
-
if (values.device) args.push("--device", values.device);
|
|
157
169
|
if (values.debug) args.push("--debug");
|
|
158
170
|
|
|
159
|
-
// Write daemon logs to ~/.zhihand/daemon.log
|
|
160
171
|
const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
|
|
161
172
|
fsSync.default.mkdirSync(zhihandDir, { recursive: true });
|
|
162
173
|
const logPath = pathMod.default.join(zhihandDir, "daemon.log");
|
|
@@ -174,48 +185,121 @@ switch (command) {
|
|
|
174
185
|
process.exit(0);
|
|
175
186
|
}
|
|
176
187
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
177
|
-
await startDaemon({
|
|
178
|
-
port,
|
|
179
|
-
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
180
|
-
debug: values.debug,
|
|
181
|
-
});
|
|
188
|
+
await startDaemon({ port, debug: values.debug });
|
|
182
189
|
break;
|
|
183
190
|
}
|
|
184
191
|
|
|
185
192
|
case "stop": {
|
|
186
193
|
const stopped = stopDaemon();
|
|
187
|
-
|
|
188
|
-
console.log("Daemon stopped.");
|
|
189
|
-
} else {
|
|
190
|
-
console.log("No daemon is running.");
|
|
191
|
-
}
|
|
194
|
+
console.log(stopped ? "Daemon stopped." : "No daemon is running.");
|
|
192
195
|
break;
|
|
193
196
|
}
|
|
194
197
|
|
|
198
|
+
case "mcp":
|
|
195
199
|
case "serve": {
|
|
196
|
-
|
|
197
|
-
await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
|
|
200
|
+
await startStdioServer();
|
|
198
201
|
break;
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
case "pair": {
|
|
202
205
|
const edgeId = `mcp-${Date.now().toString(36)}`;
|
|
203
|
-
const
|
|
204
|
-
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}`);
|
|
205
286
|
break;
|
|
206
287
|
}
|
|
207
288
|
|
|
208
289
|
case "status": {
|
|
209
|
-
const
|
|
290
|
+
const records = listDeviceRecords();
|
|
291
|
+
const cfg = loadConfig();
|
|
210
292
|
const backend = loadBackendConfig();
|
|
211
293
|
const daemonPid = isAlreadyRunning();
|
|
212
294
|
|
|
213
|
-
if (
|
|
214
|
-
console.log(
|
|
215
|
-
console.log(`Credential ID: ${cred.credentialId}`);
|
|
216
|
-
console.log(`Endpoint: ${cred.endpoint}`);
|
|
295
|
+
if (records.length === 0) {
|
|
296
|
+
console.log("No paired devices. Run: zhihand setup");
|
|
217
297
|
} else {
|
|
218
|
-
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
|
+
}
|
|
219
303
|
}
|
|
220
304
|
const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
|
|
221
305
|
const modelLabel = backend.activeBackend
|
|
@@ -224,7 +308,6 @@ switch (command) {
|
|
|
224
308
|
console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
|
|
225
309
|
console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
|
|
226
310
|
|
|
227
|
-
// If daemon running, get live status
|
|
228
311
|
if (daemonPid) {
|
|
229
312
|
try {
|
|
230
313
|
const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -248,20 +331,19 @@ switch (command) {
|
|
|
248
331
|
}
|
|
249
332
|
|
|
250
333
|
case "setup": {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (!cred) {
|
|
334
|
+
const records = listDeviceRecords();
|
|
335
|
+
if (records.length === 0) {
|
|
254
336
|
console.log("No paired device found. Starting pairing...\n");
|
|
255
337
|
const edgeId = `mcp-${Date.now().toString(36)}`;
|
|
256
|
-
|
|
257
|
-
await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
|
|
258
|
-
cred = loadDefaultCredential();
|
|
338
|
+
await executePairing(DEFAULT_ENDPOINT, edgeId, values.label);
|
|
259
339
|
}
|
|
260
|
-
|
|
261
|
-
|
|
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`);
|
|
262
345
|
}
|
|
263
346
|
|
|
264
|
-
// 2. Detect tools
|
|
265
347
|
const tools = await detectCLITools();
|
|
266
348
|
console.log(formatDetectedTools(tools));
|
|
267
349
|
|
|
@@ -270,88 +352,51 @@ switch (command) {
|
|
|
270
352
|
break;
|
|
271
353
|
}
|
|
272
354
|
|
|
273
|
-
// 3. Auto-select best tool and configure MCP (HTTP transport)
|
|
274
355
|
const best = tools.find((t) => t.loggedIn) ?? tools[0];
|
|
275
356
|
const config = loadBackendConfig();
|
|
276
357
|
|
|
277
358
|
console.log(`\nAuto-selecting backend: ${displayName(best.name)}...`);
|
|
278
|
-
|
|
279
|
-
if (values.port) {
|
|
280
|
-
process.env.ZHIHAND_PORT = values.port;
|
|
281
|
-
}
|
|
359
|
+
if (values.port) process.env.ZHIHAND_PORT = values.port;
|
|
282
360
|
configureMCP(best.name, config.activeBackend);
|
|
283
361
|
saveBackendConfig({ activeBackend: best.name });
|
|
284
362
|
|
|
285
|
-
// 4. Start daemon
|
|
286
363
|
console.log(`\nStarting daemon...\n`);
|
|
287
364
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
288
|
-
await startDaemon({
|
|
289
|
-
port,
|
|
290
|
-
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
291
|
-
});
|
|
365
|
+
await startDaemon({ port });
|
|
292
366
|
break;
|
|
293
367
|
}
|
|
294
368
|
|
|
295
|
-
case "list":
|
|
296
369
|
case "test": {
|
|
297
|
-
const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
|
|
298
370
|
const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
|
|
299
371
|
const { waitForCommandAck } = await import("../dist/core/sse.js");
|
|
300
372
|
const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
|
|
301
|
-
const {
|
|
302
|
-
|
|
303
|
-
// ── Test Registry ────────────────────────────────────────
|
|
304
|
-
// Kind: "profile" | "status" | "screenshot" | "hid" | "system"
|
|
305
|
-
// - Each kind maps to a required capability (see KIND_CAPABILITY).
|
|
306
|
-
// Platform: undefined | "android" | "ios" (skipped on non-matching)
|
|
307
|
-
// Unsafe: won't run in full-suite unless explicitly requested
|
|
308
|
-
|
|
309
|
-
// Required capability per test kind. Tests whose required capability
|
|
310
|
-
// is not ready are SKIPPED (not failed), unless --force is passed.
|
|
311
|
-
//
|
|
312
|
-
// NOTE: `system` commands (volume, brightness, notification, media,
|
|
313
|
-
// etc.) are executed by the phone app via native OS APIs
|
|
314
|
-
// (AccessibilityService on Android, Shortcuts/system hooks on iOS)
|
|
315
|
-
// and do NOT depend on the BLE HID channel. Only the `hid` kind
|
|
316
|
-
// (click, swipe, type, keycombo — which inject into the paired
|
|
317
|
-
// target via the ZhiHand peripheral) needs the HID capability.
|
|
373
|
+
const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
|
|
374
|
+
|
|
318
375
|
const KIND_CAPABILITY = {
|
|
319
|
-
profile: "none",
|
|
320
|
-
status: "none",
|
|
321
|
-
screenshot: "screen",
|
|
322
|
-
hid: "hid",
|
|
323
|
-
system: "none",
|
|
376
|
+
profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
|
|
324
377
|
};
|
|
325
378
|
const REGISTRY = [
|
|
326
|
-
// Phase A — Device Info API
|
|
327
379
|
{ id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
|
|
328
380
|
{ id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
|
|
329
|
-
// Phase B — Screenshot
|
|
330
381
|
{ id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
|
|
331
|
-
// Phase C — Tap / Touch
|
|
332
382
|
{ id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
|
|
333
383
|
{ id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
334
384
|
{ id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
|
|
335
385
|
{ id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
336
386
|
{ id: 8, phase: "Tap/Touch", label: "Middle click", kind: "hid", params: { action: "middleclick", xRatio: 0.5, yRatio: 0.5 } },
|
|
337
|
-
// Phase D — Swipe / Scroll
|
|
338
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 } },
|
|
339
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 } },
|
|
340
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 } },
|
|
341
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 } },
|
|
342
391
|
{ id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
|
|
343
392
|
{ id: 14, phase: "Swipe/Scroll", label: "Scroll up", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 } },
|
|
344
|
-
// Phase E — Text + Keys
|
|
345
393
|
{ id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
|
|
346
394
|
{ id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
|
|
347
395
|
{ id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
|
|
348
|
-
// Phase F — App Navigation
|
|
349
396
|
{ id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
|
|
350
397
|
{ id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
|
|
351
398
|
{ id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
|
|
352
|
-
// Phase G — Clipboard
|
|
353
399
|
{ id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
|
|
354
|
-
// Phase H — System Navigation
|
|
355
400
|
{ id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
|
|
356
401
|
{ id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
|
|
357
402
|
{ id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
|
|
@@ -360,7 +405,6 @@ switch (command) {
|
|
|
360
405
|
{ id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
|
|
361
406
|
{ id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
|
|
362
407
|
{ id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
|
|
363
|
-
// Phase I — Media
|
|
364
408
|
{ id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
|
|
365
409
|
{ id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
|
|
366
410
|
{ id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
|
|
@@ -370,50 +414,36 @@ switch (command) {
|
|
|
370
414
|
{ id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
|
|
371
415
|
{ id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
|
|
372
416
|
{ id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
|
|
373
|
-
// Phase J — Hardware
|
|
374
417
|
{ id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
|
|
375
418
|
{ id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
|
|
376
419
|
{ id: 41, phase: "Hardware", label: "Power button (⚠️ may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
|
|
377
420
|
];
|
|
378
421
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (t.unsafe) tags.push("unsafe");
|
|
391
|
-
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
392
|
-
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;
|
|
393
433
|
}
|
|
394
|
-
console.log(`\n Total: ${REGISTRY.length} tests`);
|
|
395
|
-
console.log("\nUsage:");
|
|
396
|
-
console.log(" zhihand test # run all safe tests");
|
|
397
|
-
console.log(" zhihand test 4 # run test #4 only");
|
|
398
|
-
console.log(" zhihand test 4,9,20 # run tests #4, #9, #20");
|
|
399
|
-
console.log(" zhihand test all # run ALL tests (including unsafe)");
|
|
400
|
-
process.exit(0);
|
|
401
434
|
}
|
|
402
435
|
|
|
403
|
-
// ── "test" sub-command ───────────────────────────────────
|
|
404
436
|
let testConfig;
|
|
405
437
|
try {
|
|
406
|
-
testConfig =
|
|
438
|
+
testConfig = resolveConfig(credentialArg);
|
|
407
439
|
} catch (err) {
|
|
408
440
|
console.error(`Error: ${err.message}`);
|
|
409
|
-
console.error("Run 'zhihand
|
|
441
|
+
console.error("Run 'zhihand pair' to add a device first.");
|
|
410
442
|
process.exit(1);
|
|
411
443
|
}
|
|
412
444
|
|
|
413
|
-
// Parse which tests to run from positional args
|
|
414
|
-
const filterArg = positionals[1]; // e.g. "4" or "4,9,20" or "all"
|
|
415
445
|
const forceRun = values.force === true;
|
|
416
|
-
let selectedIds = null;
|
|
446
|
+
let selectedIds = null;
|
|
417
447
|
let includeUnsafe = false;
|
|
418
448
|
if (filterArg) {
|
|
419
449
|
if (filterArg === "all") {
|
|
@@ -423,16 +453,13 @@ switch (command) {
|
|
|
423
453
|
filterArg.split(",").map((s) => {
|
|
424
454
|
const trimmed = s.trim();
|
|
425
455
|
const n = Number(trimmed);
|
|
426
|
-
// Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
|
|
427
456
|
return Number.isInteger(n) && n > 0 ? n : NaN;
|
|
428
457
|
}).filter((n) => !isNaN(n))
|
|
429
458
|
);
|
|
430
459
|
if (selectedIds.size === 0) {
|
|
431
460
|
console.error(`Invalid test IDs: ${filterArg}`);
|
|
432
|
-
console.error("Run 'zhihand list' to see available tests.");
|
|
433
461
|
process.exit(1);
|
|
434
462
|
}
|
|
435
|
-
// Explicit selection implies user knows what they're doing
|
|
436
463
|
includeUnsafe = true;
|
|
437
464
|
}
|
|
438
465
|
}
|
|
@@ -441,38 +468,40 @@ switch (command) {
|
|
|
441
468
|
console.log(` Device: ${testConfig.credentialId}`);
|
|
442
469
|
console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
|
|
443
470
|
|
|
444
|
-
// Pre-fetch device profile
|
|
445
|
-
|
|
446
|
-
|
|
471
|
+
// Pre-fetch device profile
|
|
472
|
+
let currentRawAttrs = {};
|
|
473
|
+
let currentProfile = null;
|
|
474
|
+
let currentCaps = null;
|
|
475
|
+
let profileReceivedAtMs = 0;
|
|
447
476
|
try {
|
|
448
|
-
await
|
|
449
|
-
|
|
450
|
-
|
|
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";
|
|
451
486
|
|
|
452
|
-
// ── Capability readiness pre-flight ──
|
|
453
487
|
console.log(" ── Capability readiness ──");
|
|
454
|
-
|
|
455
|
-
if (!preCaps) {
|
|
488
|
+
if (!currentCaps) {
|
|
456
489
|
console.log(" ⚠️ Device profile not loaded — all capability gates will allow tests through.");
|
|
457
490
|
} else {
|
|
458
491
|
const fmt = (name, cap) => ` ${cap.ready ? "✅" : "⚠️"} ${name.padEnd(14)} ${cap.ready ? "ready" : "NOT ready"} — ${cap.reason}`;
|
|
459
|
-
console.log(fmt("screen_sharing",
|
|
460
|
-
console.log(fmt("hid",
|
|
461
|
-
console.log(fmt("live_session",
|
|
462
|
-
const ageStr =
|
|
463
|
-
console.log(` ${
|
|
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)" : ""}`);
|
|
464
497
|
if (forceRun) {
|
|
465
498
|
console.log(" --force passed: capability gates disabled.");
|
|
466
499
|
}
|
|
467
500
|
}
|
|
468
501
|
console.log("");
|
|
469
502
|
|
|
470
|
-
let passed = 0;
|
|
471
|
-
let failed = 0;
|
|
472
|
-
let skipped = 0;
|
|
473
|
-
let totalSteps = 0;
|
|
503
|
+
let passed = 0, failed = 0, skipped = 0, totalSteps = 0;
|
|
474
504
|
|
|
475
|
-
// ── Resolve platform-aware params (evaluated at test time) ──
|
|
476
505
|
function resolvePlatformAwareParams(variant) {
|
|
477
506
|
const platform = getDevicePlatform();
|
|
478
507
|
if (variant === "open_wechat") {
|
|
@@ -490,7 +519,6 @@ switch (command) {
|
|
|
490
519
|
return null;
|
|
491
520
|
}
|
|
492
521
|
|
|
493
|
-
// ── Test runners (shared ACK logic) ──
|
|
494
522
|
async function runCommandTest(t, command) {
|
|
495
523
|
totalSteps++;
|
|
496
524
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
@@ -520,25 +548,18 @@ switch (command) {
|
|
|
520
548
|
}
|
|
521
549
|
|
|
522
550
|
async function runSingleTest(t) {
|
|
523
|
-
// Platform skip (evaluated at test time — Test 1 may have just populated the profile)
|
|
524
551
|
const currentPlatform = getDevicePlatform();
|
|
525
552
|
if (t.platform && t.platform !== currentPlatform) {
|
|
526
|
-
totalSteps++;
|
|
527
|
-
skipped++;
|
|
553
|
+
totalSteps++; skipped++;
|
|
528
554
|
console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
|
|
529
555
|
return;
|
|
530
556
|
}
|
|
531
|
-
|
|
532
|
-
// Capability gate (unless --force) — evaluated per-test so later
|
|
533
|
-
// tests see fresh readiness flags pushed after Test 1's profile fetch.
|
|
534
|
-
if (!forceRun && isDeviceProfileLoaded()) {
|
|
557
|
+
if (!forceRun && currentCaps) {
|
|
535
558
|
const requiredCap = KIND_CAPABILITY[t.kind] ?? "none";
|
|
536
559
|
if (requiredCap !== "none") {
|
|
537
|
-
const
|
|
538
|
-
const gate = requiredCap === "screen" ? caps.screen_sharing : caps.hid;
|
|
560
|
+
const gate = requiredCap === "screen" ? currentCaps.screen_sharing : currentCaps.hid;
|
|
539
561
|
if (!gate.ready) {
|
|
540
|
-
totalSteps++;
|
|
541
|
-
skipped++;
|
|
562
|
+
totalSteps++; skipped++;
|
|
542
563
|
console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${requiredCap} not ready: ${gate.reason})`);
|
|
543
564
|
return;
|
|
544
565
|
}
|
|
@@ -551,10 +572,14 @@ switch (command) {
|
|
|
551
572
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
552
573
|
const t0 = Date.now();
|
|
553
574
|
try {
|
|
554
|
-
await
|
|
575
|
+
const fetched = await fetchDeviceProfileOnce(testConfig);
|
|
555
576
|
const ms = Date.now() - t0;
|
|
556
|
-
if (
|
|
557
|
-
|
|
577
|
+
if (fetched) {
|
|
578
|
+
currentRawAttrs = fetched.rawAttrs;
|
|
579
|
+
profileReceivedAtMs = fetched.receivedAtMs;
|
|
580
|
+
currentProfile = extractStatic(currentRawAttrs);
|
|
581
|
+
currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
|
|
582
|
+
const s = currentProfile;
|
|
558
583
|
console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
|
|
559
584
|
passed++;
|
|
560
585
|
} else {
|
|
@@ -571,11 +596,22 @@ switch (command) {
|
|
|
571
596
|
totalSteps++;
|
|
572
597
|
process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
|
|
573
598
|
try {
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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);
|
|
579
615
|
const topLevel = Object.keys(status).filter((k) => k !== "raw" && k !== "capabilities");
|
|
580
616
|
const rawKeys = Object.keys(status.raw ?? {});
|
|
581
617
|
const caps = status.capabilities ?? {};
|
|
@@ -608,9 +644,6 @@ switch (command) {
|
|
|
608
644
|
const shot = await fetchScreenshot(testConfig);
|
|
609
645
|
const kb = (shot.buffer.length / 1024).toFixed(0);
|
|
610
646
|
const ms = Date.now() - t0;
|
|
611
|
-
// The screenshot endpoint returns the last cached frame even
|
|
612
|
-
// when the phone isn't actively sharing — the age header is
|
|
613
|
-
// the only way to tell. Treat stale as failure.
|
|
614
647
|
if (shot.stale) {
|
|
615
648
|
const threshold = getSnapshotStaleThresholdMs();
|
|
616
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)`);
|
|
@@ -627,11 +660,11 @@ switch (command) {
|
|
|
627
660
|
}
|
|
628
661
|
case "hid": {
|
|
629
662
|
const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
|
|
630
|
-
await runCommandTest(t, createControlCommand(params));
|
|
663
|
+
await runCommandTest(t, createControlCommand(params, getDevicePlatform()));
|
|
631
664
|
break;
|
|
632
665
|
}
|
|
633
666
|
case "system": {
|
|
634
|
-
await runCommandTest(t, createSystemCommand(t.params));
|
|
667
|
+
await runCommandTest(t, createSystemCommand(t.params, getDevicePlatform()));
|
|
635
668
|
break;
|
|
636
669
|
}
|
|
637
670
|
}
|
|
@@ -639,7 +672,6 @@ switch (command) {
|
|
|
639
672
|
|
|
640
673
|
const pause = () => new Promise((r) => setTimeout(r, 1500));
|
|
641
674
|
|
|
642
|
-
// Select tests to run
|
|
643
675
|
const toRun = REGISTRY.filter((t) => {
|
|
644
676
|
if (selectedIds) return selectedIds.has(t.id);
|
|
645
677
|
if (t.unsafe && !includeUnsafe) return false;
|
|
@@ -648,17 +680,13 @@ switch (command) {
|
|
|
648
680
|
|
|
649
681
|
if (toRun.length === 0) {
|
|
650
682
|
console.error("No matching tests.");
|
|
651
|
-
console.error("Run 'zhihand list' to see available tests.");
|
|
652
683
|
process.exit(1);
|
|
653
684
|
}
|
|
654
685
|
|
|
655
|
-
// Warn about missing IDs
|
|
656
686
|
if (selectedIds) {
|
|
657
687
|
const foundIds = new Set(toRun.map((t) => t.id));
|
|
658
688
|
const missing = [...selectedIds].filter((id) => !foundIds.has(id));
|
|
659
|
-
if (missing.length) {
|
|
660
|
-
console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
|
|
661
|
-
}
|
|
689
|
+
if (missing.length) console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
|
|
662
690
|
}
|
|
663
691
|
|
|
664
692
|
let currentPhase = "";
|
|
@@ -672,13 +700,9 @@ switch (command) {
|
|
|
672
700
|
if (i < toRun.length - 1) await pause();
|
|
673
701
|
}
|
|
674
702
|
|
|
675
|
-
// ── Summary ──────────────────────────────────────────────
|
|
676
703
|
console.log(`\n Result: ${passed}/${totalSteps} passed, ${failed} failed, ${skipped} skipped`);
|
|
677
|
-
if (failed === 0)
|
|
678
|
-
|
|
679
|
-
} else {
|
|
680
|
-
console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
|
|
681
|
-
}
|
|
704
|
+
if (failed === 0) console.log(" ✅ All tests passed! Device is fully responsive.");
|
|
705
|
+
else console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
|
|
682
706
|
process.exit(failed > 0 ? 1 : 0);
|
|
683
707
|
}
|
|
684
708
|
|