@nickname4th/pura-cli 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![pura social preview](assets/pura-social-preview.png)
4
4
 
5
- pura is a LAN Android device mirror for product and design teams. A central Hub shows all online Android devices, while each developer runs a local Agent that talks to their own USB-connected phone through ADB.
5
+ pura is a LAN Android device mirror for product and design teams. A central Hub shows all online Android devices, while each developer runs a local Agent that talks to their own USB-connected phone through ADB. Agents keep outbound connections to the Hub, so the Hub can run inside Docker without reaching back into developer laptops.
6
6
 
7
7
  No login, no cloud, no public tunnel. It is meant for trusted office networks.
8
8
 
@@ -105,9 +105,11 @@ Each developer connects their local Agent to the Hub. Once connected, the Hub we
105
105
  pura-cli connect 192.168.100.128:8787 --name "Zhang San"
106
106
  ```
107
107
 
108
- The Agent listens on `8788` by default and continuously reports local ADB devices to the Hub. Use the web UI to publish, rename, unpublish, and manage devices.
108
+ The Agent keeps an outbound control WebSocket to the Hub and continuously reports local ADB devices. Use the web UI to publish, rename, unpublish, and manage devices.
109
109
 
110
- If the Hub cannot reach the auto-detected Agent URL, specify it:
110
+ The Agent still exposes `8788` locally for diagnostics and standalone mode, but the Hub no longer depends on reverse HTTP access to that port. In normal Hub deployments, you should not need `--public-url`.
111
+
112
+ For diagnostics, you can still override the announced local URL:
111
113
 
112
114
  ```bash
113
115
  pura-cli connect 192.168.100.128:8787 --name "Zhang San" --public-url http://192.168.100.45:8788
@@ -158,6 +160,7 @@ pura-cli connect device --serial RFCY10DHQ3P --name "Samsung S25" --owner "Li Si
158
160
 
159
161
  - Hub maintains online Agents and devices, serves the web UI, and proxies video WebSocket/tap requests.
160
162
  - Agent runs on each developer machine and owns ADB, screen capture, tap execution, and device metadata.
163
+ - Agent opens outbound control/video WebSockets to the Hub. The Hub does not need to call back into Agent LAN addresses, which makes Docker/NAT/firewall deployment much more reliable.
161
164
  - CLI commands:
162
165
  - `pura-cli hub`
163
166
  - `pura-cli connect <hub>`
@@ -177,6 +180,8 @@ Hub:
177
180
  - `DELETE /api/devices/:deviceId/publication`
178
181
  - `DELETE /api/sessions/:id`
179
182
  - `WS /ws/sessions/:id/video`
183
+ - `WS /ws/agents/:agentId/control`
184
+ - `WS /ws/agents/:agentId/sessions/:agentSessionId/video`
180
185
 
181
186
  Agent:
182
187
 
@@ -196,7 +201,7 @@ Agent:
196
201
  - `HUB_URL=http://<hub-ip>:8787`
197
202
  - `AGENT_ID`
198
203
  - `AGENT_NAME`
199
- - `PUBLIC_URL=http://<agent-ip>:8788`
204
+ - `PUBLIC_URL=http://<agent-ip>:8788` optional diagnostic URL; Hub control does not depend on it
200
205
  - `ADB_PATH=adb`
201
206
  - `STREAM_SIZE` optional; unset uses native device resolution
202
207
  - `STREAM_BITRATE=8000000`
@@ -212,15 +217,15 @@ Release flow:
212
217
 
213
218
  1. Update `version` in `package.json`.
214
219
  2. Run `npm run check`, `npm run build`, and `npm pack --dry-run`.
215
- 3. Push a tag like `v0.1.0`.
216
- 4. GitHub Actions publishes `@nickname4th/pura-cli` to npm and `ghcr.io/liutianjie/pura` to GHCR.
220
+ 3. Publish manually with `npm publish --access public`, or push a tag like `v0.1.3` to use the release workflow.
221
+ 4. The Docker workflow publishes `ghcr.io/liutianjie/pura:main` from `main` and builds both `linux/amd64` and `linux/arm64`.
217
222
 
218
223
  The release workflow requires an `NPM_TOKEN` repository secret.
219
224
 
220
225
  ## Notes
221
226
 
222
227
  - The current video path uses Android `screenrecord` H.264 output. No Android app or root is required.
223
- - Mouse control currently supports tap only.
228
+ - Mouse control supports tap, long press, scroll/swipe, system keys, text input, screenshots, and shared cursor annotations.
224
229
  - Do not expose Hub or Agent ports directly to the public internet.
225
230
  - Agent Docker is intentionally not the default because local USB/ADB access is much smoother with native `pura-cli`.
226
231
  - Some Android builds enforce `screenrecord` time limits; the Agent restarts the stream automatically when it exits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickname4th/pura-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "LAN Android device mirroring hub and developer CLI for distributed teams.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  const execFileAsync = promisify(execFile);
4
- const ADB = process.env.ADB_PATH ?? "adb";
7
+ const ADB = resolveAdbCommand();
5
8
  const INCLUDE_TCP_DEVICES = process.env.INCLUDE_TCP_DEVICES === "true";
6
9
  export function adbCommand(args) {
7
10
  return {
@@ -151,6 +154,18 @@ export async function getDisplaySize(serial) {
151
154
  function clamp(value, min, max) {
152
155
  return Math.max(min, Math.min(max, value));
153
156
  }
157
+ function resolveAdbCommand() {
158
+ if (process.env.ADB_PATH)
159
+ return process.env.ADB_PATH;
160
+ const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
161
+ const candidates = [
162
+ sdkRoot ? path.join(sdkRoot, "platform-tools", "adb") : "",
163
+ path.join(os.homedir(), "Library", "Android", "sdk", "platform-tools", "adb"),
164
+ "/opt/homebrew/bin/adb",
165
+ "/usr/local/bin/adb"
166
+ ].filter(Boolean);
167
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? "adb";
168
+ }
154
169
  const keyEvents = {
155
170
  back: "KEYCODE_BACK",
156
171
  home: "KEYCODE_HOME",
@@ -1,8 +1,10 @@
1
+ import { WebSocket } from "ws";
1
2
  import { captureScreenshot, controlDevice, listDevices, longPressDevice, swipeDevice, tapDevice } from "./adb.js";
2
- import { getLanAddress, normalizeHttpUrl } from "./network.js";
3
+ import { getLanAddress, httpToWs, normalizeHttpUrl } from "./network.js";
3
4
  import { getPublications, publishDevice, unpublishDevice } from "./registry.js";
4
5
  import { listDeviceScreenshots, saveScreenshot } from "./screenshots.js";
5
6
  import { deleteSession, getOrCreateSession, listSessions } from "./sessions.js";
7
+ const activeVideoRelays = new Map();
6
8
  export function installAgentRoutes(app) {
7
9
  app.get("/api/devices", async (_req, res) => {
8
10
  try {
@@ -170,6 +172,183 @@ export function startAgentHeartbeat(options) {
170
172
  void tick();
171
173
  setInterval(tick, Number(process.env.HEARTBEAT_MS ?? 3000));
172
174
  }
175
+ export function startAgentControlChannel(options) {
176
+ if (!options.hubUrl)
177
+ return;
178
+ const hubUrl = normalizeHttpUrl(options.hubUrl);
179
+ const controlUrl = `${httpToWs(hubUrl)}/ws/agents/${encodeURIComponent(options.agentId)}/control`;
180
+ let reconnectTimer;
181
+ const connect = () => {
182
+ const socket = new WebSocket(controlUrl);
183
+ socket.on("open", () => {
184
+ socket.send(JSON.stringify({
185
+ type: "hello",
186
+ agentId: options.agentId,
187
+ agentName: options.agentName
188
+ }));
189
+ });
190
+ socket.on("message", (data) => {
191
+ void handleControlMessage(socket, data.toString("utf8"), options);
192
+ });
193
+ socket.on("close", () => {
194
+ reconnectTimer = setTimeout(connect, Number(process.env.AGENT_RECONNECT_MS ?? 1500));
195
+ });
196
+ socket.on("error", (error) => {
197
+ console.error(`Hub control channel failed: ${error instanceof Error ? error.message : String(error)}`);
198
+ socket.close();
199
+ });
200
+ };
201
+ connect();
202
+ return () => {
203
+ if (reconnectTimer)
204
+ clearTimeout(reconnectTimer);
205
+ };
206
+ }
207
+ async function handleControlMessage(socket, text, options) {
208
+ let request;
209
+ try {
210
+ request = JSON.parse(text);
211
+ }
212
+ catch {
213
+ socket.close(1003, "invalid control message");
214
+ return;
215
+ }
216
+ if (request.type !== "request" || !request.requestId || !request.command)
217
+ return;
218
+ try {
219
+ const body = await runControlCommand(request, options);
220
+ sendControlResponse(socket, { type: "response", requestId: request.requestId, ok: true, body });
221
+ }
222
+ catch (error) {
223
+ sendControlResponse(socket, {
224
+ type: "response",
225
+ requestId: request.requestId,
226
+ ok: false,
227
+ error: error instanceof Error ? error.message : String(error)
228
+ });
229
+ }
230
+ }
231
+ async function runControlCommand(request, options) {
232
+ const serial = request.serial;
233
+ const body = request.body ?? {};
234
+ switch (request.command) {
235
+ case "publish":
236
+ return {
237
+ publication: publishDevice(requireSerial(serial), {
238
+ label: asString(body.label),
239
+ owner: asString(body.owner),
240
+ note: asString(body.note)
241
+ })
242
+ };
243
+ case "unpublish":
244
+ return { publication: unpublishDevice(requireSerial(serial)) };
245
+ case "start-session": {
246
+ const session = getOrCreateSession(requireSerial(serial), { restart: body.restart === true });
247
+ const hubSessionId = asString(body.hubSessionId);
248
+ if (!hubSessionId)
249
+ throw new Error("hubSessionId is required");
250
+ connectVideoRelay({
251
+ hubUrl: requireHubUrl(options.hubUrl),
252
+ agentId: options.agentId,
253
+ agentSessionId: session.id,
254
+ hubSessionId,
255
+ port: options.port
256
+ });
257
+ return { session };
258
+ }
259
+ case "delete-session":
260
+ return { deleted: deleteSession(requireString(body.sessionId, "sessionId")) };
261
+ case "screenshot": {
262
+ const image = await captureScreenshot(requireSerial(serial));
263
+ return { contentType: "image/png", data: image.toString("base64") };
264
+ }
265
+ case "tap":
266
+ return { tap: await tapDevice(requireSerial(serial), requireRatio(body.xRatio, "xRatio"), requireRatio(body.yRatio, "yRatio")) };
267
+ case "long-press":
268
+ return {
269
+ longPress: await longPressDevice(requireSerial(serial), requireRatio(body.xRatio, "xRatio"), requireRatio(body.yRatio, "yRatio"), asNumber(body.durationMs) ?? 650)
270
+ };
271
+ case "swipe":
272
+ return {
273
+ swipe: await swipeDevice(requireSerial(serial), {
274
+ xStartRatio: requireRatio(body.xStartRatio, "xStartRatio"),
275
+ yStartRatio: requireRatio(body.yStartRatio, "yStartRatio"),
276
+ xEndRatio: requireRatio(body.xEndRatio, "xEndRatio"),
277
+ yEndRatio: requireRatio(body.yEndRatio, "yEndRatio"),
278
+ durationMs: asNumber(body.durationMs)
279
+ })
280
+ };
281
+ case "control": {
282
+ const action = asString(body.action);
283
+ if (!action || !controlActions.includes(action))
284
+ throw new Error("A supported control action is required");
285
+ return { control: await controlDevice(requireSerial(serial), action, asString(body.value)) };
286
+ }
287
+ default:
288
+ throw new Error(`Unsupported agent command: ${request.command}`);
289
+ }
290
+ }
291
+ function connectVideoRelay(options) {
292
+ activeVideoRelays.get(options.hubSessionId)?.hub.close();
293
+ activeVideoRelays.get(options.hubSessionId)?.local.close();
294
+ const hub = new WebSocket(`${httpToWs(options.hubUrl)}/ws/agents/${encodeURIComponent(options.agentId)}/sessions/${encodeURIComponent(options.agentSessionId)}/video?hubSessionId=${encodeURIComponent(options.hubSessionId)}`);
295
+ const local = new WebSocket(`ws://127.0.0.1:${options.port}/ws/sessions/${encodeURIComponent(options.agentSessionId)}/video`);
296
+ activeVideoRelays.set(options.hubSessionId, { hub, local });
297
+ local.on("message", (data, isBinary) => {
298
+ if (hub.readyState === WebSocket.OPEN) {
299
+ hub.send(data, { binary: isBinary });
300
+ }
301
+ });
302
+ const cleanup = () => {
303
+ const relay = activeVideoRelays.get(options.hubSessionId);
304
+ if (relay?.hub === hub && relay.local === local) {
305
+ activeVideoRelays.delete(options.hubSessionId);
306
+ }
307
+ if (hub.readyState === WebSocket.OPEN || hub.readyState === WebSocket.CONNECTING)
308
+ hub.close();
309
+ if (local.readyState === WebSocket.OPEN || local.readyState === WebSocket.CONNECTING)
310
+ local.close();
311
+ };
312
+ hub.on("close", cleanup);
313
+ local.on("close", cleanup);
314
+ hub.on("error", cleanup);
315
+ local.on("error", cleanup);
316
+ }
317
+ function sendControlResponse(socket, response) {
318
+ if (socket.readyState === WebSocket.OPEN) {
319
+ socket.send(JSON.stringify(response));
320
+ }
321
+ }
322
+ function requireHubUrl(value) {
323
+ if (!value)
324
+ throw new Error("Agent is missing HUB_URL");
325
+ return normalizeHttpUrl(value);
326
+ }
327
+ function requireSerial(value) {
328
+ if (!value)
329
+ throw new Error("serial is required");
330
+ return value;
331
+ }
332
+ function requireString(value, name) {
333
+ const text = asString(value);
334
+ if (!text)
335
+ throw new Error(`${name} is required`);
336
+ return text;
337
+ }
338
+ function asString(value) {
339
+ return typeof value === "string" ? value : undefined;
340
+ }
341
+ function asNumber(value) {
342
+ const number = Number(value);
343
+ return Number.isFinite(number) ? number : undefined;
344
+ }
345
+ function requireRatio(value, name) {
346
+ const number = Number(value);
347
+ if (!Number.isFinite(number) || number < 0 || number > 1) {
348
+ throw new Error(`${name} must be a number between 0 and 1`);
349
+ }
350
+ return number;
351
+ }
173
352
  async function listAgentDevices() {
174
353
  const publications = getPublications();
175
354
  return (await listDevices()).map((device) => ({
@@ -204,6 +204,16 @@ function printLaunchAgentStatus() {
204
204
  }
205
205
  }
206
206
  function makeLaunchAgentPlist(nodePath, cliPath) {
207
+ const environment = {
208
+ PATH: process.env.PATH,
209
+ ANDROID_HOME: process.env.ANDROID_HOME,
210
+ ANDROID_SDK_ROOT: process.env.ANDROID_SDK_ROOT,
211
+ ADB_PATH: findExecutable("adb")
212
+ };
213
+ const environmentEntries = Object.entries(environment)
214
+ .filter((entry) => Boolean(entry[1]))
215
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
216
+ .join("\n");
207
217
  return `<?xml version="1.0" encoding="UTF-8"?>
208
218
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
209
219
  <plist version="1.0">
@@ -220,6 +230,10 @@ function makeLaunchAgentPlist(nodePath, cliPath) {
220
230
  <true/>
221
231
  <key>KeepAlive</key>
222
232
  <true/>
233
+ <key>EnvironmentVariables</key>
234
+ <dict>
235
+ ${environmentEntries}
236
+ </dict>
223
237
  <key>StandardOutPath</key>
224
238
  <string>${escapeXml(path.join(os.homedir(), "Library", "Logs", "pura-agent.log"))}</string>
225
239
  <key>StandardErrorPath</key>
@@ -256,6 +270,16 @@ function resolveAgentDataDir(value) {
256
270
  return path.join(os.homedir(), ".pura", "agent-data");
257
271
  return path.isAbsolute(value) ? value : path.resolve(value);
258
272
  }
273
+ function findExecutable(name) {
274
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
275
+ if (!directory)
276
+ continue;
277
+ const candidate = path.join(directory, name);
278
+ if (fs.existsSync(candidate))
279
+ return candidate;
280
+ }
281
+ return undefined;
282
+ }
259
283
  function startServer(env) {
260
284
  const child = spawn(process.execPath, [new URL("./index.js", import.meta.url).pathname], {
261
285
  stdio: "inherit",
@@ -1,24 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { WebSocket } from "ws";
3
3
  import { makeDeviceId, parseDeviceId } from "./device-id.js";
4
- import { httpToWs } from "./network.js";
5
4
  import { listDeviceScreenshots, saveScreenshot } from "./screenshots.js";
6
5
  const agents = new Map();
7
6
  const sessions = new Map();
7
+ const pendingAgentRequests = new Map();
8
8
  const AGENT_TTL_MS = Number(process.env.AGENT_TTL_MS ?? 15_000);
9
+ const AGENT_REQUEST_TIMEOUT_MS = Number(process.env.AGENT_REQUEST_TIMEOUT_MS ?? 30_000);
9
10
  export function installHubRoutes(app) {
10
11
  app.post("/api/agents/heartbeat", (req, res) => {
11
12
  const body = req.body;
12
- if (!body.agentId || !body.url || !Array.isArray(body.devices)) {
13
- res.status(400).json({ error: "agentId, url and devices are required" });
13
+ if (!body.agentId || !Array.isArray(body.devices)) {
14
+ res.status(400).json({ error: "agentId and devices are required" });
14
15
  return;
15
16
  }
17
+ const existing = agents.get(body.agentId);
16
18
  agents.set(body.agentId, {
17
19
  agentId: body.agentId,
18
20
  agentName: body.agentName,
19
- url: body.url.replace(/\/$/, ""),
21
+ url: body.url?.replace(/\/$/, "") ?? existing?.url ?? "",
20
22
  devices: body.devices,
21
- lastSeen: Date.now()
23
+ lastSeen: Date.now(),
24
+ control: existing?.control
22
25
  });
23
26
  res.json({ ok: true });
24
27
  });
@@ -28,12 +31,15 @@ export function installHubRoutes(app) {
28
31
  app.put("/api/devices/:deviceId/publication", async (req, res) => {
29
32
  try {
30
33
  const target = findDevice(req.params.deviceId);
31
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/publication`, {
32
- method: "PUT",
33
- headers: { "Content-Type": "application/json" },
34
- body: JSON.stringify(req.body ?? {})
34
+ const body = await sendAgentRequest(target.agent, "publish", {
35
+ serial: target.remoteSerial,
36
+ body: req.body ?? {}
37
+ });
38
+ res.json({
39
+ publication: body.publication
40
+ ? { ...body.publication, serial: req.params.deviceId }
41
+ : body.publication
35
42
  });
36
- res.status(response.status).json(await response.json());
37
43
  }
38
44
  catch (error) {
39
45
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to publish device" });
@@ -42,10 +48,14 @@ export function installHubRoutes(app) {
42
48
  app.delete("/api/devices/:deviceId/publication", async (req, res) => {
43
49
  try {
44
50
  const target = findDevice(req.params.deviceId);
45
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/publication`, {
46
- method: "DELETE"
51
+ const body = await sendAgentRequest(target.agent, "unpublish", {
52
+ serial: target.remoteSerial
53
+ });
54
+ res.json({
55
+ publication: body.publication
56
+ ? { ...body.publication, serial: req.params.deviceId }
57
+ : body.publication
47
58
  });
48
- res.status(response.status).json(await response.json());
49
59
  }
50
60
  catch (error) {
51
61
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to unpublish device" });
@@ -58,24 +68,19 @@ export function installHubRoutes(app) {
58
68
  if (restart) {
59
69
  await deleteHubSessionsForDevice(req.params.deviceId);
60
70
  }
61
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/session`, {
62
- method: "POST",
63
- headers: { "Content-Type": "application/json" },
64
- body: JSON.stringify({ restart })
71
+ const sessionId = randomUUID();
72
+ const body = await sendAgentRequest(target.agent, "start-session", {
73
+ serial: target.remoteSerial,
74
+ body: { restart, hubSessionId: sessionId }
65
75
  });
66
- if (!response.ok) {
67
- res.status(response.status).json(await response.json());
68
- return;
69
- }
70
- const body = (await response.json());
71
76
  const session = {
72
- id: randomUUID(),
77
+ id: sessionId,
73
78
  deviceId: req.params.deviceId,
74
79
  agentId: target.agent.agentId,
75
- agentUrl: target.agent.url,
76
80
  agentSessionId: body.session.id,
77
81
  serial: target.remoteSerial,
78
- startedAt: Date.now()
82
+ startedAt: Date.now(),
83
+ clients: new Set()
79
84
  };
80
85
  sessions.set(session.id, session);
81
86
  res.json({
@@ -92,16 +97,10 @@ export function installHubRoutes(app) {
92
97
  });
93
98
  app.get("/api/devices/:deviceId/screenshot", async (req, res) => {
94
99
  try {
95
- const target = findDevice(req.params.deviceId);
96
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/screenshot`);
97
- if (!response.ok) {
98
- res.status(response.status).json({ error: await response.text() });
99
- return;
100
- }
101
- const image = Buffer.from(await response.arrayBuffer());
102
- res.setHeader("Content-Type", response.headers.get("content-type") ?? "image/png");
100
+ const image = await captureRemoteScreenshot(req.params.deviceId);
101
+ res.setHeader("Content-Type", image.contentType);
103
102
  res.setHeader("Cache-Control", "no-store");
104
- res.end(image);
103
+ res.end(image.buffer);
105
104
  }
106
105
  catch (error) {
107
106
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to capture remote device screenshot" });
@@ -109,14 +108,8 @@ export function installHubRoutes(app) {
109
108
  });
110
109
  app.post("/api/devices/:deviceId/screenshots", async (req, res) => {
111
110
  try {
112
- const target = findDevice(req.params.deviceId);
113
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/screenshot`);
114
- if (!response.ok) {
115
- res.status(response.status).json({ error: await response.text() });
116
- return;
117
- }
118
- const image = Buffer.from(await response.arrayBuffer());
119
- res.json({ screenshot: await saveScreenshot(image, req.params.deviceId) });
111
+ const image = await captureRemoteScreenshot(req.params.deviceId);
112
+ res.json({ screenshot: await saveScreenshot(image.buffer, req.params.deviceId) });
120
113
  }
121
114
  catch (error) {
122
115
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to save remote device screenshot" });
@@ -134,12 +127,7 @@ export function installHubRoutes(app) {
134
127
  app.post("/api/devices/:deviceId/tap", async (req, res) => {
135
128
  try {
136
129
  const target = findDevice(req.params.deviceId);
137
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/tap`, {
138
- method: "POST",
139
- headers: { "Content-Type": "application/json" },
140
- body: JSON.stringify(req.body ?? {})
141
- });
142
- res.status(response.status).json(await response.json());
130
+ res.json(await sendAgentRequest(target.agent, "tap", { serial: target.remoteSerial, body: req.body ?? {} }));
143
131
  }
144
132
  catch (error) {
145
133
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to tap remote device" });
@@ -148,12 +136,7 @@ export function installHubRoutes(app) {
148
136
  app.post("/api/devices/:deviceId/long-press", async (req, res) => {
149
137
  try {
150
138
  const target = findDevice(req.params.deviceId);
151
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/long-press`, {
152
- method: "POST",
153
- headers: { "Content-Type": "application/json" },
154
- body: JSON.stringify(req.body ?? {})
155
- });
156
- res.status(response.status).json(await response.json());
139
+ res.json(await sendAgentRequest(target.agent, "long-press", { serial: target.remoteSerial, body: req.body ?? {} }));
157
140
  }
158
141
  catch (error) {
159
142
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to long press remote device" });
@@ -162,12 +145,7 @@ export function installHubRoutes(app) {
162
145
  app.post("/api/devices/:deviceId/swipe", async (req, res) => {
163
146
  try {
164
147
  const target = findDevice(req.params.deviceId);
165
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/swipe`, {
166
- method: "POST",
167
- headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify(req.body ?? {})
169
- });
170
- res.status(response.status).json(await response.json());
148
+ res.json(await sendAgentRequest(target.agent, "swipe", { serial: target.remoteSerial, body: req.body ?? {} }));
171
149
  }
172
150
  catch (error) {
173
151
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to swipe remote device" });
@@ -176,12 +154,7 @@ export function installHubRoutes(app) {
176
154
  app.post("/api/devices/:deviceId/control", async (req, res) => {
177
155
  try {
178
156
  const target = findDevice(req.params.deviceId);
179
- const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/control`, {
180
- method: "POST",
181
- headers: { "Content-Type": "application/json" },
182
- body: JSON.stringify(req.body ?? {})
183
- });
184
- res.status(response.status).json(await response.json());
157
+ res.json(await sendAgentRequest(target.agent, "control", { serial: target.remoteSerial, body: req.body ?? {} }));
185
158
  }
186
159
  catch (error) {
187
160
  res.status(502).json({ error: error instanceof Error ? error.message : "Failed to control remote device" });
@@ -194,20 +167,91 @@ export function installHubRoutes(app) {
194
167
  res.json({ deleted: false });
195
168
  return;
196
169
  }
197
- await fetch(`${session.agentUrl}/api/sessions/${encodeURIComponent(session.agentSessionId)}`, {
198
- method: "DELETE"
199
- }).catch(() => undefined);
170
+ closeHubSession(session);
171
+ const agent = agents.get(session.agentId);
172
+ if (agent) {
173
+ await sendAgentRequest(agent, "delete-session", {
174
+ body: { sessionId: session.agentSessionId }
175
+ }).catch(() => undefined);
176
+ }
200
177
  res.json({ deleted: true });
201
178
  });
202
179
  }
203
- async function deleteHubSessionsForDevice(deviceId) {
204
- const staleSessions = [...sessions.values()].filter((session) => session.deviceId === deviceId);
205
- await Promise.all(staleSessions.map(async (session) => {
206
- sessions.delete(session.id);
207
- await fetch(`${session.agentUrl}/api/sessions/${encodeURIComponent(session.agentSessionId)}`, {
208
- method: "DELETE"
209
- }).catch(() => undefined);
210
- }));
180
+ export function attachAgentControlClient(agentId, socket) {
181
+ const existing = agents.get(agentId);
182
+ if (existing?.control && existing.control.readyState === WebSocket.OPEN) {
183
+ existing.control.close(1001, "replaced by a new control channel");
184
+ }
185
+ agents.set(agentId, {
186
+ agentId,
187
+ agentName: existing?.agentName,
188
+ url: existing?.url ?? "",
189
+ devices: existing?.devices ?? [],
190
+ lastSeen: Date.now(),
191
+ control: socket
192
+ });
193
+ socket.on("message", (data) => {
194
+ try {
195
+ const message = JSON.parse(data.toString("utf8"));
196
+ if (message.type === "hello") {
197
+ const agent = agents.get(agentId);
198
+ if (agent) {
199
+ agent.agentName = message.agentName ?? agent.agentName;
200
+ agent.lastSeen = Date.now();
201
+ }
202
+ return;
203
+ }
204
+ if (message.type !== "response")
205
+ return;
206
+ const pending = pendingAgentRequests.get(message.requestId);
207
+ if (!pending)
208
+ return;
209
+ pendingAgentRequests.delete(message.requestId);
210
+ clearTimeout(pending.timer);
211
+ if (message.ok) {
212
+ pending.resolve(message.body);
213
+ }
214
+ else {
215
+ pending.reject(new Error(message.error || "Agent request failed"));
216
+ }
217
+ }
218
+ catch {
219
+ socket.close(1003, "invalid control message");
220
+ }
221
+ });
222
+ socket.on("close", () => {
223
+ const agent = agents.get(agentId);
224
+ if (agent?.control === socket) {
225
+ agent.control = undefined;
226
+ agent.lastSeen = Date.now();
227
+ }
228
+ });
229
+ }
230
+ export function attachAgentVideoStream(agentId, agentSessionId, hubSessionId, stream) {
231
+ const session = sessions.get(hubSessionId);
232
+ if (!session || session.agentId !== agentId || session.agentSessionId !== agentSessionId) {
233
+ stream.close(1008, "session not found");
234
+ return;
235
+ }
236
+ if (session.stream && session.stream.readyState === WebSocket.OPEN) {
237
+ session.stream.close(1001, "replaced by a new stream");
238
+ }
239
+ session.stream = stream;
240
+ stream.on("message", (data, isBinary) => {
241
+ for (const client of session.clients) {
242
+ if (client.readyState === WebSocket.OPEN) {
243
+ client.send(data, { binary: isBinary });
244
+ }
245
+ }
246
+ });
247
+ stream.on("close", () => {
248
+ if (session.stream === stream) {
249
+ session.stream = undefined;
250
+ for (const client of session.clients) {
251
+ client.close(1001, "agent stream closed");
252
+ }
253
+ }
254
+ });
211
255
  }
212
256
  export function attachHubVideoClient(sessionId, client) {
213
257
  const session = sessions.get(sessionId);
@@ -215,22 +259,51 @@ export function attachHubVideoClient(sessionId, client) {
215
259
  client.close(1008, "session not found");
216
260
  return;
217
261
  }
218
- const remote = new WebSocket(`${httpToWs(session.agentUrl)}/ws/sessions/${session.agentSessionId}/video`);
219
- remote.on("message", (data, isBinary) => {
220
- if (client.readyState === WebSocket.OPEN) {
221
- client.send(data, { binary: isBinary });
262
+ session.clients.add(client);
263
+ const waitTimer = setTimeout(() => {
264
+ if (!session.stream || session.stream.readyState !== WebSocket.OPEN) {
265
+ client.close(1011, "agent stream unavailable");
222
266
  }
223
- });
224
- remote.on("error", () => {
225
- client.close(1011, "remote stream error");
226
- });
227
- remote.on("close", () => {
228
- client.close(1001, "remote stream closed");
229
- });
267
+ }, 8000);
230
268
  client.on("close", () => {
231
- remote.close();
269
+ clearTimeout(waitTimer);
270
+ session.clients.delete(client);
232
271
  });
233
272
  }
273
+ async function captureRemoteScreenshot(deviceId) {
274
+ const target = findDevice(deviceId);
275
+ const screenshot = await sendAgentRequest(target.agent, "screenshot", {
276
+ serial: target.remoteSerial
277
+ });
278
+ if (!screenshot.data) {
279
+ throw new Error("Agent returned an empty screenshot");
280
+ }
281
+ return {
282
+ contentType: screenshot.contentType ?? "image/png",
283
+ buffer: Buffer.from(screenshot.data, "base64")
284
+ };
285
+ }
286
+ async function deleteHubSessionsForDevice(deviceId) {
287
+ const staleSessions = [...sessions.values()].filter((session) => session.deviceId === deviceId);
288
+ await Promise.all(staleSessions.map(async (session) => {
289
+ sessions.delete(session.id);
290
+ closeHubSession(session);
291
+ const agent = agents.get(session.agentId);
292
+ if (agent) {
293
+ await sendAgentRequest(agent, "delete-session", {
294
+ body: { sessionId: session.agentSessionId }
295
+ }).catch(() => undefined);
296
+ }
297
+ }));
298
+ }
299
+ function closeHubSession(session) {
300
+ if (session.stream && session.stream.readyState === WebSocket.OPEN) {
301
+ session.stream.close(1001, "session ended");
302
+ }
303
+ for (const client of session.clients) {
304
+ client.close(1001, "session ended");
305
+ }
306
+ }
234
307
  function listHubDevices() {
235
308
  pruneAgents();
236
309
  return [...agents.values()].flatMap((agent) => agent.devices.map((device) => ({
@@ -240,6 +313,7 @@ function listHubDevices() {
240
313
  agentId: agent.agentId,
241
314
  agentName: agent.agentName,
242
315
  agentUrl: agent.url,
316
+ controlOnline: agent.control?.readyState === WebSocket.OPEN,
243
317
  publication: device.publication
244
318
  ? {
245
319
  ...device.publication,
@@ -252,7 +326,7 @@ function listHubSessions() {
252
326
  return [...sessions.values()].map((session) => ({
253
327
  id: session.id,
254
328
  serial: session.deviceId,
255
- viewerCount: 0,
329
+ viewerCount: session.clients.size,
256
330
  startedAt: session.startedAt,
257
331
  stream: {
258
332
  codec: "h264",
@@ -277,10 +351,40 @@ function findDevice(deviceId) {
277
351
  remoteSerial: parsed.serial
278
352
  };
279
353
  }
354
+ function sendAgentRequest(agent, command, payload = {}) {
355
+ if (!agent.control || agent.control.readyState !== WebSocket.OPEN) {
356
+ throw new Error("Agent control channel is offline");
357
+ }
358
+ const requestId = randomUUID();
359
+ const message = {
360
+ type: "request",
361
+ requestId,
362
+ command,
363
+ ...payload
364
+ };
365
+ return new Promise((resolve, reject) => {
366
+ const timer = setTimeout(() => {
367
+ pendingAgentRequests.delete(requestId);
368
+ reject(new Error(`Agent request timed out: ${command}`));
369
+ }, AGENT_REQUEST_TIMEOUT_MS);
370
+ pendingAgentRequests.set(requestId, {
371
+ resolve: (body) => resolve(body),
372
+ reject,
373
+ timer
374
+ });
375
+ agent.control?.send(JSON.stringify(message), (error) => {
376
+ if (!error)
377
+ return;
378
+ pendingAgentRequests.delete(requestId);
379
+ clearTimeout(timer);
380
+ reject(error);
381
+ });
382
+ });
383
+ }
280
384
  function pruneAgents() {
281
385
  const now = Date.now();
282
386
  for (const [agentId, agent] of agents.entries()) {
283
- if (now - agent.lastSeen > AGENT_TTL_MS) {
387
+ if (now - agent.lastSeen > AGENT_TTL_MS && agent.control?.readyState !== WebSocket.OPEN) {
284
388
  agents.delete(agentId);
285
389
  }
286
390
  }
@@ -2,8 +2,8 @@ import express from "express";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { WebSocketServer } from "ws";
5
- import { installAgentRoutes, startAgentHeartbeat } from "./agent.js";
6
- import { attachHubVideoClient, installHubRoutes } from "./hub.js";
5
+ import { installAgentRoutes, startAgentControlChannel, startAgentHeartbeat } from "./agent.js";
6
+ import { attachAgentControlClient, attachAgentVideoStream, attachHubVideoClient, installHubRoutes } from "./hub.js";
7
7
  import { getLanAddress } from "./network.js";
8
8
  import { attachPresenceClient } from "./presence.js";
9
9
  import { installScreenshotRoutes } from "./screenshots.js";
@@ -28,6 +28,7 @@ else {
28
28
  installAgentRoutes(app);
29
29
  if (role === "agent") {
30
30
  startAgentHeartbeat({ hubUrl, agentId, agentName, publicUrl, port });
31
+ startAgentControlChannel({ hubUrl, agentId, agentName, publicUrl, port });
31
32
  }
32
33
  }
33
34
  const __filename = fileURLToPath(import.meta.url);
@@ -49,7 +50,9 @@ server.on("upgrade", (request, socket, head) => {
49
50
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
50
51
  const videoMatch = url.pathname.match(/^\/ws\/sessions\/([^/]+)\/video$/);
51
52
  const presenceMatch = url.pathname.match(/^\/ws\/presence\/([^/]+)$/);
52
- if (!videoMatch && !presenceMatch) {
53
+ const agentControlMatch = url.pathname.match(/^\/ws\/agents\/([^/]+)\/control$/);
54
+ const agentVideoMatch = url.pathname.match(/^\/ws\/agents\/([^/]+)\/sessions\/([^/]+)\/video$/);
55
+ if (!videoMatch && !presenceMatch && !agentControlMatch && !agentVideoMatch) {
53
56
  socket.destroy();
54
57
  return;
55
58
  }
@@ -57,11 +60,20 @@ server.on("upgrade", (request, socket, head) => {
57
60
  if (presenceMatch) {
58
61
  attachPresenceClient(decodeURIComponent(presenceMatch[1]), ws);
59
62
  }
60
- else if (role === "hub") {
63
+ else if (role === "hub" && agentControlMatch) {
64
+ attachAgentControlClient(decodeURIComponent(agentControlMatch[1]), ws);
65
+ }
66
+ else if (role === "hub" && agentVideoMatch) {
67
+ attachAgentVideoStream(decodeURIComponent(agentVideoMatch[1]), decodeURIComponent(agentVideoMatch[2]), url.searchParams.get("hubSessionId") ?? "", ws);
68
+ }
69
+ else if (role === "hub" && videoMatch) {
61
70
  attachHubVideoClient(videoMatch[1], ws);
62
71
  }
63
- else {
72
+ else if (videoMatch) {
64
73
  attachClient(videoMatch[1], ws);
65
74
  }
75
+ else {
76
+ ws.close(1008, "unsupported websocket route");
77
+ }
66
78
  });
67
79
  });