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 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
- triggerMenuRedraw?.();
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
- triggerMenuRedraw?.();
629
+ if (!deviceApprovalBusy) activeSubmenuRefresh?.();
630
+ safeRedraw();
613
631
  } else if (type === "deviceApproval" && data.action === "pending") {
614
- if (deviceApprovalBusy) return;
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 apiGet("/api/ui/state");
654
- return { desktopEnabled: !!d?.desktopEnabled, remoteAvailable: !!d?.remoteAvailable };
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: "Manage Devices \u25b6", action: "devices" },
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 data = await apiGet("/api/device/approved");
804
- const devices = data?.devices || [];
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, items.length - 1);
851
+ const idx = await selectMenu(title, items, 0);
817
852
 
818
- if (idx === -1 || idx === devices.length) return; // Back/ESC
853
+ if (idx === -1) return; // ESC
854
+ const sel = items[idx];
855
+ if (sel.action === "back") return;
819
856
 
820
- // Remove selected device
821
- const deviceId = devices[idx].deviceId;
822
- const confirmed = await tuiConfirm(chalk.yellow(`Remove device ${deviceId.slice(0, 8)}...?`));
823
- if (confirmed) await apiPost("/api/device/remove", { deviceId });
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 a redraw trigger fn (for SSE updates)
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
- // Fully take over stdin from selectMenu
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