9remote 0.1.63 → 2.0.1

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/index.js CHANGED
@@ -1,717 +1,273 @@
1
1
  /**
2
- * Server entry point - Socket.IO backend
2
+ * Server entry point - config-driven HTTP router + Socket.IO
3
3
  */
4
4
 
5
5
  import { createServer, request as httpRequest } from "http";
6
- import { parse } from "url";
7
- import { exec, execSync } from "child_process";
8
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
6
+ import { exec, spawn } from "child_process";
7
+ import { readFileSync, existsSync } from "fs";
9
8
  import { join, extname } from "path";
10
9
  import { fileURLToPath } from "url";
10
+ import chalk from "chalk";
11
+
12
+ import { createRouter, jsonOk, jsonErr } from "./lib/router.js";
13
+ import { STEP, browserFetch, PERMISSION_POLL_MS } from "./lib/constants.js";
11
14
  import { setupSocketIO, getIO } from "./lib/socketio.js";
12
- import { handleLocalSites } from "./api/localSites.js";
13
15
  import { setCorsHeaders, handlePreflight } from "./middleware/cors.js";
14
16
  import { createProxyServer, handleProxyRequest, startProxySession, endProxySession } from "./proxy/index.js";
15
17
  import { initializeTerminal } from "./features/terminal/terminalSocket.js";
16
- import { sendPushNotification } from "./features/terminal/pushManager.js";
17
- import { addNotification } from "./features/terminal/notificationManager.js";
18
- import chalk from "chalk";
19
- import { loadKey, saveKey, writeCmd } from "./cli/utils/state.js";
20
- import { checkPermissions, openPermissionPane } from "./cli/utils/permissions.js";
21
- import { generateApiKeyWithMachine } from "./cli/utils/apiKey.js";
22
- import { getConsistentMachineId } from "./cli/utils/machineId.js";
18
+ import { handleLocalSites } from "./api/localSites.js";
19
+ import { verifyApiKeyCrc } from "./cli/utils/apiKey.js";
20
+ import { isNewerVersion } from "./cli/utils/updateChecker.js";
21
+
22
+ import {
23
+ loadUiState, loadDesktopState, refreshPermissionsAsync, pushUiEvent, setRemoteAvailable,
24
+ handleSseEvents, handleStateGet, handleStatePost,
25
+ handleStop, handleStart, handleShutdown,
26
+ handleConnections, handleDesktopToggle,
27
+ handlePermissionsGet, handlePermissionsRequest,
28
+ } from "./api/ui.js";
29
+ import { handleOneTimeKey, handleRegenerate } from "./api/key.js";
30
+ import { handleApprove, handleReject, handlePending, handleApproved, handleRemove, handleDisconnect, handleRejected, handleApproveRejected, handleClearRejected } from "./api/device.js";
31
+ import { handleNotifyPost, handleNotifyGet } from "./api/notify.js";
23
32
 
24
33
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
25
34
  const IS_DEV = process.env.NODE_ENV === "development";
26
35
  const VITE_PORT = 5173;
36
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/9remote/latest";
27
37
 
28
- // UI dist: bundled → cli/dist/ui/, dev → server/ui/dist/
29
38
  const UI_DIST = existsSync(join(__dirname, "ui", "dist"))
30
39
  ? join(__dirname, "ui", "dist")
31
40
  : join(__dirname, "ui");
32
41
 
33
42
  const MIME_TYPES = {
34
- ".html": "text/html",
35
- ".js": "application/javascript",
36
- ".css": "text/css",
37
- ".svg": "image/svg+xml",
38
- ".png": "image/png",
39
- ".ico": "image/x-icon",
40
- ".woff2": "font/woff2",
41
- ".woff": "font/woff",
43
+ ".html": "text/html", ".js": "application/javascript", ".css": "text/css",
44
+ ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
45
+ ".woff2": "font/woff2", ".woff": "font/woff",
42
46
  };
43
47
 
44
- const NPM_PACKAGE_NAME = "9remote";
45
- const NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
48
+ // ── Static / Dev helpers ─────────────────────────────────────────
46
49
 
47
- /** Proxy request to Vite dev server (dev mode only, includes HMR websocket) */
48
- function proxyToVite(req, res) {
50
+ function proxyToVite(req, res, fallbackFn) {
49
51
  const proxy = httpRequest(
50
52
  { hostname: "localhost", port: VITE_PORT, path: req.url, method: req.method, headers: req.headers },
51
- (proxyRes) => {
52
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
53
- proxyRes.pipe(res);
54
- }
53
+ (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }
55
54
  );
56
- proxy.on("error", () => {
57
- res.writeHead(502);
58
- res.end("Vite dev server not ready yet, please wait...");
59
- });
55
+ proxy.on("error", () => fallbackFn ? fallbackFn() : (() => { res.writeHead(502); res.end("Vite dev server not ready"); })());
60
56
  req.pipe(proxy);
61
57
  }
62
58
 
63
- /** Serve a static file from ui/dist */
64
59
  function serveStatic(res, filePath) {
65
60
  if (!existsSync(filePath)) return false;
66
- const ext = extname(filePath);
67
- const mime = MIME_TYPES[ext] || "application/octet-stream";
61
+ const mime = MIME_TYPES[extname(filePath)] || "application/octet-stream";
68
62
  res.setHeader("Content-Type", mime);
69
63
  res.writeHead(200);
70
64
  res.end(readFileSync(filePath));
71
65
  return true;
72
66
  }
73
67
 
74
- /** Check npm registry for newer version, push via SSE if found */
75
68
  async function checkForUpdate(currentVersion) {
76
69
  try {
77
- const res = await fetch(NPM_REGISTRY_URL);
70
+ const res = await browserFetch(NPM_REGISTRY_URL);
78
71
  if (!res.ok) return;
79
72
  const { version } = await res.json();
80
- if (version && version !== currentVersion) {
81
- pushUiEvent("updateAvailable", { version });
82
- }
73
+ if (version && isNewerVersion(currentVersion, version)) pushUiEvent("updateAvailable", { version });
83
74
  } catch { /* non-critical */ }
84
75
  }
85
76
 
86
- // ── UI State (SSE) ──────────────────────────────────────────────────────────
87
-
88
- const UI_STATE_FILE = join(
89
- process.env.HOME || process.env.USERPROFILE || ".",
90
- ".9remote", "ui-state.json"
91
- );
92
-
93
- /** Persist uiState to disk */
94
- function saveUiState() {
95
- try {
96
- mkdirSync(join(process.env.HOME || process.env.USERPROFILE || ".", ".9remote"), { recursive: true });
97
- writeFileSync(UI_STATE_FILE, JSON.stringify(uiState));
98
- } catch {}
99
- }
100
-
101
- /** Load uiState from disk */
102
- function loadUiState() {
103
- try {
104
- if (existsSync(UI_STATE_FILE)) {
105
- const saved = JSON.parse(readFileSync(UI_STATE_FILE, "utf8"));
106
- // Only restore if tunnel was ready — otherwise start fresh
107
- if (saved.step === 4 && saved.permanentKey) {
108
- uiState = { ...uiState, ...saved };
109
- }
110
- }
111
- } catch {}
112
- }
113
-
114
- // In-memory UI state + SSE clients
115
- let uiState = { step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null, permanentKey: "", qrUrl: "", latency: null, uptime: null };
116
- const sseClients = new Set();
117
-
118
- // Active socket connections for UI display
119
- const activeConnections = new Map();
120
-
121
- // Remote desktop toggle state — loaded from state file on start
122
- let desktopEnabled = false;
123
-
124
- const DESKTOP_STATE_FILE = join(
125
- process.env.HOME || process.env.USERPROFILE || ".",
126
- ".9remote", "desktop.json"
127
- );
128
-
129
- /** Load desktopEnabled from state file */
130
- function loadDesktopState() {
131
- try {
132
- if (existsSync(DESKTOP_STATE_FILE)) {
133
- const data = JSON.parse(readFileSync(DESKTOP_STATE_FILE, "utf8"));
134
- desktopEnabled = !!data.enabled;
135
- }
136
- } catch {}
137
- }
138
-
139
- /** Save desktopEnabled to state file */
140
- function saveDesktopState() {
141
- try {
142
- mkdirSync(join(process.env.HOME || process.env.USERPROFILE || ".", ".9remote"), { recursive: true });
143
- writeFileSync(DESKTOP_STATE_FILE, JSON.stringify({ enabled: desktopEnabled }));
144
- } catch {}
145
- }
146
-
147
- // Cached permissions — refreshed async, never blocks request
148
- let cachedPermissions = { screenRecording: false, accessibility: false };
149
-
150
- function refreshPermissionsAsync() {
151
- checkPermissions().then((p) => { cachedPermissions = p; });
152
- }
153
-
154
- function getSystemPermissions() {
155
- return cachedPermissions;
156
- }
157
-
158
- /** Open System Preferences pane for a permission, then poll until granted */
159
- function requestSystemPermission(type) {
160
- return new Promise((resolve) => {
161
- if (process.platform !== "darwin") { resolve(); return; }
162
- openPermissionPane(type);
163
- resolve(); // resolve immediately — UI stays open
164
-
165
- // Poll every 2s for up to 60s to detect when user grants permission
166
- let attempts = 0;
167
- const poll = setInterval(() => {
168
- attempts++;
169
- checkPermissions().then((p) => {
170
- cachedPermissions = p;
171
- if (p[type] || attempts >= 30) {
172
- clearInterval(poll);
173
- pushUiEvent("permissions", { ...cachedPermissions, desktopEnabled });
174
- }
175
- });
176
- }, 2000);
177
- });
178
- }
179
-
180
- /** Clear one-time key from UI state after client uses it */
181
- export function clearOneTimeKey() {
182
- uiState = { ...uiState, oneTimeKey: "", oneTimeKeyExpiresAt: null, qrUrl: "" };
183
- pushUiEvent("state", uiState);
184
- saveUiState();
185
- }
186
-
187
- /** Track a new socket connection */
188
- export function trackConnection(socketId, ip, type = "ws") {
189
- activeConnections.set(socketId, { ip, type, connectedAt: Date.now() });
190
- pushUiEvent("connections", { connections: [...activeConnections.values()] });
191
- }
192
-
193
- /** Remove a socket connection */
194
- export function untrackConnection(socketId) {
195
- activeConnections.delete(socketId);
196
- pushUiEvent("connections", { connections: [...activeConnections.values()] });
197
- }
198
-
199
- /** Push a log line to UI */
200
- export function pushUiLog(message) {
201
- pushUiEvent("log", { message: `[${new Date().toLocaleTimeString()}] ${message}` });
202
- }
203
-
204
- /** Push event to all connected UI SSE clients */
205
- function pushUiEvent(type, data) {
206
- const payload = `data: ${JSON.stringify({ type, ...data })}\n\n`;
207
- for (const res of sseClients) {
208
- try { res.write(payload); } catch { sseClients.delete(res); }
209
- }
210
- }
211
-
212
- /** Handle SSE connection from UI */
213
- function handleUiEvents(req, res) {
214
- res.setHeader("Content-Type", "text/event-stream");
215
- res.setHeader("Cache-Control", "no-cache");
216
- res.setHeader("Connection", "keep-alive");
217
- res.writeHead(200);
77
+ // ── Codespace handler ────────────────────────────────────────
218
78
 
219
- // Send current state + connections + permissions immediately on connect
220
- res.write(`data: ${JSON.stringify({ type: "state", ...uiState })}\n\n`);
221
- res.write(`data: ${JSON.stringify({ type: "connections", connections: [...activeConnections.values()] })}\n\n`);
222
- res.write(`data: ${JSON.stringify({ type: "permissions", ...getSystemPermissions(), desktopEnabled })}\n\n`);
223
- sseClients.add(res);
224
-
225
- req.on("close", () => sseClients.delete(res));
226
- }
227
-
228
- /** Handle UI state update from CLI */
229
- function handleUiState(body) {
230
- try {
231
- const data = JSON.parse(body || "{}");
232
- uiState = { ...uiState, ...data };
233
- pushUiEvent("state", uiState);
234
- saveUiState();
235
- } catch { /* ignore malformed */ }
236
- }
237
-
238
- const ORANGE = chalk.rgb(230, 138, 110);
239
-
240
- function isCodespaces() {
241
- return process.env.CODESPACES === "true";
79
+ function handleCodespaceStop(req, res) {
80
+ if (process.env.CODESPACES !== "true") { jsonErr(res, 400, "Not running on Codespaces"); return; }
81
+ const name = process.env.CODESPACE_NAME;
82
+ if (!name) { jsonErr(res, 400, "Codespace name not found"); return; }
83
+ const io = getIO();
84
+ if (io) io.emit("codespace:stopping");
85
+ jsonOk(res, { success: true, message: "Stopping codespace..." });
86
+ setTimeout(() => exec(`gh codespace stop -c ${name}`, { windowsHide: true }), 500);
242
87
  }
243
88
 
244
- async function handleCodespaceStop(req, res) {
245
- if (req.method !== "POST") {
246
- res.writeHead(405);
247
- res.end(JSON.stringify({ error: "Method not allowed" }));
248
- return;
249
- }
89
+ // ── Proxy handlers ────────────────────────────────────────────
250
90
 
251
- if (!isCodespaces()) {
252
- res.writeHead(400);
253
- res.end(JSON.stringify({ error: "Not running on Codespaces" }));
254
- return;
255
- }
91
+ let proxyServer;
256
92
 
257
- const codespaceName = process.env.CODESPACE_NAME;
258
- if (!codespaceName) {
259
- res.writeHead(400);
260
- res.end(JSON.stringify({ error: "Codespace name not found" }));
93
+ async function handleProxyStartEnd(req, res, { pathname }) {
94
+ // Verify API key (required for tunnel access)
95
+ const authHeader = req.headers.authorization;
96
+ if (!authHeader || !authHeader.startsWith("Bearer ") || !verifyApiKeyCrc(authHeader.slice(7))) {
97
+ jsonErr(res, 401, "Unauthorized");
261
98
  return;
262
99
  }
263
-
264
- // Emit event to all clients before stopping
265
- const io = getIO();
266
- if (io) {
267
- io.emit("codespace:stopping");
268
- }
269
-
270
- res.setHeader("Content-Type", "application/json");
271
- res.writeHead(200);
272
- res.end(JSON.stringify({ success: true, message: "Stopping codespace..." }));
273
-
274
- setTimeout(() => {
275
- exec(`gh codespace stop -c ${codespaceName}`, (error) => {
276
- if (error) {
277
- console.error("Failed to stop codespace:", error);
278
- }
279
- });
280
- }, 500);
100
+ const { parseJsonBody } = await import("./lib/router.js");
101
+ const data = await parseJsonBody(req, res);
102
+ if (!data) return;
103
+ if (!data.port) { jsonErr(res, 400, "Port required"); return; }
104
+ pathname.endsWith("start") ? startProxySession(data.port) : endProxySession(data.port);
105
+ jsonOk(res, { success: true });
281
106
  }
282
107
 
283
- const hostname = "localhost";
284
- const port = parseInt(process.env.PORT || "2208", 10);
285
-
286
- // Rate limiting for PWA push only (not badge)
287
- const pushLastTime = {};
288
- const PUSH_RATE_LIMIT_MS = 10000;
289
-
290
- function handleNotify(req, res, body, query) {
291
- const now = Date.now();
292
-
293
- let type, sessionId, tool;
294
-
295
- if (query) {
296
- type = query.type || "stop";
297
- sessionId = query.sessionId || "";
298
- tool = query.tool || "claude";
108
+ function handleProxy(req, res, { pathname, search }) {
109
+ const match = pathname.match(/^\/proxy\/(\d+)(\/.*)?$/);
110
+ if (match) {
111
+ handleProxyRequest(proxyServer, req, res, match[1], match[2] || "/", search);
299
112
  } else {
300
- try {
301
- const data = JSON.parse(body || "{}");
302
- type = data.type || "stop";
303
- sessionId = data.sessionId || "";
304
- tool = data.tool || "claude";
305
- } catch {
306
- res.writeHead(400);
307
- res.end(JSON.stringify({ error: "Invalid JSON" }));
308
- return;
309
- }
113
+ jsonErr(res, 404, "Not found");
310
114
  }
115
+ }
311
116
 
117
+ // ────────────────────────────────────────────────────────────────────────────
118
+ // ROUTE TABLE — single source of truth for all HTTP endpoints
119
+ // public: true = accessible via tunnel, false/omit = localhost-only
120
+ // ────────────────────────────────────────────────────────────────────────────
121
+
122
+ const ROUTES = [
123
+ // Public routes (accessible via tunnel)
124
+ { path: "/api/health", method: "GET", public: true, handler: (req, res) => jsonOk(res, { status: "ok", timestamp: Date.now() }) },
125
+ { path: "/api/version", method: "GET", public: true, handler: (req, res) => {
126
+ const version = typeof __CLI_VERSION__ !== "undefined"
127
+ ? __CLI_VERSION__
128
+ : JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
129
+ jsonOk(res, { version });
130
+ }},
131
+ { path: "/api/notify", method: "POST", public: true, handler: handleNotifyPost },
132
+ { path: "/api/notify", method: "GET", public: true, handler: handleNotifyGet },
133
+ { path: "/proxy/*", method: "*", public: true, handler: handleProxy },
134
+
135
+ // UI state & SSE (localhost-only)
136
+ { path: "/api/ui/events", method: "GET", handler: handleSseEvents },
137
+ { path: "/api/ui/state", method: "GET", handler: handleStateGet },
138
+ { path: "/api/ui/state", method: "POST", handler: handleStatePost },
139
+ { path: "/api/ui/stop", method: "POST", handler: handleStop },
140
+ { path: "/api/ui/start", method: "POST", handler: handleStart },
141
+ { path: "/api/ui/shutdown", method: "POST", handler: handleShutdown },
142
+
143
+ // Key management (localhost-only)
144
+ { path: "/api/key/one-time", method: "POST", handler: handleOneTimeKey },
145
+ { path: "/api/key/regenerate", method: "POST", handler: handleRegenerate },
146
+
147
+ // Device approval (localhost-only)
148
+ { path: "/api/device/approve", method: "POST", handler: handleApprove },
149
+ { path: "/api/device/reject", method: "POST", handler: handleReject },
150
+ { path: "/api/device/pending", method: "GET", handler: handlePending },
151
+ { path: "/api/device/approved", method: "GET", handler: handleApproved },
152
+ { path: "/api/device/rejected", method: "GET", handler: handleRejected },
153
+ { path: "/api/device/approve-rejected", method: "POST", handler: handleApproveRejected },
154
+ { path: "/api/device/clear-rejected", method: "POST", handler: handleClearRejected },
155
+ { path: "/api/device/remove", method: "POST", handler: handleRemove },
156
+ { path: "/api/device/disconnect", method: "POST", handler: handleDisconnect },
157
+
158
+ // System (localhost-only)
159
+ { path: "/api/connections", method: "GET", handler: handleConnections },
160
+ { path: "/api/permissions", method: "GET", handler: handlePermissionsGet },
161
+ { path: "/api/permissions/request", method: "POST", handler: handlePermissionsRequest },
162
+ { path: "/api/desktop/toggle", method: "POST", handler: handleDesktopToggle },
163
+ { path: "/api/local-sites", method: "*", public: true, handler: handleLocalSites },
164
+ { path: "/api/codespace/stop", method: "POST", handler: handleCodespaceStop },
165
+
166
+ // Proxy session management (tunnel-accessible, requires API key)
167
+ { path: "/api/proxy/start", method: "POST", public: true, handler: handleProxyStartEnd },
168
+ { path: "/api/proxy/end", method: "POST", public: true, handler: handleProxyStartEnd },
169
+ ];
170
+
171
+ // ── Server bootstrap ───────────────────────────────────────────────────────
312
172
 
313
- const io = getIO();
314
- if (io) {
315
- const notification = { type, sessionId, tool, timestamp: now };
316
-
317
- if (sessionId)
318
-
319
- // Always persist badge and notify all clients (no throttle)
320
- if (sessionId) {
321
- addNotification(sessionId, notification);
322
- io.emit("chatNotification", notification);
323
-
324
- // PWA push: throttle per tool+type to avoid spam
325
- const pushKey = `${tool}:${type}`;
326
- const pushThrottled = now - (pushLastTime[pushKey] || 0) < PUSH_RATE_LIMIT_MS;
327
- if (!pushThrottled) {
328
- pushLastTime[pushKey] = now;
329
- io.timeout(5000).emit("chatNotificationAck", notification, (err, responses) => {
330
- const connectedCount = io.sockets.sockets.size;
331
- const hasFocused = !err && responses && responses.length > 0;
332
- if (!hasFocused && connectedCount === 0) sendPushNotification(notification);
333
- });
334
- }
335
- }
336
- }
173
+ const hostname = "localhost";
174
+ const port = parseInt(process.env.PORT || "2208", 10);
337
175
 
338
- res.setHeader("Content-Type", "application/json");
339
- res.writeHead(200);
340
- res.end(JSON.stringify({ success: true }));
176
+ // Auto-start Vite dev server in dev mode
177
+ let viteProcess = null;
178
+ function startViteDev() {
179
+ if (!IS_DEV) return;
180
+ const viteConfigPath = join(__dirname, "vite.config.js");
181
+ if (!existsSync(viteConfigPath)) return;
182
+ const viteBin = existsSync(join(__dirname, "node_modules", ".bin", "vite"))
183
+ ? join(__dirname, "node_modules", ".bin", "vite")
184
+ : join(__dirname, "..", "node_modules", ".bin", "vite");
185
+ viteProcess = spawn("node", [viteBin, "--config", viteConfigPath], {
186
+ cwd: __dirname,
187
+ stdio: "ignore",
188
+ detached: false,
189
+ });
190
+ viteProcess.on("error", () => {});
191
+ viteProcess.unref();
341
192
  }
342
193
 
343
194
  export async function startServer() {
344
- // Initialize terminal sessions
345
195
  await initializeTerminal();
196
+ proxyServer = createProxyServer();
197
+ startViteDev();
346
198
 
347
- const proxy = createProxyServer();
199
+ // Static file / Vite dev fallback (localhost-only)
200
+ const staticFallback = (req, res, { pathname }) => {
201
+ if (pathname.startsWith("/api/") || pathname.startsWith("/socket.io")) {
202
+ jsonErr(res, 404, "Not found");
203
+ return;
204
+ }
205
+ const isTunnel = !!req.headers["cf-connecting-ip"];
206
+ const isLocal = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
207
+ if (isTunnel || !isLocal) { jsonErr(res, 403, "Forbidden"); return; }
208
+ const serveStaticFiles = () => {
209
+ const filePath = (pathname === "/" || pathname === "") ? join(UI_DIST, "index.html") : join(UI_DIST, pathname);
210
+ if (serveStatic(res, filePath)) return;
211
+ serveStatic(res, join(UI_DIST, "index.html"));
212
+ };
213
+ if (IS_DEV) { proxyToVite(req, res, serveStaticFiles); return; }
214
+ serveStaticFiles();
215
+ };
216
+
217
+ const router = createRouter(ROUTES, { fallback: staticFallback });
348
218
 
349
219
  const server = createServer(async (req, res) => {
350
220
  setCorsHeaders(res);
351
-
352
221
  if (handlePreflight(req, res)) return;
353
-
354
- try {
355
- const parsedUrl = parse(req.url, true);
356
- const { pathname, search } = parsedUrl;
357
-
358
- // UI routes: only accessible from localhost (not via tunnel)
359
- const isLocalhost = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
360
- const isUiRoute = pathname === "/api/ui/events" || pathname === "/api/ui/state"
361
- || (!pathname.startsWith("/api/") && !pathname.startsWith("/proxy/") && !pathname.startsWith("/socket.io"));
362
- if (isUiRoute && !isLocalhost) {
363
- res.writeHead(403);
364
- res.end(JSON.stringify({ error: "forbidden" }));
365
- return;
366
- }
367
-
368
- // UI SSE stream
369
- if (pathname === "/api/ui/events") {
370
- handleUiEvents(req, res);
371
- return;
372
- }
373
-
374
- // UI state update from CLI
375
- if (pathname === "/api/ui/state" && req.method === "POST") {
376
- let body = "";
377
- req.on("data", chunk => body += chunk);
378
- req.on("end", () => {
379
- handleUiState(body);
380
- res.setHeader("Content-Type", "application/json");
381
- res.writeHead(200);
382
- res.end(JSON.stringify({ ok: true }));
383
- });
384
- return;
385
- }
386
-
387
- // UI state snapshot
388
- if (pathname === "/api/ui/state" && req.method === "GET") {
389
- res.setHeader("Content-Type", "application/json");
390
- res.writeHead(200);
391
- // Include permissions + desktopEnabled so UI only needs 1 fetch
392
- res.end(JSON.stringify({
393
- ...uiState,
394
- ...getSystemPermissions(),
395
- desktopEnabled,
396
- }));
397
- return;
398
- }
399
-
400
- // Stop tunnel (disconnect), keep server alive
401
- if (pathname === "/api/ui/stop" && req.method === "POST") {
402
- res.setHeader("Content-Type", "application/json");
403
- res.writeHead(200);
404
- res.end(JSON.stringify({ ok: true }));
405
- // Immediately reset state so UI transitions to welcome screen
406
- uiState = { ...uiState, step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null };
407
- pushUiEvent("state", uiState);
408
- saveUiState();
409
- writeCmd("stop-tunnel");
410
- return;
411
- }
412
-
413
- // Start tunnel (reconnect)
414
- if (pathname === "/api/ui/start" && req.method === "POST") {
415
- res.setHeader("Content-Type", "application/json");
416
- res.writeHead(200);
417
- res.end(JSON.stringify({ ok: true }));
418
- // Immediately push step:1 so UI transitions to progress screen
419
- uiState = { ...uiState, step: 1 };
420
- pushUiEvent("state", uiState);
421
- saveUiState();
422
- writeCmd("start-tunnel");
423
- return;
424
- }
425
-
426
- // Generate new one-time key
427
- if (pathname === "/api/key/one-time" && req.method === "POST") {
428
- const workerUrl = uiState.workerUrl || "https://9remote.cc";
429
- const permanentKey = uiState.permanentKey;
430
- if (!permanentKey) {
431
- res.writeHead(400);
432
- res.end(JSON.stringify({ error: "No permanent key set" }));
433
- return;
434
- }
435
- try {
436
- const r = await fetch(`${workerUrl}/api/temp-key/create`, {
437
- method: "POST",
438
- headers: { "Content-Type": "application/json" },
439
- body: JSON.stringify({ apiKey: permanentKey, expiryMinutes: 30 }),
440
- });
441
- const data = await r.json();
442
- const qrUrl = `${workerUrl}/login?k=${data.tempKey}`;
443
- uiState = { ...uiState, oneTimeKey: data.tempKey, oneTimeKeyExpiresAt: data.expiresAt, qrUrl };
444
- pushUiEvent("state", uiState);
445
- res.setHeader("Content-Type", "application/json");
446
- res.writeHead(200);
447
- res.end(JSON.stringify({ oneTimeKey: data.tempKey, expiresAt: data.expiresAt, qrUrl }));
448
- } catch (err) {
449
- res.writeHead(500);
450
- res.end(JSON.stringify({ error: err.message }));
451
- }
452
- return;
453
- }
454
-
455
- // Regenerate permanent key
456
- if (pathname === "/api/key/regenerate" && req.method === "POST") {
457
- res.setHeader("Content-Type", "application/json");
458
- try {
459
- const machineId = await getConsistentMachineId();
460
- const { key } = generateApiKeyWithMachine(machineId);
461
- const existing = loadKey();
462
- saveKey(machineId, key, existing?.name || "Default");
463
- pushUiEvent("state", { ...uiState, permanentKey: key });
464
- uiState = { ...uiState, permanentKey: key };
465
- res.writeHead(200);
466
- res.end(JSON.stringify({ ok: true, permanentKey: key }));
467
- } catch (err) {
468
- res.writeHead(500);
469
- res.end(JSON.stringify({ error: err.message }));
470
- }
471
- return;
472
- }
473
-
474
- // Get system permissions status
475
- if (pathname === "/api/permissions" && req.method === "GET") {
476
- res.setHeader("Content-Type", "application/json");
477
- res.writeHead(200);
478
- res.end(JSON.stringify(getSystemPermissions()));
479
- return;
480
- }
481
-
482
- // Request a system permission
483
- if (pathname === "/api/permissions/request" && req.method === "POST") {
484
- let body = "";
485
- req.on("data", (chunk) => body += chunk);
486
- req.on("end", async () => {
487
- try {
488
- const { type } = JSON.parse(body || "{}");
489
- await requestSystemPermission(type);
490
- res.setHeader("Content-Type", "application/json");
491
- res.writeHead(200);
492
- res.end(JSON.stringify({ ok: true }));
493
- } catch (err) {
494
- res.writeHead(500);
495
- res.end(JSON.stringify({ error: err.message }));
496
- }
497
- });
498
- return;
499
- }
500
-
501
- // Toggle remote desktop
502
- if (pathname === "/api/desktop/toggle" && req.method === "POST") {
503
- let body = "";
504
- req.on("data", (chunk) => body += chunk);
505
- req.on("end", () => {
506
- try {
507
- const { enabled } = JSON.parse(body || "{}");
508
- desktopEnabled = !!enabled;
509
- saveDesktopState();
510
- // Push updated state including desktopEnabled + fresh permissions
511
- pushUiEvent("permissions", { ...getSystemPermissions(), desktopEnabled });
512
- res.setHeader("Content-Type", "application/json");
513
- res.writeHead(200);
514
- res.end(JSON.stringify({ ok: true, enabled: desktopEnabled }));
515
- } catch (err) {
516
- res.writeHead(500);
517
- res.end(JSON.stringify({ error: err.message }));
518
- }
519
- });
520
- return;
521
- }
522
-
523
- // Get active connections
524
- if (pathname === "/api/connections" && req.method === "GET") {
525
- res.setHeader("Content-Type", "application/json");
526
- res.writeHead(200);
527
- res.end(JSON.stringify({ connections: [...activeConnections.values()] }));
528
- return;
529
- }
530
-
531
- // Health check endpoint
532
- if (pathname === "/api/health") {
533
- res.setHeader("Content-Type", "application/json");
534
- res.writeHead(200);
535
- res.end(JSON.stringify({ status: "ok", timestamp: Date.now() }));
536
- return;
537
- }
538
-
539
- // Version endpoint
540
- if (pathname === "/api/version") {
541
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
542
- res.setHeader("Content-Type", "application/json");
543
- res.writeHead(200);
544
- res.end(JSON.stringify({ version: pkg.version }));
545
- return;
546
- }
547
-
548
- // Serve UI: proxy to Vite (dev) or serve static (production)
549
- if (!pathname.startsWith("/api/") && !pathname.startsWith("/proxy/") && !pathname.startsWith("/socket.io")) {
550
- if (IS_DEV) {
551
- proxyToVite(req, res);
552
- return;
553
- }
554
- const filePath = pathname === "/" || pathname === ""
555
- ? join(UI_DIST, "index.html")
556
- : join(UI_DIST, pathname);
557
- if (serveStatic(res, filePath)) return;
558
- // SPA fallback
559
- if (serveStatic(res, join(UI_DIST, "index.html"))) return;
560
- }
561
-
562
- // API routes
563
- if (pathname === "/api/local-sites") {
564
- await handleLocalSites(req, res);
565
- return;
566
- }
567
-
568
- // Codespace stop endpoint
569
- if (pathname === "/api/codespace/stop") {
570
- await handleCodespaceStop(req, res);
571
- return;
572
- }
573
-
574
- // Notification endpoint (called by AI tool hooks)
575
- if (pathname === "/api/notify" && req.method === "POST") {
576
- let body = "";
577
- req.on("data", chunk => body += chunk);
578
- req.on("end", () => {
579
- handleNotify(req, res, body);
580
- });
581
- return;
582
- }
583
-
584
- if (pathname === "/api/notify" && req.method === "GET") {
585
- handleNotify(req, res, null, parsedUrl.query);
586
- return;
587
- }
588
-
589
- // Proxy session management
590
- if (pathname === "/api/proxy/start" && req.method === "POST") {
591
- let body = "";
592
- req.on("data", chunk => body += chunk);
593
- req.on("end", () => {
594
- const { port } = JSON.parse(body || "{}");
595
- if (port) {
596
- startProxySession(port);
597
- res.setHeader("Content-Type", "application/json");
598
- res.writeHead(200);
599
- res.end(JSON.stringify({ success: true }));
600
- } else {
601
- res.writeHead(400);
602
- res.end(JSON.stringify({ error: "Port required" }));
603
- }
604
- });
605
- return;
606
- }
607
-
608
- if (pathname === "/api/proxy/end" && req.method === "POST") {
609
- let body = "";
610
- req.on("data", chunk => body += chunk);
611
- req.on("end", () => {
612
- const { port } = JSON.parse(body || "{}");
613
- if (port) {
614
- endProxySession(port);
615
- res.setHeader("Content-Type", "application/json");
616
- res.writeHead(200);
617
- res.end(JSON.stringify({ success: true }));
618
- } else {
619
- res.writeHead(400);
620
- res.end(JSON.stringify({ error: "Port required" }));
621
- }
622
- });
623
- return;
624
- }
625
-
626
- // Proxy routes
627
- if (pathname.startsWith("/proxy/")) {
628
- const match = pathname.match(/^\/proxy\/(\d+)(\/.*)?$/);
629
- if (match) {
630
- handleProxyRequest(proxy, req, res, match[1], match[2] || "/", search);
631
- return;
632
- }
633
- }
634
-
635
- // 404 for unknown routes
636
- res.writeHead(404);
637
- res.end(JSON.stringify({ error: "Not found" }));
638
- } catch (err) {
639
- console.error("Error:", req.url, err);
640
- res.statusCode = 500;
641
- res.end("Internal server error");
642
- }
222
+ await router(req, res);
643
223
  });
644
224
 
645
- // Load persisted states + warm up permission cache
646
225
  loadUiState();
647
226
  loadDesktopState();
648
227
  refreshPermissionsAsync();
228
+ // macOS TCC has no change event — poll to detect permission revoke/grant
229
+ if (process.platform === "darwin") {
230
+ setInterval(refreshPermissionsAsync, PERMISSION_POLL_MS);
231
+ }
649
232
 
650
233
  await setupSocketIO(server);
651
234
 
652
235
  server.listen(port, (err) => {
653
236
  if (err) throw err;
654
- console.log(ORANGE(`✅ Server ready on http://${hostname}:${port}`));
655
-
656
- // Check for updates in background (non-blocking)
657
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
658
- checkForUpdate(pkg.version);
237
+ const version = typeof __CLI_VERSION__ !== "undefined"
238
+ ? __CLI_VERSION__
239
+ : JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
240
+ checkForUpdate(version);
659
241
  });
660
242
 
661
- // Graceful shutdown handler
243
+ // Graceful shutdown
662
244
  let isShuttingDown = false;
663
-
664
245
  const gracefulShutdown = async (signal) => {
665
246
  if (isShuttingDown) return;
666
247
  isShuttingDown = true;
667
-
668
248
  console.log(chalk.yellow(`\n🛑 Received ${signal}, shutting down gracefully...`));
669
-
670
- // Set timeout to force exit if cleanup takes too long
671
- const forceExitTimeout = setTimeout(() => {
672
- console.log(chalk.red("⚠️ Forced exit after 5s timeout"));
673
- process.exit(1);
674
- }, 5000);
675
-
249
+ const forceExit = setTimeout(() => { console.log(chalk.red("⚠️ Forced exit")); process.exit(1); }, 5000);
676
250
  try {
677
- // 1. Stop accepting new connections
678
- server.close(() => {
679
- console.log(chalk.gray("✓ HTTP server closed"));
680
- });
681
-
682
- // 2. Close all Socket.IO connections
251
+ server.close(() => console.log(chalk.gray("✓ HTTP server closed")));
252
+ if (viteProcess) { try { viteProcess.kill(); } catch {} }
683
253
  const io = getIO();
684
- if (io) {
685
- io.emit("server:shutdown");
686
- io.close(() => {
687
- console.log(chalk.gray("✓ Socket.IO closed"));
688
- });
689
- }
690
-
691
- // 3. Wait a bit for cleanup
692
- await new Promise(resolve => setTimeout(resolve, 500));
693
-
694
- clearTimeout(forceExitTimeout);
254
+ if (io) { io.emit("server:shutdown"); io.close(() => console.log(chalk.gray("✓ Socket.IO closed"))); }
255
+ await new Promise((r) => setTimeout(r, 500));
256
+ clearTimeout(forceExit);
695
257
  console.log(chalk.green("✅ Server stopped cleanly"));
696
258
  process.exit(0);
697
259
  } catch (error) {
698
260
  console.error(chalk.red("❌ Error during shutdown:"), error);
699
- clearTimeout(forceExitTimeout);
261
+ clearTimeout(forceExit);
700
262
  process.exit(1);
701
263
  }
702
264
  };
703
-
704
- // Register signal handlers
265
+
705
266
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
706
267
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
707
-
708
- // Windows-specific signal
709
- if (process.platform === "win32") {
710
- process.on("SIGBREAK", () => gracefulShutdown("SIGBREAK"));
711
- }
712
-
268
+ if (process.platform === "win32") process.on("SIGBREAK", () => gracefulShutdown("SIGBREAK"));
269
+
713
270
  return server;
714
271
  }
715
272
 
716
- // Auto start if run directly
717
273
  startServer();