@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 +12 -7
- package/package.json +1 -1
- package/server/dist/adb.js +16 -1
- package/server/dist/agent.js +180 -1
- package/server/dist/cli.js +24 -0
- package/server/dist/hub.js +195 -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/adb.js
CHANGED
|
@@ -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 =
|
|
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",
|
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/cli.js
CHANGED
|
@@ -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",
|
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,24 +68,19 @@ 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);
|
|
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
|
|
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");
|
|
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
|
|
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) });
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
client.
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
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
|
});
|