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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user