diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 346036e1..ea310689 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -43,7 +43,7 @@ import { DeleteChannel } from "./delete-channel"; import { Github, GithubSidebar } from "./github"; import { useGithub, useGithubRefresh } from "./github-cache"; import { desktop, here, titlebar } from "./desktop"; -import { host } from "./host"; +import { host, refused } from "./host"; import { type Invite, parse } from "./invite"; import { Rename } from "./layout/rename"; import { MoveChannel } from "./move-channel"; @@ -125,9 +125,31 @@ function useAnswers(): boolean | undefined { return answers; } +/** Asked again while disconnected: a helper that was down can come back with a new token. */ +function useRefused(): boolean { + const [value, setValue] = useState(false); + useEffect(() => { + if (deployed || desktop) return; + const check = () => void refused().then(setValue); + check(); + const interval = setInterval(check, 5000); + return () => clearInterval(interval); + }, []); + return value; +} + function Disconnected() { const answers = useAnswers(); + const signedOut = useRefused(); if (desktop) return <>Disconnected from Ace Helper. Open Settings → This Mac to start it.; + if (signedOut) { + return ( + <> + This browser isn't signed in to your host. Run ace open{" "} + on that Mac, or open its sign-in link again. + + ); + } if (!deployed) return <>Disconnected from your host. Reconnecting…; return ( <> diff --git a/apps/app/src/host.ts b/apps/app/src/host.ts index 1fbf5dd9..5dbcfde7 100644 --- a/apps/app/src/host.ts +++ b/apps/app/src/host.ts @@ -26,6 +26,23 @@ if (fragment.has("token")) { // main.tsx loads the app only once there is a gateway to reach. export const host = new GatewayClient(gateway!, token); +// A sign-in link opened in a tab already on this origin changes only the hash, so nothing reads it. +window.addEventListener("hashchange", () => { + if (new URLSearchParams(location.hash.slice(1)).has("token")) location.reload(); +}); + +/** + * Whether the host refuses this page's credentials, as after Ace Helper restarts with a new token. + * A browser hides a WebSocket's 401, so this asks over HTTP with what the socket sends. + */ +export async function refused(): Promise { + const response = await fetch(gateway!.replace(/^ws/, "http"), { + cache: "no-store", + ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}), + }).catch(() => undefined); + return response?.status === 401; +} + // Phones suspend backgrounded pages; reconnect as soon as the person comes back. for (const event of ["visibilitychange", "online", "pageshow"]) { window.addEventListener(event, () => {