@nickname4th/pura-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/client/assets/index-0nO8CWi5.css +1 -0
- package/dist/client/assets/index-BI4BBLE6.js +12 -0
- package/dist/client/index.html +13 -0
- package/package.json +69 -0
- package/server/dist/adb.js +183 -0
- package/server/dist/agent.js +179 -0
- package/server/dist/cli.js +286 -0
- package/server/dist/config.js +17 -0
- package/server/dist/device-id.js +13 -0
- package/server/dist/hub.js +287 -0
- package/server/dist/index.js +67 -0
- package/server/dist/network.js +20 -0
- package/server/dist/presence.js +92 -0
- package/server/dist/registry.js +61 -0
- package/server/dist/screenshots.js +59 -0
- package/server/dist/sessions.js +229 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>LAN Android Mirror</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BI4BBLE6.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-0nO8CWi5.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nickname4th/pura-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LAN Android device mirroring hub and developer CLI for distributed teams.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://github.com/LiuTianjie/pura#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/LiuTianjie/pura/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/LiuTianjie/pura.git"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"android",
|
|
17
|
+
"adb",
|
|
18
|
+
"screen-mirroring",
|
|
19
|
+
"lan",
|
|
20
|
+
"device-lab",
|
|
21
|
+
"cli",
|
|
22
|
+
"webrtc",
|
|
23
|
+
"h264"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist/client",
|
|
30
|
+
"server/dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"bin": {
|
|
35
|
+
"pura-cli": "server/dist/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"dev": "concurrently -k -n server,client -c blue,green \"tsx watch server/src/index.ts\" \"vite --host 0.0.0.0\"",
|
|
39
|
+
"dev:server": "tsx watch server/src/index.ts",
|
|
40
|
+
"dev:client": "vite --host 0.0.0.0",
|
|
41
|
+
"dev:hub": "ROLE=hub tsx watch server/src/index.ts",
|
|
42
|
+
"dev:agent": "ROLE=agent HUB_URL=http://127.0.0.1:8787 PORT=8788 DATA_DIR=data-agent tsx watch server/src/index.ts",
|
|
43
|
+
"build": "vite build && tsc -p server/tsconfig.json",
|
|
44
|
+
"start": "node server/dist/index.js",
|
|
45
|
+
"hub": "ROLE=hub node server/dist/index.js",
|
|
46
|
+
"check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
|
|
47
|
+
"prepack": "npm run build"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"express": "^5.2.1",
|
|
51
|
+
"jmuxer": "^2.1.0",
|
|
52
|
+
"lucide-react": "^0.562.0",
|
|
53
|
+
"react": "^19.2.1",
|
|
54
|
+
"react-dom": "^19.2.1",
|
|
55
|
+
"ws": "^8.18.3"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
59
|
+
"@types/express": "^5.0.6",
|
|
60
|
+
"@types/node": "^25.0.3",
|
|
61
|
+
"@types/react": "^19.2.7",
|
|
62
|
+
"@types/react-dom": "^19.2.3",
|
|
63
|
+
"@types/ws": "^8.18.1",
|
|
64
|
+
"concurrently": "^10.0.3",
|
|
65
|
+
"tsx": "^4.21.0",
|
|
66
|
+
"typescript": "^5.9.3",
|
|
67
|
+
"vite": "^7.2.7"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const ADB = process.env.ADB_PATH ?? "adb";
|
|
5
|
+
const INCLUDE_TCP_DEVICES = process.env.INCLUDE_TCP_DEVICES === "true";
|
|
6
|
+
export function adbCommand(args) {
|
|
7
|
+
return {
|
|
8
|
+
command: ADB,
|
|
9
|
+
args
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export async function listDevices() {
|
|
13
|
+
const { stdout } = await execFileAsync(ADB, ["devices", "-l"], {
|
|
14
|
+
timeout: 5000,
|
|
15
|
+
maxBuffer: 1024 * 1024
|
|
16
|
+
});
|
|
17
|
+
const baseDevices = stdout
|
|
18
|
+
.split("\n")
|
|
19
|
+
.slice(1)
|
|
20
|
+
.map((line) => line.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map(parseDeviceLine)
|
|
23
|
+
.filter((device) => Boolean(device))
|
|
24
|
+
.filter((device) => INCLUDE_TCP_DEVICES || device.transport === "usb");
|
|
25
|
+
return Promise.all(baseDevices.map(enrichDevice));
|
|
26
|
+
}
|
|
27
|
+
export async function tapDevice(serial, xRatio, yRatio) {
|
|
28
|
+
const size = await getDisplaySize(serial);
|
|
29
|
+
const x = clamp(Math.round(xRatio * size.width), 0, size.width - 1);
|
|
30
|
+
const y = clamp(Math.round(yRatio * size.height), 0, size.height - 1);
|
|
31
|
+
await execFileAsync(ADB, ["-s", serial, "shell", "input", "tap", String(x), String(y)], {
|
|
32
|
+
timeout: 3000,
|
|
33
|
+
maxBuffer: 64 * 1024
|
|
34
|
+
});
|
|
35
|
+
return { x, y, width: size.width, height: size.height };
|
|
36
|
+
}
|
|
37
|
+
export async function longPressDevice(serial, xRatio, yRatio, durationMs = 650) {
|
|
38
|
+
const size = await getDisplaySize(serial);
|
|
39
|
+
const x = clamp(Math.round(xRatio * size.width), 0, size.width - 1);
|
|
40
|
+
const y = clamp(Math.round(yRatio * size.height), 0, size.height - 1);
|
|
41
|
+
const duration = clamp(Math.round(durationMs), 350, 2500);
|
|
42
|
+
await execFileAsync(ADB, ["-s", serial, "shell", "input", "swipe", String(x), String(y), String(x), String(y), String(duration)], {
|
|
43
|
+
timeout: 4000,
|
|
44
|
+
maxBuffer: 64 * 1024
|
|
45
|
+
});
|
|
46
|
+
return { x, y, width: size.width, height: size.height, duration };
|
|
47
|
+
}
|
|
48
|
+
export async function swipeDevice(serial, input) {
|
|
49
|
+
const size = await getDisplaySize(serial);
|
|
50
|
+
const x1 = clamp(Math.round(input.xStartRatio * size.width), 0, size.width - 1);
|
|
51
|
+
const y1 = clamp(Math.round(input.yStartRatio * size.height), 0, size.height - 1);
|
|
52
|
+
const x2 = clamp(Math.round(input.xEndRatio * size.width), 0, size.width - 1);
|
|
53
|
+
const y2 = clamp(Math.round(input.yEndRatio * size.height), 0, size.height - 1);
|
|
54
|
+
const duration = clamp(Math.round(input.durationMs ?? 320), 80, 2500);
|
|
55
|
+
await execFileAsync(ADB, ["-s", serial, "shell", "input", "swipe", String(x1), String(y1), String(x2), String(y2), String(duration)], {
|
|
56
|
+
timeout: 5000,
|
|
57
|
+
maxBuffer: 64 * 1024
|
|
58
|
+
});
|
|
59
|
+
return { from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, width: size.width, height: size.height, duration };
|
|
60
|
+
}
|
|
61
|
+
export async function captureScreenshot(serial) {
|
|
62
|
+
const { stdout } = await execFileAsync(ADB, ["-s", serial, "exec-out", "screencap", "-p"], {
|
|
63
|
+
encoding: "buffer",
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
maxBuffer: 20 * 1024 * 1024
|
|
66
|
+
});
|
|
67
|
+
return stdout;
|
|
68
|
+
}
|
|
69
|
+
export async function controlDevice(serial, action, value) {
|
|
70
|
+
if (action === "text") {
|
|
71
|
+
const text = sanitizeInputText(value ?? "");
|
|
72
|
+
if (!text)
|
|
73
|
+
throw new Error("Text input is empty");
|
|
74
|
+
await execFileAsync(ADB, ["-s", serial, "shell", "input", "text", text], {
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
maxBuffer: 64 * 1024
|
|
77
|
+
});
|
|
78
|
+
return { action, text };
|
|
79
|
+
}
|
|
80
|
+
const keyCode = keyEvents[action];
|
|
81
|
+
if (keyCode) {
|
|
82
|
+
await execFileAsync(ADB, ["-s", serial, "shell", "input", "keyevent", keyCode], {
|
|
83
|
+
timeout: 3000,
|
|
84
|
+
maxBuffer: 64 * 1024
|
|
85
|
+
});
|
|
86
|
+
return { action, keyCode };
|
|
87
|
+
}
|
|
88
|
+
if (action.startsWith("swipe_")) {
|
|
89
|
+
const size = await getDisplaySize(serial);
|
|
90
|
+
const [x1, y1, x2, y2] = swipePoints(action, size);
|
|
91
|
+
await execFileAsync(ADB, ["-s", serial, "shell", "input", "swipe", String(x1), String(y1), String(x2), String(y2), "280"], {
|
|
92
|
+
timeout: 4000,
|
|
93
|
+
maxBuffer: 64 * 1024
|
|
94
|
+
});
|
|
95
|
+
return { action, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, width: size.width, height: size.height };
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Unsupported control action: ${action}`);
|
|
98
|
+
}
|
|
99
|
+
function parseDeviceLine(line) {
|
|
100
|
+
const [serial, state] = line.split(/\s+/, 2);
|
|
101
|
+
if (!serial || !state)
|
|
102
|
+
return null;
|
|
103
|
+
if (!["device", "unauthorized", "offline"].includes(state))
|
|
104
|
+
return null;
|
|
105
|
+
return {
|
|
106
|
+
serial,
|
|
107
|
+
state: state,
|
|
108
|
+
transport: serial.includes(":") ? "tcp" : "usb"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function enrichDevice(device) {
|
|
112
|
+
if (device.state !== "device")
|
|
113
|
+
return device;
|
|
114
|
+
const [manufacturer, model, androidVersion, size] = await Promise.all([
|
|
115
|
+
getProp(device.serial, "ro.product.manufacturer"),
|
|
116
|
+
getProp(device.serial, "ro.product.model"),
|
|
117
|
+
getProp(device.serial, "ro.build.version.release"),
|
|
118
|
+
getDisplaySize(device.serial).catch(() => undefined)
|
|
119
|
+
]);
|
|
120
|
+
return {
|
|
121
|
+
...device,
|
|
122
|
+
manufacturer: manufacturer || undefined,
|
|
123
|
+
model: model || undefined,
|
|
124
|
+
androidVersion: androidVersion || undefined,
|
|
125
|
+
size
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function getProp(serial, prop) {
|
|
129
|
+
const { stdout } = await execFileAsync(ADB, ["-s", serial, "shell", "getprop", prop], {
|
|
130
|
+
timeout: 2500,
|
|
131
|
+
maxBuffer: 64 * 1024
|
|
132
|
+
});
|
|
133
|
+
return stdout.trim();
|
|
134
|
+
}
|
|
135
|
+
export async function getDisplaySize(serial) {
|
|
136
|
+
const { stdout } = await execFileAsync(ADB, ["-s", serial, "shell", "wm", "size"], {
|
|
137
|
+
timeout: 2500,
|
|
138
|
+
maxBuffer: 64 * 1024
|
|
139
|
+
});
|
|
140
|
+
const overrideMatch = stdout.match(/Override size:\s*(\d+)x(\d+)/i);
|
|
141
|
+
const physicalMatch = stdout.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
142
|
+
const match = overrideMatch ?? physicalMatch;
|
|
143
|
+
if (!match) {
|
|
144
|
+
throw new Error(`Unable to parse display size: ${stdout.trim() || "empty output"}`);
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
width: Number(match[1]),
|
|
148
|
+
height: Number(match[2])
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function clamp(value, min, max) {
|
|
152
|
+
return Math.max(min, Math.min(max, value));
|
|
153
|
+
}
|
|
154
|
+
const keyEvents = {
|
|
155
|
+
back: "KEYCODE_BACK",
|
|
156
|
+
home: "KEYCODE_HOME",
|
|
157
|
+
recents: "KEYCODE_APP_SWITCH",
|
|
158
|
+
menu: "KEYCODE_MENU",
|
|
159
|
+
power: "KEYCODE_POWER",
|
|
160
|
+
volume_up: "KEYCODE_VOLUME_UP",
|
|
161
|
+
volume_down: "KEYCODE_VOLUME_DOWN",
|
|
162
|
+
mute: "KEYCODE_VOLUME_MUTE",
|
|
163
|
+
enter: "KEYCODE_ENTER",
|
|
164
|
+
delete: "KEYCODE_DEL"
|
|
165
|
+
};
|
|
166
|
+
function swipePoints(action, size) {
|
|
167
|
+
const left = Math.round(size.width * 0.22);
|
|
168
|
+
const right = Math.round(size.width * 0.78);
|
|
169
|
+
const centerX = Math.round(size.width * 0.5);
|
|
170
|
+
const top = Math.round(size.height * 0.24);
|
|
171
|
+
const bottom = Math.round(size.height * 0.78);
|
|
172
|
+
const centerY = Math.round(size.height * 0.5);
|
|
173
|
+
if (action === "swipe_up")
|
|
174
|
+
return [centerX, bottom, centerX, top];
|
|
175
|
+
if (action === "swipe_down")
|
|
176
|
+
return [centerX, top, centerX, bottom];
|
|
177
|
+
if (action === "swipe_left")
|
|
178
|
+
return [right, centerY, left, centerY];
|
|
179
|
+
return [left, centerY, right, centerY];
|
|
180
|
+
}
|
|
181
|
+
function sanitizeInputText(value) {
|
|
182
|
+
return value.trim().replaceAll("%", "%25").replace(/\s/g, "%s").slice(0, 500);
|
|
183
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { captureScreenshot, controlDevice, listDevices, longPressDevice, swipeDevice, tapDevice } from "./adb.js";
|
|
2
|
+
import { getLanAddress, normalizeHttpUrl } from "./network.js";
|
|
3
|
+
import { getPublications, publishDevice, unpublishDevice } from "./registry.js";
|
|
4
|
+
import { listDeviceScreenshots, saveScreenshot } from "./screenshots.js";
|
|
5
|
+
import { deleteSession, getOrCreateSession, listSessions } from "./sessions.js";
|
|
6
|
+
export function installAgentRoutes(app) {
|
|
7
|
+
app.get("/api/devices", async (_req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
res.json({ devices: await listAgentDevices(), sessions: listSessions() });
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
res.status(500).json({
|
|
13
|
+
error: error instanceof Error ? error.message : "Failed to list devices"
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
app.put("/api/devices/:serial/publication", (req, res) => {
|
|
18
|
+
res.json({
|
|
19
|
+
publication: publishDevice(req.params.serial, {
|
|
20
|
+
label: req.body?.label,
|
|
21
|
+
owner: req.body?.owner,
|
|
22
|
+
note: req.body?.note
|
|
23
|
+
})
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
app.delete("/api/devices/:serial/publication", (req, res) => {
|
|
27
|
+
res.json({ publication: unpublishDevice(req.params.serial) });
|
|
28
|
+
});
|
|
29
|
+
app.post("/api/devices/:serial/session", (req, res) => {
|
|
30
|
+
res.json({ session: getOrCreateSession(req.params.serial, { restart: req.body?.restart === true }) });
|
|
31
|
+
});
|
|
32
|
+
app.get("/api/devices/:serial/screenshot", async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const image = await captureScreenshot(req.params.serial);
|
|
35
|
+
res.setHeader("Content-Type", "image/png");
|
|
36
|
+
res.setHeader("Cache-Control", "no-store");
|
|
37
|
+
res.end(image);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
res.status(500).json({
|
|
41
|
+
error: error instanceof Error ? error.message : "Failed to capture device screenshot"
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
app.post("/api/devices/:serial/screenshots", async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const image = await captureScreenshot(req.params.serial);
|
|
48
|
+
res.json({ screenshot: await saveScreenshot(image, req.params.serial) });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
res.status(500).json({
|
|
52
|
+
error: error instanceof Error ? error.message : "Failed to save device screenshot"
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
app.get("/api/devices/:serial/screenshots", (req, res) => {
|
|
57
|
+
res.json({ screenshots: listDeviceScreenshots(req.params.serial) });
|
|
58
|
+
});
|
|
59
|
+
app.post("/api/devices/:serial/tap", async (req, res) => {
|
|
60
|
+
const xRatio = Number(req.body?.xRatio);
|
|
61
|
+
const yRatio = Number(req.body?.yRatio);
|
|
62
|
+
if (!Number.isFinite(xRatio) || !Number.isFinite(yRatio) || xRatio < 0 || xRatio > 1 || yRatio < 0 || yRatio > 1) {
|
|
63
|
+
res.status(400).json({ error: "xRatio and yRatio must be numbers between 0 and 1" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
res.json({ tap: await tapDevice(req.params.serial, xRatio, yRatio) });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
res.status(500).json({
|
|
71
|
+
error: error instanceof Error ? error.message : "Failed to tap device"
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
app.post("/api/devices/:serial/long-press", async (req, res) => {
|
|
76
|
+
const xRatio = Number(req.body?.xRatio);
|
|
77
|
+
const yRatio = Number(req.body?.yRatio);
|
|
78
|
+
const durationMs = Number(req.body?.durationMs ?? 650);
|
|
79
|
+
if (!Number.isFinite(xRatio) || !Number.isFinite(yRatio) || xRatio < 0 || xRatio > 1 || yRatio < 0 || yRatio > 1) {
|
|
80
|
+
res.status(400).json({ error: "xRatio and yRatio must be numbers between 0 and 1" });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
res.json({ longPress: await longPressDevice(req.params.serial, xRatio, yRatio, durationMs) });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
res.status(500).json({
|
|
88
|
+
error: error instanceof Error ? error.message : "Failed to long press device"
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
app.post("/api/devices/:serial/swipe", async (req, res) => {
|
|
93
|
+
const xStartRatio = Number(req.body?.xStartRatio);
|
|
94
|
+
const yStartRatio = Number(req.body?.yStartRatio);
|
|
95
|
+
const xEndRatio = Number(req.body?.xEndRatio);
|
|
96
|
+
const yEndRatio = Number(req.body?.yEndRatio);
|
|
97
|
+
const durationMs = Number(req.body?.durationMs ?? 320);
|
|
98
|
+
if (![xStartRatio, yStartRatio, xEndRatio, yEndRatio].every((value) => Number.isFinite(value) && value >= 0 && value <= 1)) {
|
|
99
|
+
res.status(400).json({ error: "Swipe ratios must be numbers between 0 and 1" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
res.json({ swipe: await swipeDevice(req.params.serial, { xStartRatio, yStartRatio, xEndRatio, yEndRatio, durationMs }) });
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
res.status(500).json({
|
|
107
|
+
error: error instanceof Error ? error.message : "Failed to swipe device"
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
app.post("/api/devices/:serial/control", async (req, res) => {
|
|
112
|
+
const action = req.body?.action;
|
|
113
|
+
if (!action || !controlActions.includes(action)) {
|
|
114
|
+
res.status(400).json({ error: "A supported control action is required" });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
res.json({ control: await controlDevice(req.params.serial, action, req.body?.value) });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
res.status(500).json({
|
|
122
|
+
error: error instanceof Error ? error.message : "Failed to control device"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
app.delete("/api/sessions/:id", (req, res) => {
|
|
127
|
+
res.json({ deleted: deleteSession(req.params.id) });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const controlActions = [
|
|
131
|
+
"back",
|
|
132
|
+
"home",
|
|
133
|
+
"recents",
|
|
134
|
+
"menu",
|
|
135
|
+
"power",
|
|
136
|
+
"volume_up",
|
|
137
|
+
"volume_down",
|
|
138
|
+
"mute",
|
|
139
|
+
"enter",
|
|
140
|
+
"delete",
|
|
141
|
+
"swipe_up",
|
|
142
|
+
"swipe_down",
|
|
143
|
+
"swipe_left",
|
|
144
|
+
"swipe_right",
|
|
145
|
+
"text"
|
|
146
|
+
];
|
|
147
|
+
export function startAgentHeartbeat(options) {
|
|
148
|
+
if (!options.hubUrl)
|
|
149
|
+
return;
|
|
150
|
+
const hubUrl = normalizeHttpUrl(options.hubUrl);
|
|
151
|
+
const publicUrl = options.publicUrl ?? `http://${getLanAddress()}:${options.port}`;
|
|
152
|
+
const tick = async () => {
|
|
153
|
+
try {
|
|
154
|
+
const devices = await listAgentDevices();
|
|
155
|
+
await fetch(`${hubUrl}/api/agents/heartbeat`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
agentId: options.agentId,
|
|
160
|
+
agentName: options.agentName,
|
|
161
|
+
url: publicUrl,
|
|
162
|
+
devices
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.error(`Hub heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
void tick();
|
|
171
|
+
setInterval(tick, Number(process.env.HEARTBEAT_MS ?? 3000));
|
|
172
|
+
}
|
|
173
|
+
async function listAgentDevices() {
|
|
174
|
+
const publications = getPublications();
|
|
175
|
+
return (await listDevices()).map((device) => ({
|
|
176
|
+
...device,
|
|
177
|
+
publication: publications[device.serial]
|
|
178
|
+
}));
|
|
179
|
+
}
|