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:
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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