9remote 0.1.64 → 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.
Binary file
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import https from "https";
4
4
  import os from "os";
5
5
  import { execSync, spawn } from "child_process";
6
+ import { writePid, readPid, clearPid } from "./pids.js";
6
7
 
7
8
  // Network change detection
8
9
  let networkMonitorInterval = null;
@@ -14,7 +15,8 @@ const BINARY_NAME = "cloudflared";
14
15
  const IS_WINDOWS = os.platform() === "win32";
15
16
  const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
16
17
  const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
17
- const PID_FILE = path.join(os.homedir(), ".9remote", "cloudflared.pid");
18
+ // Legacy PID file kept for one-time cleanup of installs from older versions
19
+ const LEGACY_PID_FILE = path.join(os.homedir(), ".9remote", "cloudflared.pid");
18
20
 
19
21
  // Track intentional shutdown to suppress exit logs
20
22
  let isIntentionalShutdown = false;
@@ -64,34 +66,58 @@ function getDownloadUrl() {
64
66
  return `${GITHUB_BASE_URL}/${binaryName}`;
65
67
  }
66
68
 
69
+ // Emit progress at most every N ms to avoid render thrash
70
+ const PROGRESS_THROTTLE_MS = 150;
71
+
67
72
  /**
68
- * Download file from URL
73
+ * Download file from URL with progress tracking
74
+ * @param {string} url
75
+ * @param {string} dest
76
+ * @param {(percent: number) => void} [onProgress]
69
77
  */
70
- async function downloadFile(url, dest) {
78
+ async function downloadFile(url, dest, onProgress) {
71
79
  return new Promise((resolve, reject) => {
72
80
  const file = fs.createWriteStream(dest);
73
-
81
+
74
82
  https.get(url, (response) => {
75
83
  if ([301, 302].includes(response.statusCode)) {
76
84
  file.close();
77
85
  fs.unlinkSync(dest);
78
- downloadFile(response.headers.location, dest).then(resolve).catch(reject);
86
+ downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
79
87
  return;
80
88
  }
81
-
89
+
82
90
  if (response.statusCode !== 200) {
83
91
  file.close();
84
92
  fs.unlinkSync(dest);
85
93
  reject(new Error(`Download failed with status ${response.statusCode}`));
86
94
  return;
87
95
  }
88
-
96
+
97
+ const total = parseInt(response.headers["content-length"] || "0", 10);
98
+ let received = 0;
99
+ let lastEmit = 0;
100
+ let lastPercent = -1;
101
+
102
+ response.on("data", (chunk) => {
103
+ received += chunk.length;
104
+ if (!onProgress || !total) return;
105
+ const now = Date.now();
106
+ const percent = Math.min(100, Math.floor((received / total) * 100));
107
+ if (percent !== lastPercent && now - lastEmit >= PROGRESS_THROTTLE_MS) {
108
+ lastEmit = now;
109
+ lastPercent = percent;
110
+ onProgress(percent);
111
+ }
112
+ });
113
+
89
114
  response.pipe(file);
90
-
115
+
91
116
  file.on("finish", () => {
117
+ if (onProgress && total) onProgress(100);
92
118
  file.close(() => resolve(dest));
93
119
  });
94
-
120
+
95
121
  file.on("error", (err) => {
96
122
  file.close();
97
123
  fs.unlinkSync(dest);
@@ -107,39 +133,40 @@ async function downloadFile(url, dest) {
107
133
 
108
134
  /**
109
135
  * Ensure cloudflared binary exists
136
+ * @param {(progress: { phase: "download" | "extract", percent?: number }) => void} [onProgress]
110
137
  */
111
- export async function ensureCloudflared() {
138
+ export async function ensureCloudflared(onProgress) {
112
139
  if (!fs.existsSync(BIN_DIR)) {
113
140
  fs.mkdirSync(BIN_DIR, { recursive: true });
114
141
  }
115
-
142
+
116
143
  if (fs.existsSync(BIN_PATH)) {
117
144
  if (!IS_WINDOWS) {
118
145
  fs.chmodSync(BIN_PATH, "755");
119
146
  }
120
147
  return BIN_PATH;
121
148
  }
122
-
123
- console.log("📥 Downloading tunnel binary...");
124
-
149
+
125
150
  const url = getDownloadUrl();
126
151
  const isArchive = url.endsWith(".tgz");
127
152
  const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH;
128
-
153
+
129
154
  try {
130
- await downloadFile(url, downloadDest);
131
-
155
+ onProgress?.({ phase: "download", percent: 0 });
156
+ await downloadFile(url, downloadDest, (percent) => {
157
+ onProgress?.({ phase: "download", percent });
158
+ });
159
+
132
160
  if (isArchive) {
133
- console.log(" Extracting...");
134
- execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe" });
161
+ onProgress?.({ phase: "extract" });
162
+ execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true });
135
163
  fs.unlinkSync(downloadDest);
136
164
  }
137
-
165
+
138
166
  if (!IS_WINDOWS) {
139
167
  fs.chmodSync(BIN_PATH, "755");
140
168
  }
141
-
142
- console.log("✅ cloudflared ready");
169
+
143
170
  return BIN_PATH;
144
171
  } catch (error) {
145
172
  console.error("❌ Failed to download cloudflared:", error.message);
@@ -199,11 +226,11 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate = null) {
199
226
 
200
227
  const child = spawn(
201
228
  binaryPath,
202
- ["tunnel", "--url", `http://localhost:${localPort}`, "--config", configPath, "--no-autoupdate"],
229
+ ["tunnel", "--url", `http://localhost:${localPort}`, "--config", configPath, "--no-autoupdate", "--protocol", "http2"],
203
230
  { detached: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }
204
231
  );
205
232
 
206
- fs.writeFileSync(PID_FILE, child.pid.toString());
233
+ writePid("cloudflared", child.pid);
207
234
  isIntentionalShutdown = false;
208
235
 
209
236
  return new Promise((resolve, reject) => {
@@ -286,6 +313,7 @@ export async function spawnCloudflared(tunnelToken, onRestart = null) {
286
313
 
287
314
  const child = spawn(binaryPath, ["tunnel", "run", "--token", tunnelToken], {
288
315
  detached: false,
316
+ windowsHide: true,
289
317
  stdio: ["ignore", "pipe", "pipe"]
290
318
  });
291
319
 
@@ -360,8 +388,8 @@ export async function spawnCloudflared(tunnelToken, onRestart = null) {
360
388
  });
361
389
 
362
390
  // Save PID
363
- fs.writeFileSync(PID_FILE, child.pid.toString());
364
-
391
+ writePid("cloudflared", child.pid);
392
+
365
393
  // Start network monitor
366
394
  startNetworkMonitor();
367
395
 
@@ -372,18 +400,26 @@ export async function spawnCloudflared(tunnelToken, onRestart = null) {
372
400
  * Kill cloudflared process
373
401
  */
374
402
  export function killCloudflared() {
403
+ // Clean up legacy PID file from older installs (one-time migration)
375
404
  try {
376
- if (fs.existsSync(PID_FILE)) {
377
- isIntentionalShutdown = true;
378
- const pid = parseInt(fs.readFileSync(PID_FILE, "utf8"));
379
- // console.log(`🔄 Killing cloudflared process PID: ${pid}`);
380
- process.kill(pid);
381
- fs.unlinkSync(PID_FILE);
382
- console.log(`✅ Cloudflared killed`);
405
+ if (fs.existsSync(LEGACY_PID_FILE)) {
406
+ const legacyPid = parseInt(fs.readFileSync(LEGACY_PID_FILE, "utf8"));
407
+ if (Number.isFinite(legacyPid)) {
408
+ isIntentionalShutdown = true;
409
+ try { process.kill(legacyPid); } catch {}
410
+ }
411
+ fs.unlinkSync(LEGACY_PID_FILE);
383
412
  }
384
- } catch (error) {
385
- // console.log(`⚠️ Error killing cloudflared: ${error.message}`);
386
- }
413
+ } catch {}
414
+
415
+ const pid = readPid("cloudflared");
416
+ if (!pid) return;
417
+ isIntentionalShutdown = true;
418
+ try {
419
+ process.kill(pid);
420
+ console.log(`✅ Cloudflared killed`);
421
+ } catch {}
422
+ clearPid("cloudflared");
387
423
  }
388
424
 
389
425
  /**
@@ -25,13 +25,13 @@ export function checkPermissions() {
25
25
  let sr = false, ax = false, done = 0;
26
26
  const finish = () => { if (++done === 2) resolve({ screenRecording: sr, accessibility: ax }); };
27
27
 
28
- // Accessibility: attempt a real keystroke action fails without permission
29
- exec(`osascript -e 'tell application "System Events" to key code 0 using {}'`,
28
+ // Accessibility: reading UI elements truly requires AX permission (reflects revoke instantly)
29
+ exec(`osascript -e 'tell application "System Events" to tell process "Finder" to get name of every window'`,
30
30
  { timeout: 3000 }, (err) => { ax = !err; finish(); });
31
31
 
32
- // Screen Recording: capture 1pxfails silently without permission
33
- exec(`screencapture -x -R 0,0,1,1 /tmp/9remote_perm_check.png && rm -f /tmp/9remote_perm_check.png`,
34
- { timeout: 5000 }, (err) => { sr = !err; finish(); });
32
+ // Screen Recording: CGPreflightScreenCaptureAccess via CoreGraphics reflects TCC state in realtime
33
+ exec(`osascript -e 'use framework "CoreGraphics"' -e "return (current application's CGPreflightScreenCaptureAccess()) as boolean"`,
34
+ { timeout: 3000 }, (err, stdout) => { sr = !err && stdout.trim() === "true"; finish(); });
35
35
  });
36
36
  }
37
37
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Centralized PID management for 9remote long-lived processes.
3
+ *
4
+ * We track ONLY two PIDs — the minimum needed to safely release file locks
5
+ * during `npm i -g 9remote@latest` without killing unrelated apps on the
6
+ * machine:
7
+ *
8
+ * - agent → the CLI that holds dist/cli.cjs open. Its children
9
+ * (server node.exe + tray helper binary) die automatically
10
+ * when we kill the agent tree (`taskkill /F /T`).
11
+ * - cloudflared → spawned as a child of agent too, but tracked separately
12
+ * because it may become orphan if agent crashes, and
13
+ * because `killCloudflared()` is called at runtime for
14
+ * network-change restarts.
15
+ *
16
+ * PID files live in ~/.9remote/pids/<name>.pid. Update scripts kill by PID
17
+ * only — never by image name (taskkill /IM) or commandline match, since
18
+ * those would also terminate unrelated node.exe / cloudflared.exe processes.
19
+ */
20
+ import fs from "fs";
21
+ import path from "path";
22
+ import os from "os";
23
+ import { execSync } from "child_process";
24
+
25
+ const PIDS_DIR = path.join(os.homedir(), ".9remote", "pids");
26
+
27
+ function ensureDir() {
28
+ try {
29
+ if (!fs.existsSync(PIDS_DIR)) fs.mkdirSync(PIDS_DIR, { recursive: true });
30
+ } catch {}
31
+ }
32
+
33
+ function pidPath(name) {
34
+ return path.join(PIDS_DIR, `${name}.pid`);
35
+ }
36
+
37
+ /** Write PID to ~/.9remote/pids/<name>.pid. Silent fail. */
38
+ export function writePid(name, pid) {
39
+ if (!pid) return;
40
+ try {
41
+ ensureDir();
42
+ fs.writeFileSync(pidPath(name), String(pid));
43
+ } catch {}
44
+ }
45
+
46
+ /** Read PID from file, or null if missing/invalid. */
47
+ export function readPid(name) {
48
+ try {
49
+ const raw = fs.readFileSync(pidPath(name), "utf8").trim();
50
+ const pid = parseInt(raw, 10);
51
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Remove PID file. Silent fail. */
58
+ export function clearPid(name) {
59
+ try {
60
+ fs.unlinkSync(pidPath(name));
61
+ } catch {}
62
+ }
63
+
64
+ /** Check if a PID is still alive (does not kill). */
65
+ export function isAlive(pid) {
66
+ if (!pid) return false;
67
+ try {
68
+ // Signal 0 = existence check on all platforms
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Kill one tracked process by name. Uses SIGKILL on POSIX, `taskkill /F /PID`
78
+ * on Windows. Clears the PID file regardless of outcome.
79
+ * Returns true if a kill was attempted, false if no PID on record.
80
+ */
81
+ export function killByName(name) {
82
+ const pid = readPid(name);
83
+ clearPid(name);
84
+ if (!pid) return false;
85
+ try {
86
+ if (process.platform === "win32") {
87
+ // taskkill /T also terminates the entire child tree (needed for systray
88
+ // helper which spawns its own child, and for detached background agent).
89
+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true });
90
+ } else {
91
+ process.kill(pid, "SIGKILL");
92
+ }
93
+ } catch {}
94
+ return true;
95
+ }
96
+
97
+ /** Names of every tracked PID. Shared with update shell scripts.
98
+ * NOTE: ptyDaemon is intentionally excluded — we never kill it to keep
99
+ * terminal sessions alive across agent restarts / updates. */
100
+ export const TRACKED_NAMES = ["cloudflared", "agent"];
101
+
102
+ /** Kill every tracked 9remote process. Safe to call repeatedly. */
103
+ export function killAll() {
104
+ // Cloudflared first so it can't reconnect mid-shutdown; agent's `/F /T`
105
+ // then sweeps server + tray (direct children).
106
+ for (const name of TRACKED_NAMES) {
107
+ killByName(name);
108
+ }
109
+ }
110
+
111
+ /** Absolute path to the PIDs directory (for shell scripts). */
112
+ export function getPidsDir() {
113
+ return PIDS_DIR;
114
+ }
@@ -1,3 +1,5 @@
1
+ import { browserFetch } from "../../lib/constants.js";
2
+
1
3
  const TEMP_KEY_EXPIRY_MINUTES = 30;
2
4
 
3
5
  /**
@@ -8,7 +10,7 @@ const TEMP_KEY_EXPIRY_MINUTES = 30;
8
10
  */
9
11
  export async function createTempKey(apiKey, workerUrl) {
10
12
  try {
11
- const response = await fetch(`${workerUrl}/api/temp-key/create`, {
13
+ const response = await browserFetch(`${workerUrl}/api/temp-key/create`, {
12
14
  method: "POST",
13
15
  headers: { "Content-Type": "application/json" },
14
16
  body: JSON.stringify({
@@ -0,0 +1,251 @@
1
+ /**
2
+ * System tray module — optional, silent fail if not supported
3
+ */
4
+
5
+ import { spawn } from "child_process";
6
+ import fs from "fs";
7
+ import path from "path";
8
+ import { fileURLToPath } from "url";
9
+
10
+ let trayInstance = null;
11
+ let trayState = { port: 0, tunnelUrl: "", running: false };
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ // PNG 64x64 base64 for macOS/Linux (generated from agent/ui/public/favicon.svg)
16
+ const TRAY_ICON_PNG = "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";
17
+
18
+ // Windows requires ICO format; resolve ICO path across dev + bundled layouts
19
+ const ICO_CANDIDATES = [
20
+ path.join(__dirname, "assets", "trayIcon.ico"), // dev: agent/cli/utils/assets/
21
+ path.join(__dirname, "..", "assets", "trayIcon.ico"), // bundled: agent/dist/assets/
22
+ ];
23
+
24
+ function getIconBase64() {
25
+ if (process.platform === "win32") {
26
+ for (const p of ICO_CANDIDATES) {
27
+ try {
28
+ if (fs.existsSync(p)) return fs.readFileSync(p).toString("base64");
29
+ } catch {}
30
+ }
31
+ return TRAY_ICON_PNG;
32
+ }
33
+ return TRAY_ICON_PNG;
34
+ }
35
+
36
+ function isTraySupported() {
37
+ const platform = process.platform;
38
+ if (!["darwin", "win32", "linux"].includes(platform)) return false;
39
+ if (platform === "linux" && !process.env.DISPLAY) return false;
40
+ return true;
41
+ }
42
+
43
+ function buildTooltip() {
44
+ const { port, tunnelUrl, running } = trayState;
45
+ const status = running
46
+ ? (tunnelUrl ? "Tunnel ON" : "Local only")
47
+ : "Idle";
48
+ const url = `http://localhost:${port}`;
49
+ // Windows tray tooltip limit is ~127 chars; keep it compact but informative
50
+ return `9Remote • ${status}\nLocal: ${url}\nRight-click to open / quit`;
51
+ }
52
+
53
+ function buildMenu() {
54
+ const { port, tunnelUrl } = trayState;
55
+ const isWin = process.platform === "win32";
56
+ const statusLine = tunnelUrl
57
+ ? `9Remote (Port ${port}) • Tunnel ON`
58
+ : `9Remote (Port ${port}) • Local only`;
59
+ return {
60
+ icon: getIconBase64(),
61
+ title: isWin ? `9Remote - Port ${port}` : "",
62
+ tooltip: buildTooltip(),
63
+ items: [
64
+ { title: statusLine, tooltip: tunnelUrl || `http://localhost:${port}`, checked: false, enabled: false },
65
+ { title: "Open Web UI", tooltip: `Open http://localhost:${port} in your browser`, checked: false, enabled: true },
66
+ { title: "Shutdown", tooltip: "Stop 9Remote server, tunnel and quit", checked: false, enabled: true },
67
+ ],
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Initialize system tray
73
+ * @param {{ port: number, onQuit: () => void, onOpenUI: () => void }} options
74
+ */
75
+ export async function initTray({ port, onQuit, onOpenUI }) {
76
+ if (!isTraySupported()) return null;
77
+
78
+ try {
79
+ const mod = await import("systray");
80
+ const SysTray = mod.default?.default || mod.default;
81
+
82
+ trayState = { port, tunnelUrl: "", running: true };
83
+
84
+ trayInstance = new SysTray({ menu: buildMenu(), debug: false, copyDir: true });
85
+ // Tray helper is a direct child of agent; no separate PID file needed.
86
+ // `taskkill /F /T /PID <agent>` at update time terminates it along with
87
+ // the agent tree — safer than matching by image name.
88
+
89
+ trayInstance.onClick((action) => {
90
+ if (action.item.title === "Open Web UI") {
91
+ onOpenUI?.();
92
+ } else if (action.item.title === "Shutdown") {
93
+ onQuit?.();
94
+ killTray();
95
+ setTimeout(() => process.exit(0), 500);
96
+ }
97
+ });
98
+
99
+ trayInstance.onReady(() => {});
100
+ trayInstance.onError(() => {});
101
+
102
+ return trayInstance;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Update tray tooltip / status so the user knows current tunnel state.
110
+ * Safe to call before tray is ready — values are captured for next refresh.
111
+ */
112
+ export function updateTrayTooltip({ tunnelUrl, running } = {}) {
113
+ if (tunnelUrl !== undefined) trayState.tunnelUrl = tunnelUrl;
114
+ if (running !== undefined) trayState.running = running;
115
+ if (!trayInstance) return;
116
+ try {
117
+ trayInstance.sendAction({
118
+ type: "update-menu",
119
+ menu: buildMenu(),
120
+ seq_id: -1,
121
+ });
122
+ } catch {}
123
+ }
124
+
125
+ export function killTray() {
126
+ const instance = trayInstance;
127
+ trayInstance = null;
128
+ if (instance) {
129
+ try { instance.kill(true); } catch {}
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Show a native OS notification (balloon tip on Windows, toast on macOS/Linux).
135
+ * Silent fail if the platform tool isn't available.
136
+ * @param {{ title?: string, message: string }} opts
137
+ */
138
+ export function showTrayNotification({ title = "9Remote", message }) {
139
+ const platform = process.platform;
140
+ if (!message) return;
141
+
142
+ if (platform === "win32") {
143
+ // Prefer Windows 10/11 Toast Notification (WinRT) so the popup actually
144
+ // appears on modern Windows (ShowBalloonTip is effectively deprecated and
145
+ // only lands silently in Action Center on Win10+). Falls back to the
146
+ // classic NotifyIcon balloon tip if WinRT is unavailable (older Windows,
147
+ // Server Core, constrained PowerShell).
148
+ const esc = (s) =>
149
+ String(s)
150
+ .replace(/&/g, "&amp;")
151
+ .replace(/</g, "&lt;")
152
+ .replace(/>/g, "&gt;")
153
+ .replace(/'/g, "&apos;")
154
+ .replace(/"/g, "&quot;");
155
+ const escPs = (s) => String(s).replace(/'/g, "''");
156
+ const appId = "9Remote";
157
+ const toastXml =
158
+ `<toast><visual><binding template="ToastText02">` +
159
+ `<text id="1">${esc(title)}</text>` +
160
+ `<text id="2">${esc(message)}</text>` +
161
+ `</binding></visual></toast>`;
162
+ const ps = [
163
+ `try {`,
164
+ `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null;`,
165
+ `[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] > $null;`,
166
+ `$xml = New-Object Windows.Data.Xml.Dom.XmlDocument;`,
167
+ `$xml.LoadXml('${escPs(toastXml)}');`,
168
+ `$toast = [Windows.UI.Notifications.ToastNotification]::new($xml);`,
169
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${escPs(appId)}').Show($toast);`,
170
+ `} catch {`,
171
+ `Add-Type -AssemblyName System.Windows.Forms;`,
172
+ `$n = New-Object System.Windows.Forms.NotifyIcon;`,
173
+ `$n.Icon = [System.Drawing.SystemIcons]::Information;`,
174
+ `$n.BalloonTipTitle = '${escPs(title)}';`,
175
+ `$n.BalloonTipText = '${escPs(message)}';`,
176
+ `$n.Visible = $true;`,
177
+ `$n.ShowBalloonTip(5000);`,
178
+ `Start-Sleep -Seconds 6;`,
179
+ `$n.Dispose();`,
180
+ `}`,
181
+ ].join(" ");
182
+ try {
183
+ const child = spawn(
184
+ "powershell.exe",
185
+ ["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps],
186
+ { detached: true, stdio: "ignore", windowsHide: true }
187
+ );
188
+ child.unref();
189
+ } catch {}
190
+ return;
191
+ }
192
+
193
+ if (platform === "darwin") {
194
+ try {
195
+ const esc = (s) => String(s).replace(/"/g, '\\"');
196
+ const script = `display notification "${esc(message)}" with title "${esc(title)}"`;
197
+ const child = spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" });
198
+ child.unref();
199
+ } catch {}
200
+ return;
201
+ }
202
+
203
+ // Linux / other POSIX
204
+ try {
205
+ const child = spawn("notify-send", [title, message], { detached: true, stdio: "ignore" });
206
+ child.unref();
207
+ } catch {}
208
+ }
209
+
210
+ export function openBrowser(url) {
211
+ const platform = process.platform;
212
+
213
+ // Windows: prefer `rundll32.exe` (GUI subsystem — no cmd window flash) over
214
+ // `cmd /c start` which briefly flashes a black console window even with
215
+ // `windowsHide: true`. Fallback to cmd/start if rundll32 ever fails.
216
+ if (platform === "win32") {
217
+ try {
218
+ const child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", url], {
219
+ detached: true,
220
+ stdio: "ignore",
221
+ windowsHide: true,
222
+ });
223
+ child.unref();
224
+ return;
225
+ } catch {}
226
+ // Fallback: cmd /c start "" "url"
227
+ try {
228
+ const child = spawn("cmd.exe", ["/d", "/s", "/c", "start", "", url], {
229
+ detached: true,
230
+ stdio: "ignore",
231
+ windowsHide: true,
232
+ });
233
+ child.unref();
234
+ } catch {}
235
+ return;
236
+ }
237
+
238
+ if (platform === "darwin") {
239
+ try {
240
+ const child = spawn("open", [url], { detached: true, stdio: "ignore" });
241
+ child.unref();
242
+ } catch {}
243
+ return;
244
+ }
245
+
246
+ // Linux / other POSIX
247
+ try {
248
+ const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
249
+ child.unref();
250
+ } catch {}
251
+ }