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:
gsxdsm
2026-04-29 13:20:03 -07:00
parent fd7278044c
commit 256e54dfef
9 changed files with 178 additions and 31 deletions

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -677,6 +677,19 @@ export function SettingsModal({
});
}, [activeSection, loadRemoteData]);
// Poll remote status while the tunnel is starting so the UI flips to
// "running" without the user closing/reopening the modal. Stops polling
// once it reaches a terminal state.
useEffect(() => {
if (activeSection !== "remote") return;
const state = remoteStatus?.state;
if (state !== "starting" && state !== "stopping") return;
const interval = setInterval(() => {
fetchRemoteStatus(projectId).then(setRemoteStatus).catch(() => {});
}, 1000);
return () => clearInterval(interval);
}, [activeSection, projectId, remoteStatus?.state]);
// When the tunnel is running, fetch a persistent-token authenticated URL +
// QR so the user can share/scan it without digging into Advanced Settings.
useEffect(() => {
@@ -4025,8 +4038,7 @@ export function SettingsModal({
<div className="form-group remote-provider-settings">
{activeProvider === "tailscale" ? (
<>
<label htmlFor="remoteTailscaleHostname">Hostname label</label>
<input id="remoteTailscaleHostname" type="text" placeholder="tailnet label" value={String(remoteForm.remoteTailscaleHostname || (typeof window !== "undefined" ? window.location.hostname : ""))} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleHostname: e.target.value } as SettingsFormState))} />
<small>Tailscale Funnel exposes the configured port on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname configuration is needed.</small>
<label htmlFor="remoteTailscaleTargetPort">Target port</label>
<input id="remoteTailscaleTargetPort" type="number" min={1} max={65535} value={Number(remoteForm.remoteTailscaleTargetPort ?? 4040)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleTargetPort: Number(e.target.value || 4040) } as SettingsFormState))} />
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
@@ -4087,7 +4099,7 @@ export function SettingsModal({
const savePayload: Partial<RemoteSettings> = {
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname || (typeof window !== "undefined" ? window.location.hostname : "")),
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "cloudflare",

View File

@@ -1614,17 +1614,15 @@ describe("SettingsModal", () => {
await openRemoteSection();
await userEvent.click(screen.getByLabelText("Tailscale"));
expect(screen.getByLabelText("Hostname label")).toBeInTheDocument();
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
expect(screen.getByLabelText("Accept routes")).toBeInTheDocument();
expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument();
await userEvent.clear(screen.getByLabelText("Hostname label"));
await userEvent.type(screen.getByLabelText("Hostname label"), "tail-new.ts.net");
fireEvent.change(screen.getByLabelText("Target port"), { target: { value: "4242" } });
await userEvent.click(screen.getByLabelText("Cloudflare"));
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
if (!screen.queryByLabelText("Tunnel name")) {
const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement;
@@ -1857,12 +1855,12 @@ describe("SettingsModal", () => {
await openRemoteSection();
await userEvent.click(screen.getByLabelText("Tailscale"));
expect(screen.getByLabelText("Hostname label")).toBeInTheDocument();
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Cloudflare"));
expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
});
it("sets quick tunnel false when opening Cloudflare advanced details", async () => {

View File

@@ -96,9 +96,27 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
return parsed;
}
// Prefer the actual public funnel URL captured from `tailscale funnel`
// output (https://<machine>.<tailnet>.ts.net/) — that's what a remote
// device must hit. The configured hostname label is only useful as a
// fallback before the tunnel reports its URL.
const liveTunnel = tunnelUrl?.trim();
if (liveTunnel) {
try {
const parsed = new URL(liveTunnel);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed;
}
} catch {
// fall through to hostname-based fallback
}
}
const hostname = remoteAccess.providers.tailscale.hostname?.trim();
if (!hostname) {
throw new ApiError(409, "Tailscale hostname is not configured", { code: "REMOTE_URL_NOT_CONFIGURED" });
throw new ApiError(409, "Tailscale tunnel URL not yet available — start the tunnel first", {
code: "REMOTE_URL_NOT_READY",
});
}
const baseUrl = new URL(`http://${hostname}`);
@@ -605,7 +623,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
try {
const { store: scopedStore, engine } = await getProjectContext(req);
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
const format = req.query.format === "image/svg" ? "image/svg" : "text";
const formatQuery = req.query.format;
const format = formatQuery === "image/svg" ? "image/svg" : formatQuery === "terminal" ? "terminal" : "text";
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType, getCurrentTunnelUrl(engine ?? options?.engine));
if (format === "image/svg") {
const svg = await QRCode.toString(payload.loginUrl, {
@@ -617,6 +636,11 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: svg });
return;
}
if (format === "terminal") {
const ascii = await QRCode.toString(payload.loginUrl, { type: "terminal", small: true, errorCorrectionLevel: "M" });
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: ascii });
return;
}
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: payload.loginUrl });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;

View File

@@ -811,8 +811,8 @@ export class ProjectEngine {
if (!tailscale.enabled) {
return { provider, reason: "provider_not_enabled", message: "Tailscale provider is disabled" };
}
if (!tailscale.hostname?.trim() || !Number.isFinite(tailscale.targetPort) || tailscale.targetPort <= 0) {
return { provider, reason: "provider_not_configured", message: "Tailscale hostname and target port must be configured" };
if (!Number.isFinite(tailscale.targetPort) || tailscale.targetPort <= 0) {
return { provider, reason: "provider_not_configured", message: "Tailscale target port must be configured" };
}
const executable = await this.checkExecutableAvailable("tailscale");