@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,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { listDevices } from "./adb.js";
|
|
8
|
+
import { readConfig, writeConfig } from "./config.js";
|
|
9
|
+
import { getLanAddress, normalizeHttpUrl } from "./network.js";
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const command = args[0];
|
|
12
|
+
const launchAgentLabel = "tech.itool.pura.agent";
|
|
13
|
+
const launchAgentPath = path.join(os.homedir(), "Library", "LaunchAgents", `${launchAgentLabel}.plist`);
|
|
14
|
+
if (!command || command === "--help" || command === "-h") {
|
|
15
|
+
printHelp();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
if (command === "hub") {
|
|
19
|
+
startServer({
|
|
20
|
+
ROLE: "hub",
|
|
21
|
+
PORT: readFlag("--port") ?? "8787",
|
|
22
|
+
HOST: readFlag("--host") ?? "0.0.0.0"
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
else if (command === "connect") {
|
|
26
|
+
await handleConnect();
|
|
27
|
+
}
|
|
28
|
+
else if (command === "auto-connect") {
|
|
29
|
+
await handleAutoConnect();
|
|
30
|
+
}
|
|
31
|
+
else if (command === "devices") {
|
|
32
|
+
console.table(await listDevices());
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.error(`Unknown command: ${command}`);
|
|
36
|
+
printHelp();
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
async function handleConnect() {
|
|
40
|
+
const target = args[1];
|
|
41
|
+
if (!target) {
|
|
42
|
+
console.error("Missing hub address or subcommand.");
|
|
43
|
+
printHelp();
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
if (target === "device") {
|
|
47
|
+
await publishLocalDevice();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const hubUrl = normalizeHttpUrl(target);
|
|
51
|
+
const config = readConfig();
|
|
52
|
+
const agentId = readFlag("--id") ?? config.agentId ?? randomUUID();
|
|
53
|
+
const agentName = readFlag("--name") ?? config.agentName ?? process.env.USER ?? "developer";
|
|
54
|
+
const port = readFlag("--port") ?? "8788";
|
|
55
|
+
const publicUrl = readFlag("--public-url") ?? `http://${getLanAddress()}:${port}`;
|
|
56
|
+
const host = readFlag("--host") ?? "0.0.0.0";
|
|
57
|
+
const dataDir = resolveAgentDataDir(readFlag("--data-dir") ?? config.dataDir);
|
|
58
|
+
writeConfig({ hubUrl, agentId, agentName, agentPort: port, publicUrl, host, dataDir });
|
|
59
|
+
console.log(`pura-cli saved hub: ${hubUrl}`);
|
|
60
|
+
console.log(`agent URL announced to hub: ${publicUrl}`);
|
|
61
|
+
if (hasFlag("--background") || hasFlag("--install")) {
|
|
62
|
+
installLaunchAgent();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.log(`pura-cli starting agent: ${agentName} (${agentId})`);
|
|
66
|
+
console.log("Tip: use `--background` to keep the Agent running after this terminal closes.");
|
|
67
|
+
startServer({
|
|
68
|
+
ROLE: "agent",
|
|
69
|
+
HUB_URL: hubUrl,
|
|
70
|
+
AGENT_ID: agentId,
|
|
71
|
+
AGENT_NAME: agentName,
|
|
72
|
+
PUBLIC_URL: publicUrl,
|
|
73
|
+
PORT: port,
|
|
74
|
+
HOST: host,
|
|
75
|
+
DATA_DIR: dataDir
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function handleAutoConnect() {
|
|
79
|
+
if (hasFlag("--install")) {
|
|
80
|
+
installLaunchAgent();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (hasFlag("--uninstall")) {
|
|
84
|
+
uninstallLaunchAgent();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (hasFlag("--status")) {
|
|
88
|
+
printLaunchAgentStatus();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const config = readConfig();
|
|
92
|
+
if (!config.hubUrl) {
|
|
93
|
+
console.error("No saved hub found. Run `pura-cli connect <hub-url> --name <name>` once first.");
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
startSavedAgent(config);
|
|
97
|
+
}
|
|
98
|
+
async function publishLocalDevice() {
|
|
99
|
+
const config = readConfig();
|
|
100
|
+
const agentPort = readFlag("--port") ?? config.agentPort ?? "8788";
|
|
101
|
+
const devices = await listDevices();
|
|
102
|
+
const serial = readFlag("--serial") ?? devices.find((device) => device.state === "device")?.serial;
|
|
103
|
+
if (!serial) {
|
|
104
|
+
console.error("No ready ADB device found. Run `adb devices -l` and authorize USB debugging first.");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const device = devices.find((item) => item.serial === serial);
|
|
108
|
+
const fallbackName = [device?.manufacturer, device?.model].filter(Boolean).join(" ") || serial;
|
|
109
|
+
const label = readFlag("--name") ?? fallbackName;
|
|
110
|
+
const owner = readFlag("--owner") ?? config.agentName ?? process.env.USER;
|
|
111
|
+
const note = readFlag("--note") ?? "";
|
|
112
|
+
const response = await fetch(`http://127.0.0.1:${agentPort}/api/devices/${encodeURIComponent(serial)}/publication`, {
|
|
113
|
+
method: "PUT",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ label, owner, note })
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
console.error(`Failed to publish device: ${await response.text()}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
console.log(`Published ${label} (${serial}) to ${config.hubUrl ?? "configured hub"}`);
|
|
122
|
+
}
|
|
123
|
+
function startSavedAgent(config) {
|
|
124
|
+
const port = readFlag("--port") ?? config.agentPort ?? "8788";
|
|
125
|
+
const publicUrl = readFlag("--public-url") ?? config.publicUrl ?? `http://${getLanAddress()}:${port}`;
|
|
126
|
+
const agentId = readFlag("--id") ?? config.agentId ?? randomUUID();
|
|
127
|
+
const agentName = readFlag("--name") ?? config.agentName ?? process.env.USER ?? "developer";
|
|
128
|
+
const host = readFlag("--host") ?? config.host ?? "0.0.0.0";
|
|
129
|
+
const dataDir = resolveAgentDataDir(readFlag("--data-dir") ?? config.dataDir);
|
|
130
|
+
writeConfig({
|
|
131
|
+
hubUrl: config.hubUrl,
|
|
132
|
+
agentId,
|
|
133
|
+
agentName,
|
|
134
|
+
agentPort: port,
|
|
135
|
+
publicUrl,
|
|
136
|
+
host,
|
|
137
|
+
dataDir
|
|
138
|
+
});
|
|
139
|
+
console.log(`pura-cli auto-connecting agent: ${agentName} (${agentId})`);
|
|
140
|
+
console.log(`hub: ${config.hubUrl}`);
|
|
141
|
+
console.log(`agent URL announced to hub: ${publicUrl}`);
|
|
142
|
+
startServer({
|
|
143
|
+
ROLE: "agent",
|
|
144
|
+
HUB_URL: config.hubUrl,
|
|
145
|
+
AGENT_ID: agentId,
|
|
146
|
+
AGENT_NAME: agentName,
|
|
147
|
+
PUBLIC_URL: publicUrl,
|
|
148
|
+
PORT: port,
|
|
149
|
+
HOST: host,
|
|
150
|
+
DATA_DIR: dataDir
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function installLaunchAgent() {
|
|
154
|
+
const config = readConfig();
|
|
155
|
+
if (!config.hubUrl) {
|
|
156
|
+
console.error("No saved hub found. Run `pura-cli connect <hub-url> --name <name>` once first.");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
if (process.platform !== "darwin") {
|
|
160
|
+
console.error("`pura-cli auto-connect --install` currently supports macOS launchd only.");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const cliPath = path.resolve(process.argv[1]);
|
|
164
|
+
fs.mkdirSync(path.dirname(launchAgentPath), { recursive: true });
|
|
165
|
+
fs.writeFileSync(launchAgentPath, makeLaunchAgentPlist(process.execPath, cliPath));
|
|
166
|
+
const guiTarget = getLaunchdGuiTarget();
|
|
167
|
+
const serviceTarget = `${guiTarget}/${launchAgentLabel}`;
|
|
168
|
+
runLaunchctl(["bootout", serviceTarget], { allowFailure: true });
|
|
169
|
+
runLaunchctl(["bootstrap", guiTarget, launchAgentPath]);
|
|
170
|
+
runLaunchctl(["enable", serviceTarget], { allowFailure: true });
|
|
171
|
+
runLaunchctl(["kickstart", "-k", serviceTarget], { allowFailure: true });
|
|
172
|
+
console.log(`Installed pura auto-connect LaunchAgent: ${launchAgentPath}`);
|
|
173
|
+
console.log(`It will start on login and keep the Agent connected to ${config.hubUrl}.`);
|
|
174
|
+
if (cliPath.includes(`${path.sep}_npx${path.sep}`)) {
|
|
175
|
+
console.warn("This was installed from an npx cache path. For long-term use, install pura-cli globally and run the install command again.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function uninstallLaunchAgent() {
|
|
179
|
+
if (process.platform !== "darwin") {
|
|
180
|
+
console.error("`pura-cli auto-connect --uninstall` currently supports macOS launchd only.");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
runLaunchctl(["bootout", `${getLaunchdGuiTarget()}/${launchAgentLabel}`], { allowFailure: true });
|
|
184
|
+
if (fs.existsSync(launchAgentPath)) {
|
|
185
|
+
fs.unlinkSync(launchAgentPath);
|
|
186
|
+
}
|
|
187
|
+
console.log("Removed pura auto-connect LaunchAgent.");
|
|
188
|
+
}
|
|
189
|
+
function printLaunchAgentStatus() {
|
|
190
|
+
if (process.platform !== "darwin") {
|
|
191
|
+
console.log("pura auto-connect status is only available for macOS launchd.");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const result = spawnSync("launchctl", ["print", `${getLaunchdGuiTarget()}/${launchAgentLabel}`], {
|
|
195
|
+
encoding: "utf8"
|
|
196
|
+
});
|
|
197
|
+
if (result.status === 0) {
|
|
198
|
+
console.log(result.stdout.trim());
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
console.log("pura auto-connect is not installed or not loaded.");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function makeLaunchAgentPlist(nodePath, cliPath) {
|
|
205
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
206
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
207
|
+
<plist version="1.0">
|
|
208
|
+
<dict>
|
|
209
|
+
<key>Label</key>
|
|
210
|
+
<string>${escapeXml(launchAgentLabel)}</string>
|
|
211
|
+
<key>ProgramArguments</key>
|
|
212
|
+
<array>
|
|
213
|
+
<string>${escapeXml(nodePath)}</string>
|
|
214
|
+
<string>${escapeXml(cliPath)}</string>
|
|
215
|
+
<string>auto-connect</string>
|
|
216
|
+
</array>
|
|
217
|
+
<key>RunAtLoad</key>
|
|
218
|
+
<true/>
|
|
219
|
+
<key>KeepAlive</key>
|
|
220
|
+
<true/>
|
|
221
|
+
<key>StandardOutPath</key>
|
|
222
|
+
<string>${escapeXml(path.join(os.homedir(), "Library", "Logs", "pura-agent.log"))}</string>
|
|
223
|
+
<key>StandardErrorPath</key>
|
|
224
|
+
<string>${escapeXml(path.join(os.homedir(), "Library", "Logs", "pura-agent.err.log"))}</string>
|
|
225
|
+
</dict>
|
|
226
|
+
</plist>
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
function runLaunchctl(args, options) {
|
|
230
|
+
const result = spawnSync("launchctl", args, { encoding: "utf8" });
|
|
231
|
+
if (result.status !== 0 && !options?.allowFailure) {
|
|
232
|
+
const message = result.stderr.trim() || result.stdout.trim() || `launchctl ${args.join(" ")} failed`;
|
|
233
|
+
console.error(message);
|
|
234
|
+
process.exit(result.status ?? 1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function getLaunchdGuiTarget() {
|
|
238
|
+
if (!process.getuid) {
|
|
239
|
+
console.error("Could not determine the current macOS user id.");
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
return `gui/${process.getuid()}`;
|
|
243
|
+
}
|
|
244
|
+
function escapeXml(value) {
|
|
245
|
+
return value
|
|
246
|
+
.replaceAll("&", "&")
|
|
247
|
+
.replaceAll("<", "<")
|
|
248
|
+
.replaceAll(">", ">")
|
|
249
|
+
.replaceAll('"', """)
|
|
250
|
+
.replaceAll("'", "'");
|
|
251
|
+
}
|
|
252
|
+
function resolveAgentDataDir(value) {
|
|
253
|
+
if (!value)
|
|
254
|
+
return path.join(os.homedir(), ".pura", "agent-data");
|
|
255
|
+
return path.isAbsolute(value) ? value : path.resolve(value);
|
|
256
|
+
}
|
|
257
|
+
function startServer(env) {
|
|
258
|
+
const child = spawn(process.execPath, [new URL("./index.js", import.meta.url).pathname], {
|
|
259
|
+
stdio: "inherit",
|
|
260
|
+
env: {
|
|
261
|
+
...process.env,
|
|
262
|
+
...env
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
266
|
+
}
|
|
267
|
+
function readFlag(name) {
|
|
268
|
+
const index = args.indexOf(name);
|
|
269
|
+
if (index === -1)
|
|
270
|
+
return undefined;
|
|
271
|
+
return args[index + 1];
|
|
272
|
+
}
|
|
273
|
+
function hasFlag(name) {
|
|
274
|
+
return args.includes(name);
|
|
275
|
+
}
|
|
276
|
+
function printHelp() {
|
|
277
|
+
console.log(`pura-cli
|
|
278
|
+
|
|
279
|
+
Usage:
|
|
280
|
+
pura-cli hub [--host 0.0.0.0] [--port 8787]
|
|
281
|
+
pura-cli connect <hub-ip-or-url> [--name developer] [--port 8788] [--public-url http://lan-ip:8788] [--background]
|
|
282
|
+
pura-cli auto-connect [--install|--uninstall|--status]
|
|
283
|
+
pura-cli connect device [--serial adb-serial] [--name device-name] [--owner developer] [--note text]
|
|
284
|
+
pura-cli devices
|
|
285
|
+
`);
|
|
286
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const configDir = path.join(os.homedir(), ".pura");
|
|
5
|
+
const configPath = path.join(configDir, "config.json");
|
|
6
|
+
export function readConfig() {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function writeConfig(config) {
|
|
15
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(configPath, `${JSON.stringify({ ...readConfig(), ...config }, null, 2)}\n`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function makeDeviceId(agentId, serial) {
|
|
2
|
+
return `${encodeURIComponent(agentId)}.${Buffer.from(serial, "utf8").toString("base64url")}`;
|
|
3
|
+
}
|
|
4
|
+
export function parseDeviceId(deviceId) {
|
|
5
|
+
const [encodedAgentId, encodedSerial] = deviceId.split(".", 2);
|
|
6
|
+
if (!encodedAgentId || !encodedSerial) {
|
|
7
|
+
throw new Error("Invalid device id");
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
agentId: decodeURIComponent(encodedAgentId),
|
|
11
|
+
serial: Buffer.from(encodedSerial, "base64url").toString("utf8")
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import { makeDeviceId, parseDeviceId } from "./device-id.js";
|
|
4
|
+
import { httpToWs } from "./network.js";
|
|
5
|
+
import { listDeviceScreenshots, saveScreenshot } from "./screenshots.js";
|
|
6
|
+
const agents = new Map();
|
|
7
|
+
const sessions = new Map();
|
|
8
|
+
const AGENT_TTL_MS = Number(process.env.AGENT_TTL_MS ?? 15_000);
|
|
9
|
+
export function installHubRoutes(app) {
|
|
10
|
+
app.post("/api/agents/heartbeat", (req, res) => {
|
|
11
|
+
const body = req.body;
|
|
12
|
+
if (!body.agentId || !body.url || !Array.isArray(body.devices)) {
|
|
13
|
+
res.status(400).json({ error: "agentId, url and devices are required" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
agents.set(body.agentId, {
|
|
17
|
+
agentId: body.agentId,
|
|
18
|
+
agentName: body.agentName,
|
|
19
|
+
url: body.url.replace(/\/$/, ""),
|
|
20
|
+
devices: body.devices,
|
|
21
|
+
lastSeen: Date.now()
|
|
22
|
+
});
|
|
23
|
+
res.json({ ok: true });
|
|
24
|
+
});
|
|
25
|
+
app.get("/api/devices", (_req, res) => {
|
|
26
|
+
res.json({ devices: listHubDevices(), sessions: listHubSessions() });
|
|
27
|
+
});
|
|
28
|
+
app.put("/api/devices/:deviceId/publication", async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const target = findDevice(req.params.deviceId);
|
|
31
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/publication`, {
|
|
32
|
+
method: "PUT",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify(req.body ?? {})
|
|
35
|
+
});
|
|
36
|
+
res.status(response.status).json(await response.json());
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to publish device" });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
app.delete("/api/devices/:deviceId/publication", async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const target = findDevice(req.params.deviceId);
|
|
45
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/publication`, {
|
|
46
|
+
method: "DELETE"
|
|
47
|
+
});
|
|
48
|
+
res.status(response.status).json(await response.json());
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to unpublish device" });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
app.post("/api/devices/:deviceId/session", async (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const target = findDevice(req.params.deviceId);
|
|
57
|
+
const restart = req.body?.restart === true;
|
|
58
|
+
if (restart) {
|
|
59
|
+
await deleteHubSessionsForDevice(req.params.deviceId);
|
|
60
|
+
}
|
|
61
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/session`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ restart })
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
res.status(response.status).json(await response.json());
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const body = (await response.json());
|
|
71
|
+
const session = {
|
|
72
|
+
id: randomUUID(),
|
|
73
|
+
deviceId: req.params.deviceId,
|
|
74
|
+
agentId: target.agent.agentId,
|
|
75
|
+
agentUrl: target.agent.url,
|
|
76
|
+
agentSessionId: body.session.id,
|
|
77
|
+
serial: target.remoteSerial,
|
|
78
|
+
startedAt: Date.now()
|
|
79
|
+
};
|
|
80
|
+
sessions.set(session.id, session);
|
|
81
|
+
res.json({
|
|
82
|
+
session: {
|
|
83
|
+
...body.session,
|
|
84
|
+
id: session.id,
|
|
85
|
+
serial: req.params.deviceId
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to start remote session" });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
app.get("/api/devices/:deviceId/screenshot", async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
const target = findDevice(req.params.deviceId);
|
|
96
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/screenshot`);
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
res.status(response.status).json({ error: await response.text() });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const image = Buffer.from(await response.arrayBuffer());
|
|
102
|
+
res.setHeader("Content-Type", response.headers.get("content-type") ?? "image/png");
|
|
103
|
+
res.setHeader("Cache-Control", "no-store");
|
|
104
|
+
res.end(image);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to capture remote device screenshot" });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
app.post("/api/devices/:deviceId/screenshots", async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const target = findDevice(req.params.deviceId);
|
|
113
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/screenshot`);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
res.status(response.status).json({ error: await response.text() });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const image = Buffer.from(await response.arrayBuffer());
|
|
119
|
+
res.json({ screenshot: await saveScreenshot(image, req.params.deviceId) });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to save remote device screenshot" });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
app.get("/api/devices/:deviceId/screenshots", (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
findDevice(req.params.deviceId);
|
|
128
|
+
res.json({ screenshots: listDeviceScreenshots(req.params.deviceId) });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to list device screenshots" });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
app.post("/api/devices/:deviceId/tap", async (req, res) => {
|
|
135
|
+
try {
|
|
136
|
+
const target = findDevice(req.params.deviceId);
|
|
137
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/tap`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
body: JSON.stringify(req.body ?? {})
|
|
141
|
+
});
|
|
142
|
+
res.status(response.status).json(await response.json());
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to tap remote device" });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
app.post("/api/devices/:deviceId/long-press", async (req, res) => {
|
|
149
|
+
try {
|
|
150
|
+
const target = findDevice(req.params.deviceId);
|
|
151
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/long-press`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify(req.body ?? {})
|
|
155
|
+
});
|
|
156
|
+
res.status(response.status).json(await response.json());
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to long press remote device" });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
app.post("/api/devices/:deviceId/swipe", async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
const target = findDevice(req.params.deviceId);
|
|
165
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/swipe`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify(req.body ?? {})
|
|
169
|
+
});
|
|
170
|
+
res.status(response.status).json(await response.json());
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to swipe remote device" });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
app.post("/api/devices/:deviceId/control", async (req, res) => {
|
|
177
|
+
try {
|
|
178
|
+
const target = findDevice(req.params.deviceId);
|
|
179
|
+
const response = await fetch(`${target.agent.url}/api/devices/${encodeURIComponent(target.remoteSerial)}/control`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
body: JSON.stringify(req.body ?? {})
|
|
183
|
+
});
|
|
184
|
+
res.status(response.status).json(await response.json());
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
res.status(502).json({ error: error instanceof Error ? error.message : "Failed to control remote device" });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
app.delete("/api/sessions/:id", async (req, res) => {
|
|
191
|
+
const session = sessions.get(req.params.id);
|
|
192
|
+
sessions.delete(req.params.id);
|
|
193
|
+
if (!session) {
|
|
194
|
+
res.json({ deleted: false });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
await fetch(`${session.agentUrl}/api/sessions/${encodeURIComponent(session.agentSessionId)}`, {
|
|
198
|
+
method: "DELETE"
|
|
199
|
+
}).catch(() => undefined);
|
|
200
|
+
res.json({ deleted: true });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async function deleteHubSessionsForDevice(deviceId) {
|
|
204
|
+
const staleSessions = [...sessions.values()].filter((session) => session.deviceId === deviceId);
|
|
205
|
+
await Promise.all(staleSessions.map(async (session) => {
|
|
206
|
+
sessions.delete(session.id);
|
|
207
|
+
await fetch(`${session.agentUrl}/api/sessions/${encodeURIComponent(session.agentSessionId)}`, {
|
|
208
|
+
method: "DELETE"
|
|
209
|
+
}).catch(() => undefined);
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
export function attachHubVideoClient(sessionId, client) {
|
|
213
|
+
const session = sessions.get(sessionId);
|
|
214
|
+
if (!session) {
|
|
215
|
+
client.close(1008, "session not found");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const remote = new WebSocket(`${httpToWs(session.agentUrl)}/ws/sessions/${session.agentSessionId}/video`);
|
|
219
|
+
remote.on("message", (data, isBinary) => {
|
|
220
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
221
|
+
client.send(data, { binary: isBinary });
|
|
222
|
+
}
|
|
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
|
+
});
|
|
230
|
+
client.on("close", () => {
|
|
231
|
+
remote.close();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function listHubDevices() {
|
|
235
|
+
pruneAgents();
|
|
236
|
+
return [...agents.values()].flatMap((agent) => agent.devices.map((device) => ({
|
|
237
|
+
...device,
|
|
238
|
+
serial: makeDeviceId(agent.agentId, device.serial),
|
|
239
|
+
remoteSerial: device.serial,
|
|
240
|
+
agentId: agent.agentId,
|
|
241
|
+
agentName: agent.agentName,
|
|
242
|
+
agentUrl: agent.url,
|
|
243
|
+
publication: device.publication
|
|
244
|
+
? {
|
|
245
|
+
...device.publication,
|
|
246
|
+
serial: makeDeviceId(agent.agentId, device.serial)
|
|
247
|
+
}
|
|
248
|
+
: undefined
|
|
249
|
+
})));
|
|
250
|
+
}
|
|
251
|
+
function listHubSessions() {
|
|
252
|
+
return [...sessions.values()].map((session) => ({
|
|
253
|
+
id: session.id,
|
|
254
|
+
serial: session.deviceId,
|
|
255
|
+
viewerCount: 0,
|
|
256
|
+
startedAt: session.startedAt,
|
|
257
|
+
stream: {
|
|
258
|
+
codec: "h264",
|
|
259
|
+
container: "annexb",
|
|
260
|
+
size: process.env.STREAM_SIZE ?? "native",
|
|
261
|
+
bitrate: process.env.STREAM_BITRATE ?? "8000000"
|
|
262
|
+
}
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
function findDevice(deviceId) {
|
|
266
|
+
pruneAgents();
|
|
267
|
+
const parsed = parseDeviceId(deviceId);
|
|
268
|
+
const agent = agents.get(parsed.agentId);
|
|
269
|
+
if (!agent)
|
|
270
|
+
throw new Error("Agent is offline");
|
|
271
|
+
const device = agent.devices.find((item) => item.serial === parsed.serial);
|
|
272
|
+
if (!device)
|
|
273
|
+
throw new Error("Device is offline");
|
|
274
|
+
return {
|
|
275
|
+
agent,
|
|
276
|
+
device,
|
|
277
|
+
remoteSerial: parsed.serial
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function pruneAgents() {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
for (const [agentId, agent] of agents.entries()) {
|
|
283
|
+
if (now - agent.lastSeen > AGENT_TTL_MS) {
|
|
284
|
+
agents.delete(agentId);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import { installAgentRoutes, startAgentHeartbeat } from "./agent.js";
|
|
6
|
+
import { attachHubVideoClient, installHubRoutes } from "./hub.js";
|
|
7
|
+
import { getLanAddress } from "./network.js";
|
|
8
|
+
import { attachPresenceClient } from "./presence.js";
|
|
9
|
+
import { installScreenshotRoutes } from "./screenshots.js";
|
|
10
|
+
import { attachClient } from "./sessions.js";
|
|
11
|
+
const app = express();
|
|
12
|
+
const port = Number(process.env.PORT ?? process.env.API_PORT ?? 8787);
|
|
13
|
+
const host = process.env.HOST ?? "0.0.0.0";
|
|
14
|
+
const role = (process.env.ROLE ?? "standalone").toLowerCase();
|
|
15
|
+
const agentId = process.env.AGENT_ID ?? `${process.env.USER ?? "dev"}-${getLanAddress()}`.replace(/[^a-zA-Z0-9_.-]/g, "-");
|
|
16
|
+
const agentName = process.env.AGENT_NAME ?? process.env.USER;
|
|
17
|
+
const publicUrl = process.env.PUBLIC_URL;
|
|
18
|
+
const hubUrl = process.env.HUB_URL;
|
|
19
|
+
app.use(express.json());
|
|
20
|
+
app.get("/api/health", (_req, res) => {
|
|
21
|
+
res.json({ ok: true, name: "pura", role, agentId: role === "agent" ? agentId : undefined });
|
|
22
|
+
});
|
|
23
|
+
installScreenshotRoutes(app);
|
|
24
|
+
if (role === "hub") {
|
|
25
|
+
installHubRoutes(app);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
installAgentRoutes(app);
|
|
29
|
+
if (role === "agent") {
|
|
30
|
+
startAgentHeartbeat({ hubUrl, agentId, agentName, publicUrl, port });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
34
|
+
const __dirname = path.dirname(__filename);
|
|
35
|
+
const clientDist = path.resolve(__dirname, "../../dist/client");
|
|
36
|
+
app.use(express.static(clientDist));
|
|
37
|
+
app.get(/.*/, (_req, res) => {
|
|
38
|
+
res.sendFile(path.join(clientDist, "index.html"));
|
|
39
|
+
});
|
|
40
|
+
const server = app.listen(port, host, () => {
|
|
41
|
+
console.log(`pura ${role} listening on http://${host}:${port}`);
|
|
42
|
+
if (role === "agent" && hubUrl) {
|
|
43
|
+
console.log(`pura agent ${agentId} connecting to ${hubUrl}`);
|
|
44
|
+
console.log(`agent public URL: ${publicUrl ?? `http://${getLanAddress()}:${port}`}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
48
|
+
server.on("upgrade", (request, socket, head) => {
|
|
49
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
50
|
+
const videoMatch = url.pathname.match(/^\/ws\/sessions\/([^/]+)\/video$/);
|
|
51
|
+
const presenceMatch = url.pathname.match(/^\/ws\/presence\/([^/]+)$/);
|
|
52
|
+
if (!videoMatch && !presenceMatch) {
|
|
53
|
+
socket.destroy();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
57
|
+
if (presenceMatch) {
|
|
58
|
+
attachPresenceClient(decodeURIComponent(presenceMatch[1]), ws);
|
|
59
|
+
}
|
|
60
|
+
else if (role === "hub") {
|
|
61
|
+
attachHubVideoClient(videoMatch[1], ws);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
attachClient(videoMatch[1], ws);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|