@nickname4th/pura-cli 0.1.2 → 0.1.4
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 +12 -7
- package/package.json +1 -1
- package/server/dist/agent.js +180 -1
- package/server/dist/hub.js +221 -91
- package/server/dist/index.js +17 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
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
|
|
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
|
-
|
|
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.
|
|
216
|
-
4.
|
|
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
|
|
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
package/server/dist/agent.js
CHANGED
|
@@ -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) => ({
|
package/server/dist/hub.js
CHANGED
|
@@ -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 || !
|
|
13
|
-
res.status(400).json({ error: "agentId
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
46
|
-
|
|
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,26 +68,22 @@ export function installHubRoutes(app) {
|
|
|
58
68
|
if (restart) {
|
|
59
69
|
await deleteHubSessionsForDevice(req.params.deviceId);
|
|
60
70
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
body:
|
|
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:
|
|
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);
|
|
86
|
+
scheduleHubSessionStopIfIdle(session);
|
|
81
87
|
res.json({
|
|
82
88
|
session: {
|
|
83
89
|
...body.session,
|
|
@@ -92,16 +98,10 @@ export function installHubRoutes(app) {
|
|
|
92
98
|
});
|
|
93
99
|
app.get("/api/devices/:deviceId/screenshot", async (req, res) => {
|
|
94
100
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
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");
|
|
101
|
+
const image = await captureRemoteScreenshot(req.params.deviceId);
|
|
102
|
+
res.setHeader("Content-Type", image.contentType);
|
|
103
103
|
res.setHeader("Cache-Control", "no-store");
|
|
104
|
-
res.end(image);
|
|
104
|
+
res.end(image.buffer);
|
|
105
105
|
}
|
|
106
106
|
catch (error) {
|
|
107
107
|
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to capture remote device screenshot" });
|
|
@@ -109,14 +109,8 @@ export function installHubRoutes(app) {
|
|
|
109
109
|
});
|
|
110
110
|
app.post("/api/devices/:deviceId/screenshots", async (req, res) => {
|
|
111
111
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
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) });
|
|
112
|
+
const image = await captureRemoteScreenshot(req.params.deviceId);
|
|
113
|
+
res.json({ screenshot: await saveScreenshot(image.buffer, req.params.deviceId) });
|
|
120
114
|
}
|
|
121
115
|
catch (error) {
|
|
122
116
|
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to save remote device screenshot" });
|
|
@@ -134,12 +128,7 @@ export function installHubRoutes(app) {
|
|
|
134
128
|
app.post("/api/devices/:deviceId/tap", async (req, res) => {
|
|
135
129
|
try {
|
|
136
130
|
const target = findDevice(req.params.deviceId);
|
|
137
|
-
|
|
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());
|
|
131
|
+
res.json(await sendAgentRequest(target.agent, "tap", { serial: target.remoteSerial, body: req.body ?? {} }));
|
|
143
132
|
}
|
|
144
133
|
catch (error) {
|
|
145
134
|
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to tap remote device" });
|
|
@@ -148,12 +137,7 @@ export function installHubRoutes(app) {
|
|
|
148
137
|
app.post("/api/devices/:deviceId/long-press", async (req, res) => {
|
|
149
138
|
try {
|
|
150
139
|
const target = findDevice(req.params.deviceId);
|
|
151
|
-
|
|
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());
|
|
140
|
+
res.json(await sendAgentRequest(target.agent, "long-press", { serial: target.remoteSerial, body: req.body ?? {} }));
|
|
157
141
|
}
|
|
158
142
|
catch (error) {
|
|
159
143
|
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to long press remote device" });
|
|
@@ -162,12 +146,7 @@ export function installHubRoutes(app) {
|
|
|
162
146
|
app.post("/api/devices/:deviceId/swipe", async (req, res) => {
|
|
163
147
|
try {
|
|
164
148
|
const target = findDevice(req.params.deviceId);
|
|
165
|
-
|
|
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());
|
|
149
|
+
res.json(await sendAgentRequest(target.agent, "swipe", { serial: target.remoteSerial, body: req.body ?? {} }));
|
|
171
150
|
}
|
|
172
151
|
catch (error) {
|
|
173
152
|
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to swipe remote device" });
|
|
@@ -176,12 +155,7 @@ export function installHubRoutes(app) {
|
|
|
176
155
|
app.post("/api/devices/:deviceId/control", async (req, res) => {
|
|
177
156
|
try {
|
|
178
157
|
const target = findDevice(req.params.deviceId);
|
|
179
|
-
|
|
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());
|
|
158
|
+
res.json(await sendAgentRequest(target.agent, "control", { serial: target.remoteSerial, body: req.body ?? {} }));
|
|
185
159
|
}
|
|
186
160
|
catch (error) {
|
|
187
161
|
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to control remote device" });
|
|
@@ -194,20 +168,91 @@ export function installHubRoutes(app) {
|
|
|
194
168
|
res.json({ deleted: false });
|
|
195
169
|
return;
|
|
196
170
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
171
|
+
closeHubSession(session);
|
|
172
|
+
const agent = agents.get(session.agentId);
|
|
173
|
+
if (agent) {
|
|
174
|
+
await sendAgentRequest(agent, "delete-session", {
|
|
175
|
+
body: { sessionId: session.agentSessionId }
|
|
176
|
+
}).catch(() => undefined);
|
|
177
|
+
}
|
|
200
178
|
res.json({ deleted: true });
|
|
201
179
|
});
|
|
202
180
|
}
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
181
|
+
export function attachAgentControlClient(agentId, socket) {
|
|
182
|
+
const existing = agents.get(agentId);
|
|
183
|
+
if (existing?.control && existing.control.readyState === WebSocket.OPEN) {
|
|
184
|
+
existing.control.close(1001, "replaced by a new control channel");
|
|
185
|
+
}
|
|
186
|
+
agents.set(agentId, {
|
|
187
|
+
agentId,
|
|
188
|
+
agentName: existing?.agentName,
|
|
189
|
+
url: existing?.url ?? "",
|
|
190
|
+
devices: existing?.devices ?? [],
|
|
191
|
+
lastSeen: Date.now(),
|
|
192
|
+
control: socket
|
|
193
|
+
});
|
|
194
|
+
socket.on("message", (data) => {
|
|
195
|
+
try {
|
|
196
|
+
const message = JSON.parse(data.toString("utf8"));
|
|
197
|
+
if (message.type === "hello") {
|
|
198
|
+
const agent = agents.get(agentId);
|
|
199
|
+
if (agent) {
|
|
200
|
+
agent.agentName = message.agentName ?? agent.agentName;
|
|
201
|
+
agent.lastSeen = Date.now();
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (message.type !== "response")
|
|
206
|
+
return;
|
|
207
|
+
const pending = pendingAgentRequests.get(message.requestId);
|
|
208
|
+
if (!pending)
|
|
209
|
+
return;
|
|
210
|
+
pendingAgentRequests.delete(message.requestId);
|
|
211
|
+
clearTimeout(pending.timer);
|
|
212
|
+
if (message.ok) {
|
|
213
|
+
pending.resolve(message.body);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
pending.reject(new Error(message.error || "Agent request failed"));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
socket.close(1003, "invalid control message");
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
socket.on("close", () => {
|
|
224
|
+
const agent = agents.get(agentId);
|
|
225
|
+
if (agent?.control === socket) {
|
|
226
|
+
agent.control = undefined;
|
|
227
|
+
agent.lastSeen = Date.now();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
export function attachAgentVideoStream(agentId, agentSessionId, hubSessionId, stream) {
|
|
232
|
+
const session = sessions.get(hubSessionId);
|
|
233
|
+
if (!session || session.agentId !== agentId || session.agentSessionId !== agentSessionId) {
|
|
234
|
+
stream.close(1008, "session not found");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (session.stream && session.stream.readyState === WebSocket.OPEN) {
|
|
238
|
+
session.stream.close(1001, "replaced by a new stream");
|
|
239
|
+
}
|
|
240
|
+
session.stream = stream;
|
|
241
|
+
stream.on("message", (data, isBinary) => {
|
|
242
|
+
for (const client of session.clients) {
|
|
243
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
244
|
+
client.send(data, { binary: isBinary });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
stream.on("close", () => {
|
|
249
|
+
if (session.stream === stream) {
|
|
250
|
+
session.stream = undefined;
|
|
251
|
+
for (const client of session.clients) {
|
|
252
|
+
client.close(1001, "agent stream closed");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
211
256
|
}
|
|
212
257
|
export function attachHubVideoClient(sessionId, client) {
|
|
213
258
|
const session = sessions.get(sessionId);
|
|
@@ -215,22 +260,76 @@ export function attachHubVideoClient(sessionId, client) {
|
|
|
215
260
|
client.close(1008, "session not found");
|
|
216
261
|
return;
|
|
217
262
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
263
|
+
session.clients.add(client);
|
|
264
|
+
if (session.stopTimer) {
|
|
265
|
+
clearTimeout(session.stopTimer);
|
|
266
|
+
session.stopTimer = undefined;
|
|
267
|
+
}
|
|
268
|
+
const waitTimer = setTimeout(() => {
|
|
269
|
+
if (!session.stream || session.stream.readyState !== WebSocket.OPEN) {
|
|
270
|
+
client.close(1011, "agent stream unavailable");
|
|
222
271
|
}
|
|
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
|
-
});
|
|
272
|
+
}, 8000);
|
|
230
273
|
client.on("close", () => {
|
|
231
|
-
|
|
274
|
+
clearTimeout(waitTimer);
|
|
275
|
+
session.clients.delete(client);
|
|
276
|
+
scheduleHubSessionStopIfIdle(session);
|
|
232
277
|
});
|
|
233
278
|
}
|
|
279
|
+
async function captureRemoteScreenshot(deviceId) {
|
|
280
|
+
const target = findDevice(deviceId);
|
|
281
|
+
const screenshot = await sendAgentRequest(target.agent, "screenshot", {
|
|
282
|
+
serial: target.remoteSerial
|
|
283
|
+
});
|
|
284
|
+
if (!screenshot.data) {
|
|
285
|
+
throw new Error("Agent returned an empty screenshot");
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
contentType: screenshot.contentType ?? "image/png",
|
|
289
|
+
buffer: Buffer.from(screenshot.data, "base64")
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
async function deleteHubSessionsForDevice(deviceId) {
|
|
293
|
+
const staleSessions = [...sessions.values()].filter((session) => session.deviceId === deviceId);
|
|
294
|
+
await Promise.all(staleSessions.map(async (session) => {
|
|
295
|
+
sessions.delete(session.id);
|
|
296
|
+
closeHubSession(session);
|
|
297
|
+
const agent = agents.get(session.agentId);
|
|
298
|
+
if (agent) {
|
|
299
|
+
await sendAgentRequest(agent, "delete-session", {
|
|
300
|
+
body: { sessionId: session.agentSessionId }
|
|
301
|
+
}).catch(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
function closeHubSession(session) {
|
|
306
|
+
if (session.stopTimer) {
|
|
307
|
+
clearTimeout(session.stopTimer);
|
|
308
|
+
session.stopTimer = undefined;
|
|
309
|
+
}
|
|
310
|
+
if (session.stream && session.stream.readyState === WebSocket.OPEN) {
|
|
311
|
+
session.stream.close(1001, "session ended");
|
|
312
|
+
}
|
|
313
|
+
for (const client of session.clients) {
|
|
314
|
+
client.close(1001, "session ended");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function scheduleHubSessionStopIfIdle(session) {
|
|
318
|
+
if (session.clients.size > 0 || session.stopTimer)
|
|
319
|
+
return;
|
|
320
|
+
session.stopTimer = setTimeout(() => {
|
|
321
|
+
if (session.clients.size > 0)
|
|
322
|
+
return;
|
|
323
|
+
sessions.delete(session.id);
|
|
324
|
+
closeHubSession(session);
|
|
325
|
+
const agent = agents.get(session.agentId);
|
|
326
|
+
if (agent) {
|
|
327
|
+
void sendAgentRequest(agent, "delete-session", {
|
|
328
|
+
body: { sessionId: session.agentSessionId }
|
|
329
|
+
}).catch(() => undefined);
|
|
330
|
+
}
|
|
331
|
+
}, 5000);
|
|
332
|
+
}
|
|
234
333
|
function listHubDevices() {
|
|
235
334
|
pruneAgents();
|
|
236
335
|
return [...agents.values()].flatMap((agent) => agent.devices.map((device) => ({
|
|
@@ -240,6 +339,7 @@ function listHubDevices() {
|
|
|
240
339
|
agentId: agent.agentId,
|
|
241
340
|
agentName: agent.agentName,
|
|
242
341
|
agentUrl: agent.url,
|
|
342
|
+
controlOnline: agent.control?.readyState === WebSocket.OPEN,
|
|
243
343
|
publication: device.publication
|
|
244
344
|
? {
|
|
245
345
|
...device.publication,
|
|
@@ -252,7 +352,7 @@ function listHubSessions() {
|
|
|
252
352
|
return [...sessions.values()].map((session) => ({
|
|
253
353
|
id: session.id,
|
|
254
354
|
serial: session.deviceId,
|
|
255
|
-
viewerCount:
|
|
355
|
+
viewerCount: session.clients.size,
|
|
256
356
|
startedAt: session.startedAt,
|
|
257
357
|
stream: {
|
|
258
358
|
codec: "h264",
|
|
@@ -277,10 +377,40 @@ function findDevice(deviceId) {
|
|
|
277
377
|
remoteSerial: parsed.serial
|
|
278
378
|
};
|
|
279
379
|
}
|
|
380
|
+
function sendAgentRequest(agent, command, payload = {}) {
|
|
381
|
+
if (!agent.control || agent.control.readyState !== WebSocket.OPEN) {
|
|
382
|
+
throw new Error("Agent control channel is offline");
|
|
383
|
+
}
|
|
384
|
+
const requestId = randomUUID();
|
|
385
|
+
const message = {
|
|
386
|
+
type: "request",
|
|
387
|
+
requestId,
|
|
388
|
+
command,
|
|
389
|
+
...payload
|
|
390
|
+
};
|
|
391
|
+
return new Promise((resolve, reject) => {
|
|
392
|
+
const timer = setTimeout(() => {
|
|
393
|
+
pendingAgentRequests.delete(requestId);
|
|
394
|
+
reject(new Error(`Agent request timed out: ${command}`));
|
|
395
|
+
}, AGENT_REQUEST_TIMEOUT_MS);
|
|
396
|
+
pendingAgentRequests.set(requestId, {
|
|
397
|
+
resolve: (body) => resolve(body),
|
|
398
|
+
reject,
|
|
399
|
+
timer
|
|
400
|
+
});
|
|
401
|
+
agent.control?.send(JSON.stringify(message), (error) => {
|
|
402
|
+
if (!error)
|
|
403
|
+
return;
|
|
404
|
+
pendingAgentRequests.delete(requestId);
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
reject(error);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
280
410
|
function pruneAgents() {
|
|
281
411
|
const now = Date.now();
|
|
282
412
|
for (const [agentId, agent] of agents.entries()) {
|
|
283
|
-
if (now - agent.lastSeen > AGENT_TTL_MS) {
|
|
413
|
+
if (now - agent.lastSeen > AGENT_TTL_MS && agent.control?.readyState !== WebSocket.OPEN) {
|
|
284
414
|
agents.delete(agentId);
|
|
285
415
|
}
|
|
286
416
|
}
|
package/server/dist/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|