@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.
@@ -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("&", "&amp;")
247
+ .replaceAll("<", "&lt;")
248
+ .replaceAll(">", "&gt;")
249
+ .replaceAll('"', "&quot;")
250
+ .replaceAll("'", "&apos;");
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
+ });