fix: route sign-in links by what can actually catch the callback
`isAnthropicSignInUrl` made the container the default action for every Anthropic sign-in link, justified by "the host has nothing to catch it with". That was wrong in both directions. The host does have something -- the auth bridge -- and the container side is not a general browser at all but Playwright's dashboard, whose packages and chromium are deliberately not baked into the image. So the default pointed at the one path that is uninstalled on a fresh project, on every platform, while the path that works sat behind a switch. The decision now lives in `useSignInOpenTarget`: a live auth bridge picks the host, otherwise a container that can actually launch a browser picks the container, otherwise the host. It resolves at mount rather than when a URL arrives, so the buttons do not swap under a moving mouse, and it re-decides on `auth-bridge-changed` so flipping the switch during a hanging login takes effect. A bridge with port conflicts reads as not live; an empty `active_ports` does not, since there is nothing to bridge until the CLI binds its listener and that races the URL. Both buttons still render either way -- this changes which one leads. `sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded copy in web_terminal/terminal.html needs no matching edit. The host "Open" path also failed silently: `dismissUrlPrompt()` ran before `openUrl`, so the toast vanished and a rejected promise reached only the devtools console. Dismissal now happens on success only, leaving "In container" one click away after a failure, and the error surfaces through the same toast the container path already used. On Linux this catch will not fire for the common case -- `xdg-open` routinely exits 0 having done nothing -- so it complements the AppImage environment fix rather than replacing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
checkBrowserViewSupport,
|
||||
getAuthBridgeStatus,
|
||||
} from "../lib/tauri-commands";
|
||||
import { canOpenPageInContainerBrowser } from "../lib/browserViewSupport";
|
||||
import type {
|
||||
AuthBridgeChangedEvent,
|
||||
AuthBridgeStatus,
|
||||
PlaywrightDetection,
|
||||
} from "../lib/types";
|
||||
|
||||
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
|
||||
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
|
||||
|
||||
/** Which of the URL toast's two buttons should lead for a sign-in link. */
|
||||
export type SignInOpenTarget = "host" | "container";
|
||||
|
||||
/**
|
||||
* Whether the auth bridge can be relied on to catch a callback for this
|
||||
* project.
|
||||
*
|
||||
* Deliberately **not** gated on `active_ports` being non-empty. There is only
|
||||
* something to bridge once the CLI has bound its callback listener, and the
|
||||
* order in which that happens against the URL landing in the transcript is not
|
||||
* ours to control — requiring a port here would make the answer depend on a
|
||||
* race and flip the default button between two otherwise identical sign-ins.
|
||||
* `enabled` is the durable fact: the poller is watching, and it will mirror the
|
||||
* port the moment it appears.
|
||||
*
|
||||
* A conflict is the exception, because it is the one state where the bridge is
|
||||
* on and nevertheless *cannot* catch the callback — the host port it needed was
|
||||
* already taken. That is precisely when the container-side browser is the
|
||||
* better default, so it must not read as live.
|
||||
*/
|
||||
export function authBridgeIsLive(status: AuthBridgeStatus | null): boolean {
|
||||
if (!status || !status.enabled) return false;
|
||||
return status.conflicts.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule, as a pure function of the two things it depends on.
|
||||
*
|
||||
* Both fallbacks land on the host, for different reasons:
|
||||
*
|
||||
* - With the bridge live, the host browser is strictly better — it is the
|
||||
* user's own signed-in profile, and the callback still reaches the container.
|
||||
* - With neither available, the host is the *more likely to work* of two
|
||||
* imperfect answers, and it is the one that reports its own failure (see
|
||||
* `handleOpenUrl` in `TerminalView`). The container-side target is
|
||||
* Playwright's dashboard pane, and Playwright's browsers are not baked into
|
||||
* the image, so on a fresh project pointing there fails on every platform
|
||||
* after a several-second wait.
|
||||
*
|
||||
* Whichever way it goes, both buttons stay in the toast. This chooses which one
|
||||
* leads, never which ones exist.
|
||||
*/
|
||||
export function chooseSignInTarget(
|
||||
bridge: AuthBridgeStatus | null,
|
||||
detection: PlaywrightDetection | null,
|
||||
): SignInOpenTarget {
|
||||
if (authBridgeIsLive(bridge)) return "host";
|
||||
if (canOpenPageInContainerBrowser(detection)) return "container";
|
||||
return "host";
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a Playwright probe is reused for.
|
||||
*
|
||||
* `check_browser_view_support` is a `docker exec` running a Node probe, and
|
||||
* every terminal tab of a project would otherwise run its own on mount. Five
|
||||
* minutes is long enough that opening a handful of tabs costs one exec, and
|
||||
* short enough that pressing "Set up Playwright" in the Browser tab is
|
||||
* reflected in the default before the user has finished reading the result.
|
||||
*/
|
||||
const DETECTION_TTL_MS = 5 * 60_000;
|
||||
|
||||
const detectionCache = new Map<
|
||||
string,
|
||||
{ at: number; probe: Promise<PlaywrightDetection | null> }
|
||||
>();
|
||||
|
||||
/** The shared, rate-limited probe. Never rejects — "didn't answer" is `null`. */
|
||||
function probeBrowserSupport(projectId: string): Promise<PlaywrightDetection | null> {
|
||||
const hit = detectionCache.get(projectId);
|
||||
if (hit && Date.now() - hit.at < DETECTION_TTL_MS) return hit.probe;
|
||||
const probe = checkBrowserViewSupport(projectId).catch(() => {
|
||||
// A failure is usually a stopped container, which is a state the user
|
||||
// leaves — so it is not worth remembering for five minutes.
|
||||
detectionCache.delete(projectId);
|
||||
return null;
|
||||
});
|
||||
detectionCache.set(projectId, { at: Date.now(), probe });
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** Test seam: drops the memoized probes so a case starts from nothing. */
|
||||
export function resetBrowserSupportCache(): void {
|
||||
detectionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default action for Anthropic sign-in links in this project.
|
||||
*
|
||||
* Resolved at mount rather than when a URL arrives, on purpose: the toast has
|
||||
* two buttons side by side, and a default that settles a second after the
|
||||
* toast appears moves them under a mouse that is already travelling.
|
||||
*
|
||||
* The expensive half is only paid when it can change the answer. The bridge
|
||||
* status is host-side and cheap; the Playwright probe is a container exec, and
|
||||
* a live bridge decides the question before it is ever asked — which, with the
|
||||
* bridge now on by default, is the ordinary case.
|
||||
*/
|
||||
export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTarget {
|
||||
const [target, setTarget] = useState<SignInOpenTarget>("host");
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setTarget("host");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let bridge: AuthBridgeStatus | null = null;
|
||||
let detection: PlaywrightDetection | null = null;
|
||||
|
||||
const settle = () => {
|
||||
if (!cancelled) setTarget(chooseSignInTarget(bridge, detection));
|
||||
};
|
||||
|
||||
const consider = (next: AuthBridgeStatus) => {
|
||||
bridge = next;
|
||||
settle();
|
||||
// Only now is the container's side of it worth an exec.
|
||||
if (authBridgeIsLive(bridge)) return;
|
||||
probeBrowserSupport(projectId).then((d) => {
|
||||
if (cancelled) return;
|
||||
detection = d;
|
||||
settle();
|
||||
});
|
||||
};
|
||||
|
||||
getAuthBridgeStatus(projectId)
|
||||
.then((s) => {
|
||||
if (!cancelled) consider(s);
|
||||
})
|
||||
// Nothing to say to the user here: this only picks which button is
|
||||
// filled in, and the fallback is the one that reports its own failures.
|
||||
.catch(() => {
|
||||
if (!cancelled) consider({ enabled: false, active_ports: [], conflicts: [] });
|
||||
});
|
||||
|
||||
// The switch can be flipped *while a login is hanging* — that is the whole
|
||||
// reason `set_auth_bridge_enabled` exists outside the Config tab's save —
|
||||
// so the default has to follow it rather than reflect whatever was true
|
||||
// when this terminal was opened.
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
||||
if (event.payload.project_id !== projectId) return;
|
||||
consider(event.payload.status);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unlisten = un;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return target;
|
||||
}
|
||||
Reference in New Issue
Block a user