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