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

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