feat(remote): real QR codes, live tailscale URL, TUI status & shortcut
* Replace placeholder /remote/qr SVG (URL drawn as text) with real QR rendered via the qrcode package; add format=terminal returning ASCII QR for the TUI. * Resolve the public tailscale funnel URL from captured CLI output instead of constructing http://<hostname>:<port> from a configured hostname label — that label was never used by `tailscale funnel` and produced a non-public URL in the auth/QR link. * Drop hostname requirement from engine + UI; only target port matters. * Tighten tailscale parseReadiness to require a URL on the matched line so the tunnel manager doesn't lock in `running` before the URL line. * TUI: poll remote status, show ● tunnel indicator + URL in MainHeader, bind Ctrl+Q to a global QR overlay (terminal ASCII), and switch the in-Settings K shortcut to render the same ASCII QR. * Auto-poll remote status in the dashboard while in `starting`/`stopping` so the UI flips to running without reopening the modal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -479,7 +479,6 @@ describe("Settings view", () => {
|
||||
await waitForFrameContains(lastFrame, "Short-lived expires:", 6000);
|
||||
|
||||
stdin.write("K");
|
||||
await waitForFrameContains(lastFrame, "QR text payload:", 6000);
|
||||
await waitForFrameContains(lastFrame, "ASCII-QR-PAYLOAD", 6000);
|
||||
unmount();
|
||||
});
|
||||
@@ -541,7 +540,7 @@ describe("Settings view", () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders SVG QR fallback instruction", async () => {
|
||||
it("renders ASCII QR payload when terminal format is returned", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
controller.setInteractiveData(makeInteractiveData({
|
||||
@@ -549,8 +548,8 @@ describe("Settings view", () => {
|
||||
getQrPayload: async () => ({
|
||||
url: "https://remote.example.com?token=svg",
|
||||
expiresAt: new Date().toISOString(),
|
||||
format: "image/svg",
|
||||
data: "<svg/>",
|
||||
format: "terminal",
|
||||
data: "▀▀▀ASCII-QR▀▀▀",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -561,7 +560,7 @@ describe("Settings view", () => {
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
stdin.write("K");
|
||||
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
|
||||
await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -959,6 +959,20 @@ function MainHeader({ state }: { state: DashboardState }) {
|
||||
);
|
||||
})}
|
||||
<Box flexGrow={1} />
|
||||
{state.remoteStatus?.state === "running" && (
|
||||
<Box flexShrink={0} marginRight={1}>
|
||||
<Text wrap="truncate-end" color="green" bold>● tunnel</Text>
|
||||
{state.remoteStatus.url && cols >= 100 && (
|
||||
<Text wrap="truncate-end" color="green"> {state.remoteStatus.url}</Text>
|
||||
)}
|
||||
{cols >= 80 && <Text wrap="truncate-end" dimColor> [^Q] QR</Text>}
|
||||
</Box>
|
||||
)}
|
||||
{state.remoteStatus?.state === "starting" && (
|
||||
<Box flexShrink={0} marginRight={1}>
|
||||
<Text wrap="truncate-end" color="yellow">● tunnel starting…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showHelpHint && <Box flexShrink={0}><Text wrap="truncate-end" dimColor>[?] help [q] quit</Text></Box>}
|
||||
</Box>
|
||||
);
|
||||
@@ -2308,16 +2322,11 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
|
||||
async function handleFetchRemoteQr(tokenType: "persistent" | "short-lived", ttlMs?: number) {
|
||||
if (!data?.remote) return;
|
||||
const result = await data.remote.getQrPayload(tokenType, ttlMs);
|
||||
const result = await data.remote.getQrPayload(tokenType, ttlMs, "terminal");
|
||||
setRemoteUrl(result.url);
|
||||
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : tokenType);
|
||||
if (result.format === "text") {
|
||||
setRemoteQrDisplay(result.data ?? result.url);
|
||||
setRemoteQrFallback(null);
|
||||
return;
|
||||
}
|
||||
setRemoteQrDisplay(null);
|
||||
setRemoteQrFallback("QR SVG returned by server. Open the authenticated URL on your phone/browser to continue.");
|
||||
setRemoteQrDisplay(result.data ?? result.url);
|
||||
setRemoteQrFallback(null);
|
||||
}
|
||||
|
||||
useInput((input, key) => {
|
||||
@@ -2628,7 +2637,11 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
<Text dimColor wrap="truncate-end">Short-lived expires: {new Date(shortLivedExpiresAt).toLocaleString()}</Text>
|
||||
)}
|
||||
{remoteQrDisplay && (
|
||||
<Text wrap="truncate-end">QR text payload: {remoteQrDisplay}</Text>
|
||||
<Box flexDirection="column">
|
||||
{remoteQrDisplay.split("\n").map((line, idx) => (
|
||||
<Text key={idx}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{remoteQrFallback && (
|
||||
<Text color="yellow" wrap="truncate-end">{remoteQrFallback}</Text>
|
||||
@@ -3881,19 +3894,63 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
useCallback(() => controller.getSnapshot(), [controller]),
|
||||
);
|
||||
|
||||
// Global QR overlay state — populated when the user hits Ctrl+Q on a
|
||||
// running tunnel. `loading` covers the network request; `error` surfaces
|
||||
// the message inline so the overlay never sits blank.
|
||||
const [qrOverlay, setQrOverlay] = useState<
|
||||
| { state: "loading" }
|
||||
| { state: "ready"; url: string; ascii: string; tokenType: string; expiresAt: string | null }
|
||||
| { state: "error"; message: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Global key handling
|
||||
useInput((input, key) => {
|
||||
// Quit — route through SIGINT so the dashboard's shutdown handler runs
|
||||
// (stops dev-server child process groups, engines, mesh, etc.). Calling
|
||||
// process.exit(0) directly here orphans node/vitest children spawned by
|
||||
// user-project dev servers.
|
||||
if (input === "q" || input === "Q" || (key.ctrl && input === "c")) {
|
||||
if (((input === "q" || input === "Q") && !key.ctrl) || (key.ctrl && input === "c")) {
|
||||
void controller.stop();
|
||||
exit();
|
||||
process.kill(process.pid, "SIGINT");
|
||||
return;
|
||||
}
|
||||
|
||||
// QR overlay open/close — Ctrl+Q toggles, Esc closes when open.
|
||||
if (qrOverlay && key.escape) {
|
||||
setQrOverlay(null);
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === "q") {
|
||||
if (qrOverlay) {
|
||||
setQrOverlay(null);
|
||||
return;
|
||||
}
|
||||
const remote = state.interactiveData?.remote;
|
||||
if (!remote) return;
|
||||
if (state.remoteStatus?.state !== "running") {
|
||||
setQrOverlay({ state: "error", message: "No remote tunnel is running. Start one in Settings (g)." });
|
||||
return;
|
||||
}
|
||||
setQrOverlay({ state: "loading" });
|
||||
void remote
|
||||
.getQrPayload("persistent", undefined, "terminal")
|
||||
.then((payload) => {
|
||||
setQrOverlay({
|
||||
state: "ready",
|
||||
url: payload.url,
|
||||
ascii: payload.data ?? "",
|
||||
tokenType: "persistent",
|
||||
expiresAt: payload.expiresAt,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setQrOverlay({ state: "error", message: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === "interactive" && state.interactiveInputLocked) {
|
||||
return;
|
||||
}
|
||||
@@ -4151,6 +4208,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
<HelpOverlay />
|
||||
</Box>
|
||||
)}
|
||||
{qrOverlay && (
|
||||
<Box position="absolute" marginTop={2} marginLeft={2} flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyanBright">Remote Access — Scan to connect</Text>
|
||||
{qrOverlay.state === "loading" && <Text dimColor>Generating QR…</Text>}
|
||||
{qrOverlay.state === "error" && <Text color="red">{qrOverlay.message}</Text>}
|
||||
{qrOverlay.state === "ready" && (
|
||||
<>
|
||||
{qrOverlay.ascii.split("\n").map((line, idx) => (
|
||||
<Text key={idx}>{line}</Text>
|
||||
))}
|
||||
<Text wrap="truncate-end">{qrOverlay.url}</Text>
|
||||
<Text dimColor>
|
||||
{qrOverlay.tokenType}
|
||||
{qrOverlay.expiresAt ? ` · expires ${new Date(qrOverlay.expiresAt).toLocaleString()}` : ""}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>[Esc] close</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
DashboardState,
|
||||
InteractiveData,
|
||||
InteractiveView,
|
||||
RemoteStatus,
|
||||
UpdateStatus,
|
||||
} from "./state.js";
|
||||
import { SECTION_ORDER } from "./state.js";
|
||||
@@ -129,6 +130,11 @@ export class DashboardTUI {
|
||||
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
||||
private lastCpuSampleAt = 0;
|
||||
|
||||
// Polled remote tunnel status; null until first successful fetch (or when
|
||||
// no remote API is wired up).
|
||||
private remoteStatus: RemoteStatus | null = null;
|
||||
private remoteStatusTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.logBuffer = new LogRingBuffer();
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export class DashboardTUI {
|
||||
vitestKillThreshold: this.vitestKillThreshold,
|
||||
updateStatus: this.updateStatus,
|
||||
clipboardFlash: this.clipboardFlash,
|
||||
remoteStatus: this.remoteStatus,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
@@ -365,6 +372,25 @@ export class DashboardTUI {
|
||||
setInteractiveData(data: InteractiveData): void {
|
||||
this.interactiveData = data;
|
||||
this.notify();
|
||||
this.startRemoteStatusPolling();
|
||||
}
|
||||
|
||||
private startRemoteStatusPolling(): void {
|
||||
if (this.remoteStatusTimer) return;
|
||||
const tick = async () => {
|
||||
const remote = this.interactiveData?.remote;
|
||||
if (!remote) return;
|
||||
try {
|
||||
const status = await remote.getStatus();
|
||||
const changed = JSON.stringify(this.remoteStatus) !== JSON.stringify(status);
|
||||
this.remoteStatus = status;
|
||||
if (changed) this.notify();
|
||||
} catch {
|
||||
// network/auth errors are non-fatal — leave the prior value alone
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
this.remoteStatusTimer = setInterval(() => { void tick(); }, 3000);
|
||||
}
|
||||
|
||||
setInteractiveView(view: InteractiveView): void {
|
||||
@@ -719,6 +745,11 @@ export class DashboardTUI {
|
||||
this.systemStatsTimer = null;
|
||||
}
|
||||
|
||||
if (this.remoteStatusTimer) {
|
||||
clearInterval(this.remoteStatusTimer);
|
||||
this.remoteStatusTimer = null;
|
||||
}
|
||||
|
||||
if (this.resizeListener && process.stdout && typeof process.stdout.off === "function") {
|
||||
process.stdout.off("resize", this.resizeListener);
|
||||
this.resizeListener = null;
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface RemoteTokenResult {
|
||||
export interface RemoteQrPayload {
|
||||
url: string;
|
||||
expiresAt: string | null;
|
||||
format: "text" | "image/svg";
|
||||
format: "text" | "image/svg" | "terminal";
|
||||
data?: string;
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ export interface InteractiveData {
|
||||
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
|
||||
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
|
||||
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
||||
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<RemoteQrPayload>;
|
||||
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => Promise<RemoteQrPayload>;
|
||||
};
|
||||
git: {
|
||||
getStatus: (projectPath: string) => Promise<GitStatus>;
|
||||
@@ -354,6 +354,10 @@ export interface DashboardState {
|
||||
// monotonic timestamp so the view can render "Copied!" briefly before the
|
||||
// controller clears it via setTimeout.
|
||||
clipboardFlash: { ok: boolean; at: number } | null;
|
||||
// Latest remote tunnel status, polled by the controller while
|
||||
// `interactiveData.remote` is available. Used to surface tunnel state
|
||||
// (state/url) globally in the TUI header.
|
||||
remoteStatus: RemoteStatus | null;
|
||||
}
|
||||
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||
@@ -382,5 +386,6 @@ export function createInitialState(): DashboardState {
|
||||
vitestKillThreshold: 0.9,
|
||||
updateStatus: null,
|
||||
clipboardFlash: null,
|
||||
remoteStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2205,9 +2205,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number) => {
|
||||
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => {
|
||||
const params = new URLSearchParams({ tokenType });
|
||||
if (typeof ttlMs === "number") params.set("ttlMs", String(ttlMs));
|
||||
if (format) params.set("format", format);
|
||||
const response = await fetch(`${baseUrl}/api/remote/qr?${params.toString()}`, { headers: buildAuthHeaders() });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
Reference in New Issue
Block a user