9remote 2.0.1 → 2.0.2
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/cli/index.js +67 -25
- package/cli/utils/tui.js +24 -5
- package/dist/cli.cjs +1 -1
- package/dist/ptyDaemon.cjs +1 -1
- package/dist/server.cjs +1 -1
- package/dist/ui/assets/index-BWfJSBGG.js +8 -0
- package/dist/ui/index.html +1 -1
- package/index.js +3 -1
- package/lib/deviceApproval.js +36 -0
- package/lib/socketio.js +15 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-njTKNAa6.js +0 -8
package/cli/index.js
CHANGED
|
@@ -588,6 +588,24 @@ async function tuiMode() {
|
|
|
588
588
|
const MAX_LOG_LINES = 200;
|
|
589
589
|
|
|
590
590
|
let deviceApprovalBusy = false;
|
|
591
|
+
|
|
592
|
+
// Skip redraws while device approval prompt is active — otherwise SSE-driven
|
|
593
|
+
// menu repaints (state/permissions/log) would clear the screen and overwrite
|
|
594
|
+
// the prompt, making it invisible to the user.
|
|
595
|
+
const safeRedraw = () => { if (!deviceApprovalBusy) triggerMenuRedraw?.(); };
|
|
596
|
+
|
|
597
|
+
// Handle pending approval — shared by SSE event and fallback poll.
|
|
598
|
+
// Fallback recovers from missed SSE events (reconnect, TUI in submenu, etc.)
|
|
599
|
+
const handlePendingApproval = async (socketId, deviceId, ip) => {
|
|
600
|
+
if (deviceApprovalBusy) return;
|
|
601
|
+
deviceApprovalBusy = true;
|
|
602
|
+
const approved = await showDeviceApproval(deviceId, ip);
|
|
603
|
+
const endpoint = approved ? "approve" : "reject";
|
|
604
|
+
await apiPost(`/api/device/${endpoint}`, { socketId });
|
|
605
|
+
deviceApprovalBusy = false;
|
|
606
|
+
triggerMenuRedraw?.();
|
|
607
|
+
};
|
|
608
|
+
|
|
591
609
|
const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
|
|
592
610
|
if (type === "log" && data.message) {
|
|
593
611
|
logBuffer.push(data.message);
|
|
@@ -604,26 +622,28 @@ async function tuiMode() {
|
|
|
604
622
|
currentTunnelUrl = newTunnel;
|
|
605
623
|
if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
|
|
606
624
|
menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
|
|
607
|
-
|
|
625
|
+
safeRedraw();
|
|
608
626
|
}
|
|
609
627
|
} else if (type === "permissions") {
|
|
610
628
|
// desktopEnabled or permission values changed — refresh both main menu and active submenu
|
|
611
|
-
activeSubmenuRefresh?.();
|
|
612
|
-
|
|
629
|
+
if (!deviceApprovalBusy) activeSubmenuRefresh?.();
|
|
630
|
+
safeRedraw();
|
|
613
631
|
} else if (type === "deviceApproval" && data.action === "pending") {
|
|
614
|
-
|
|
615
|
-
deviceApprovalBusy = true;
|
|
616
|
-
const approved = await showDeviceApproval(data.deviceId, data.ip);
|
|
617
|
-
const endpoint = approved ? "approve" : "reject";
|
|
618
|
-
await apiPost(`/api/device/${endpoint}`, { socketId: data.socketId });
|
|
619
|
-
deviceApprovalBusy = false;
|
|
620
|
-
triggerMenuRedraw?.();
|
|
632
|
+
await handlePendingApproval(data.socketId, data.deviceId, data.ip);
|
|
621
633
|
}
|
|
622
634
|
});
|
|
623
635
|
|
|
636
|
+
// Fallback poll: recover from missed SSE pending events
|
|
637
|
+
const pendingPoll = setInterval(async () => {
|
|
638
|
+
if (deviceApprovalBusy) return;
|
|
639
|
+
const d = await apiGet("/api/device/pending");
|
|
640
|
+
const first = d?.pending?.[0];
|
|
641
|
+
if (first) await handlePendingApproval(first.socketId, first.deviceId, first.ip);
|
|
642
|
+
}, 3000);
|
|
643
|
+
|
|
624
644
|
setupExitHandler({
|
|
625
645
|
getProcess: tuiServerMgr.getProcess,
|
|
626
|
-
shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); }
|
|
646
|
+
shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); clearInterval(pendingPoll); }
|
|
627
647
|
}, tunnelProcess, keyData.key);
|
|
628
648
|
|
|
629
649
|
// Handle web UI Start/Stop commands while TUI is running
|
|
@@ -632,6 +652,7 @@ async function tuiMode() {
|
|
|
632
652
|
|
|
633
653
|
const onShutdown = () => {
|
|
634
654
|
try { stopSSE(); } catch {}
|
|
655
|
+
try { clearInterval(pendingPoll); } catch {}
|
|
635
656
|
shutdownAll({
|
|
636
657
|
serverManager: tuiServerMgr,
|
|
637
658
|
tunnelProcess,
|
|
@@ -650,8 +671,15 @@ async function tuiMode() {
|
|
|
650
671
|
}
|
|
651
672
|
|
|
652
673
|
async function fetchServerState() {
|
|
653
|
-
const d = await
|
|
654
|
-
|
|
674
|
+
const [d, a] = await Promise.all([
|
|
675
|
+
apiGet("/api/ui/state"),
|
|
676
|
+
apiGet("/api/device/auto-approve"),
|
|
677
|
+
]);
|
|
678
|
+
return {
|
|
679
|
+
desktopEnabled: !!d?.desktopEnabled,
|
|
680
|
+
remoteAvailable: !!d?.remoteAvailable,
|
|
681
|
+
autoApprove: !!a?.enabled,
|
|
682
|
+
};
|
|
655
683
|
}
|
|
656
684
|
|
|
657
685
|
/**
|
|
@@ -664,7 +692,7 @@ async function fetchServerState() {
|
|
|
664
692
|
*/
|
|
665
693
|
async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null, logBuffer = []) {
|
|
666
694
|
while (true) {
|
|
667
|
-
const { desktopEnabled: desktopOn, remoteAvailable } = await fetchServerState();
|
|
695
|
+
const { desktopEnabled: desktopOn, remoteAvailable, autoApprove } = await fetchServerState();
|
|
668
696
|
|
|
669
697
|
const items = [
|
|
670
698
|
{ label: "Open Web UI", action: "webui" },
|
|
@@ -675,8 +703,9 @@ async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader =
|
|
|
675
703
|
const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ▶`;
|
|
676
704
|
items.push({ label: desktopLabel, action: "desktop" });
|
|
677
705
|
}
|
|
706
|
+
const autoLabel = autoApprove ? chalk.green("ON") : chalk.gray("OFF");
|
|
678
707
|
items.push(
|
|
679
|
-
{ label:
|
|
708
|
+
{ label: `Manage Devices \u25b6 ${chalk.dim("(Auto-approve:")} ${autoLabel}${chalk.dim(")")}`, action: "devices" },
|
|
680
709
|
{ label: `View Logs (${logBuffer.length})`, action: "logs" },
|
|
681
710
|
{ label: chalk.gray("Exit"), action: "exit" },
|
|
682
711
|
);
|
|
@@ -800,27 +829,40 @@ async function tuiDesktopMenu() {
|
|
|
800
829
|
|
|
801
830
|
async function tuiDevicesMenu() {
|
|
802
831
|
while (true) {
|
|
803
|
-
const
|
|
804
|
-
|
|
832
|
+
const [approvedData, autoData] = await Promise.all([
|
|
833
|
+
apiGet("/api/device/approved"),
|
|
834
|
+
apiGet("/api/device/auto-approve"),
|
|
835
|
+
]);
|
|
836
|
+
const devices = approvedData?.devices || [];
|
|
837
|
+
const autoOn = !!autoData?.enabled;
|
|
805
838
|
|
|
839
|
+
const toggleLabel = `Auto-approve new devices: ${autoOn ? chalk.green("ON") : chalk.gray("OFF")}`;
|
|
806
840
|
const items = [
|
|
841
|
+
{ label: toggleLabel, action: "toggle" },
|
|
807
842
|
...devices.map((d) => {
|
|
808
843
|
const short = d.deviceId.slice(0, 8);
|
|
809
844
|
const date = d.approvedAt ? new Date(d.approvedAt).toLocaleString() : "unknown";
|
|
810
|
-
return { label: `${short}... ${chalk.dim(date)}
|
|
845
|
+
return { label: `${short}... ${chalk.dim(date)}`, action: "remove", deviceId: d.deviceId };
|
|
811
846
|
}),
|
|
812
|
-
{ label: chalk.gray("\u2190 Back") },
|
|
847
|
+
{ label: chalk.gray("\u2190 Back"), action: "back" },
|
|
813
848
|
];
|
|
814
849
|
|
|
815
850
|
const title = `Approved Devices (${devices.length})`;
|
|
816
|
-
const idx = await selectMenu(title, items,
|
|
851
|
+
const idx = await selectMenu(title, items, 0);
|
|
817
852
|
|
|
818
|
-
if (idx === -1
|
|
853
|
+
if (idx === -1) return; // ESC
|
|
854
|
+
const sel = items[idx];
|
|
855
|
+
if (sel.action === "back") return;
|
|
819
856
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
857
|
+
if (sel.action === "toggle") {
|
|
858
|
+
await apiPost("/api/device/auto-approve", { enabled: !autoOn });
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (sel.action === "remove") {
|
|
863
|
+
const confirmed = await tuiConfirm(chalk.yellow(`Remove device ${sel.deviceId.slice(0, 8)}...?`));
|
|
864
|
+
if (confirmed) await apiPost("/api/device/remove", { deviceId: sel.deviceId });
|
|
865
|
+
}
|
|
824
866
|
}
|
|
825
867
|
}
|
|
826
868
|
|
package/cli/utils/tui.js
CHANGED
|
@@ -212,8 +212,8 @@ export function resetProgress() {
|
|
|
212
212
|
* @param {Array<{label: string}>} items
|
|
213
213
|
* @param {number} defaultIndex
|
|
214
214
|
* @param {string} headerContent — pre-built string shown above menu
|
|
215
|
-
* @param {(setRedraw: () => void) => void} onRedrawInit — receive
|
|
216
|
-
* @returns {Promise<number>} selected index, -1 on ESC
|
|
215
|
+
* @param {(setRedraw: () => void, forceExit?: () => void) => void} onRedrawInit — receive redraw + forceExit triggers (for SSE updates / external prompts)
|
|
216
|
+
* @returns {Promise<number>} selected index, -1 on ESC, -2 on forceExit (caller should re-render)
|
|
217
217
|
*/
|
|
218
218
|
export function selectMenu(title, items, defaultIndex = 0, headerContent = "", onRedrawInit = null, onCtrlC = null) {
|
|
219
219
|
return new Promise((resolve) => {
|
|
@@ -281,8 +281,16 @@ export function selectMenu(title, items, defaultIndex = 0, headerContent = "", o
|
|
|
281
281
|
process.stdin.resume();
|
|
282
282
|
renderMenu();
|
|
283
283
|
|
|
284
|
+
// Allow external code to force-exit this menu (e.g. to show a prompt that needs stdin).
|
|
285
|
+
// Resolves with -2 so caller knows to re-render/restart the menu with a fresh stdin state.
|
|
286
|
+
const forceExit = () => {
|
|
287
|
+
if (!isActive) return;
|
|
288
|
+
cleanup();
|
|
289
|
+
resolve(-2);
|
|
290
|
+
};
|
|
291
|
+
|
|
284
292
|
// Allow external code (SSE) to trigger a re-render without disrupting navigation
|
|
285
|
-
if (onRedrawInit) onRedrawInit(renderMenu);
|
|
293
|
+
if (onRedrawInit) onRedrawInit(renderMenu, forceExit);
|
|
286
294
|
});
|
|
287
295
|
}
|
|
288
296
|
|
|
@@ -333,7 +341,9 @@ export function showDeviceApproval(deviceId, ip) {
|
|
|
333
341
|
const shortId = deviceId ? deviceId.slice(0, 8) : "unknown";
|
|
334
342
|
const w = W();
|
|
335
343
|
|
|
336
|
-
//
|
|
344
|
+
// Save existing keypress listeners (e.g. selectMenu's) so we can restore
|
|
345
|
+
// them after the prompt — otherwise the caller's menu loses arrow-key input.
|
|
346
|
+
const savedListeners = process.stdin.listeners("keypress").slice();
|
|
337
347
|
process.stdin.removeAllListeners("keypress");
|
|
338
348
|
if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
|
|
339
349
|
process.stdin.pause();
|
|
@@ -356,6 +366,15 @@ export function showDeviceApproval(deviceId, ip) {
|
|
|
356
366
|
if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
|
|
357
367
|
process.stdin.resume();
|
|
358
368
|
|
|
369
|
+
const restoreListeners = () => {
|
|
370
|
+
for (const l of savedListeners) process.stdin.on("keypress", l);
|
|
371
|
+
if (savedListeners.length > 0) {
|
|
372
|
+
// Previous owner (selectMenu) was in raw mode + resumed stdin.
|
|
373
|
+
if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
|
|
374
|
+
process.stdin.resume();
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
359
378
|
const onKeypress = (str, key) => {
|
|
360
379
|
if (!key) return;
|
|
361
380
|
const ch = (key.name || "").toLowerCase();
|
|
@@ -370,7 +389,7 @@ export function showDeviceApproval(deviceId, ip) {
|
|
|
370
389
|
console.log(approved
|
|
371
390
|
? `\n ${C.green}\u2713 Device approved${C.reset}`
|
|
372
391
|
: `\n ${C.red}\u2717 Device rejected${C.reset}`);
|
|
373
|
-
setTimeout(() => resolve(approved), 500);
|
|
392
|
+
setTimeout(() => { restoreListeners(); resolve(approved); }, 500);
|
|
374
393
|
}
|
|
375
394
|
};
|
|
376
395
|
|