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.
- package/README.md +179 -62
- package/cli/index.js +701 -293
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +72 -36
- package/cli/utils/permissions.js +5 -5
- package/cli/utils/pids.js +114 -0
- package/cli/utils/token.js +3 -1
- package/cli/utils/tray.js +251 -0
- package/cli/utils/tui.js +209 -34
- package/cli/utils/updateChecker.js +107 -13
- package/dist/assets/trayIcon.ico +0 -0
- package/dist/cli.cjs +1 -36
- package/dist/ptyDaemon.cjs +1 -10
- package/dist/server.cjs +2 -184
- package/dist/ui/assets/{index-BfTPkO8b.css → index-COWVKicT.css} +1 -1
- package/dist/ui/assets/index-njTKNAa6.js +8 -0
- package/dist/ui/index.html +2 -2
- package/index.js +174 -618
- package/lib/constants.js +64 -0
- package/lib/deviceApproval.js +116 -0
- package/lib/router.js +134 -0
- package/lib/socketio.js +168 -25
- package/package.json +6 -1
- package/dist/ui/assets/index-B37vtDoz.js +0 -8
|
Binary file
|
package/cli/utils/cloudflared.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
385
|
-
|
|
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
|
/**
|
package/cli/utils/permissions.js
CHANGED
|
@@ -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:
|
|
29
|
-
exec(`osascript -e 'tell application "System Events" to
|
|
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:
|
|
33
|
-
exec(`
|
|
34
|
-
{ timeout:
|
|
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
|
+
}
|
package/cli/utils/token.js
CHANGED
|
@@ -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
|
|
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, "&")
|
|
151
|
+
.replace(/</g, "<")
|
|
152
|
+
.replace(/>/g, ">")
|
|
153
|
+
.replace(/'/g, "'")
|
|
154
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|