Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 17 additions & 53 deletions src/components/CredentialsHandoffOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useT } from "@/lib/i18n";
import ReconnectStage from "./ReconnectStage";
import { imgProbe } from "@/lib/handoff-probe";
import { useReconnect } from "@/hooks/useReconnect";

interface CredentialsHandoffOverlayProps {
/** Full setup URL (…/setup) to probe and, when the address changed, redirect to. */
Expand All @@ -26,8 +26,6 @@ interface CredentialsHandoffOverlayProps {
graceMs?: number;
}

type Phase = "applying" | "waiting" | "done";

/**
* Full-screen overlay for the Step 3 → 4 handoff. Saving new credentials can
* restart the setup hotspot (and optionally rename the device), which drops
Expand All @@ -44,22 +42,13 @@ export default function CredentialsHandoffOverlay({
graceMs = 4000,
}: CredentialsHandoffOverlayProps) {
const { t } = useT();
const [phase, setPhase] = useState<Phase>("applying");

// onContinue is typically an inline arrow from the parent, so its identity
// changes each render — keep it in a ref so the probe loop isn't restarted.
const onContinueRef = useRef(onContinue);
useEffect(() => {
onContinueRef.current = onContinue;
}, [onContinue]);

useEffect(() => {
let cancelled = false;
let loopTimer: ReturnType<typeof setTimeout> | null = null;

// Same origin → fetch HEAD works. A 405 (method not allowed) still proves
// the server is up, so treat any response as reachable.
async function fetchProbe(): Promise<boolean> {
const phase = useReconnect({
// Same origin → a HEAD fetch works; a 405 (method not allowed) still proves
// the server is up, so treat any 2xx–4xx as reachable. Renamed device →
// cross-origin, so <img>-probe the new origin (fetch is CORS-blocked).
probe: async (attempt) => {
if (!sameOrigin) return imgProbe(targetUrl.replace(/\/setup\/?$/, ""), attempt);
try {
const res = await fetch(targetUrl, {
method: "HEAD",
Expand All @@ -70,42 +59,17 @@ export default function CredentialsHandoffOverlay({
} catch {
return false;
}
}

const graceTimer = setTimeout(() => {
if (cancelled) return;
setPhase("waiting");
let attempt = 0;
const loop = async () => {
if (cancelled) return;
attempt += 1;
const reachable = sameOrigin
? await fetchProbe()
: await imgProbe(targetUrl.replace(/\/setup\/?$/, ""), attempt);
if (cancelled) return;
if (reachable) {
setPhase("done");
setTimeout(() => {
if (cancelled) return;
if (sameOrigin) onContinueRef.current();
else window.location.replace(targetUrl);
}, 1600);
return;
}
loopTimer = setTimeout(loop, 2500);
};
loop();
}, graceMs);

return () => {
cancelled = true;
clearTimeout(graceTimer);
if (loopTimer) clearTimeout(loopTimer);
};
}, [targetUrl, sameOrigin, graceMs]);
},
onReady: () => {
if (sameOrigin) onContinue();
else window.location.replace(targetUrl);
},
graceMs,
readyDelayMs: 1600,
});

const completed = phase === "done";
const phaseIndex = phase === "applying" ? 0 : phase === "waiting" ? 1 : 2;
const completed = phase === "ready";
const phaseIndex = phase === "grace" ? 0 : phase === "probing" ? 1 : 2;

let prettyUrl = targetUrl;
try {
Expand Down
65 changes: 25 additions & 40 deletions src/components/ReconnectingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useEffect, useState } from "react";
import { useT } from "@/lib/i18n";
import ReconnectStage from "./ReconnectStage";
import { useReconnect } from "@/hooks/useReconnect";

interface ReconnectingOverlayProps {
/**
Expand All @@ -23,8 +23,6 @@ interface ReconnectingOverlayProps {
graceMs?: number;
}

type Phase = "restarting" | "reconnecting" | "done";

/**
* Full-screen overlay shown while the device restarts and the browser's
* connection drops on the SAME network (manual restart, or the reboot inside a
Expand All @@ -41,44 +39,31 @@ export default function ReconnectingOverlay({
graceMs = 4000,
}: ReconnectingOverlayProps) {
const { t } = useT();
const [phase, setPhase] = useState<Phase>("restarting");

useEffect(() => {
let cancelled = false;
let pollId: ReturnType<typeof setInterval> | null = null;

const graceTimer = setTimeout(() => {
if (cancelled) return;
setPhase("reconnecting");
pollId = setInterval(async () => {
try {
const res = await fetch(healthUrl, {
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
if (cancelled || !res.ok) return;
if (pollId) clearInterval(pollId);
setPhase("done");
setTimeout(() => {
if (cancelled) return;
if (redirectTo) window.location.replace(redirectTo);
else window.location.reload();
}, 1600);
} catch {
/* device still offline — keep looping */
}
}, 2500);
}, graceMs);

return () => {
cancelled = true;
clearTimeout(graceTimer);
if (pollId) clearInterval(pollId);
};
}, [healthUrl, redirectTo, graceMs]);
// Same-network restart → poll the health endpoint until it answers OK, then
// reload in place (or redirect). A thrown fetch / non-OK just keeps looping.
const phase = useReconnect({
probe: async () => {
try {
const res = await fetch(healthUrl, {
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
return res.ok;
} catch {
return false;
}
},
onReady: () => {
if (redirectTo) window.location.replace(redirectTo);
else window.location.reload();
},
graceMs,
readyDelayMs: 1600,
});

const completed = phase === "done";
const phaseIndex = phase === "restarting" ? 0 : phase === "reconnecting" ? 1 : 2;
const completed = phase === "ready";
const phaseIndex = phase === "grace" ? 0 : phase === "probing" ? 1 : 2;

return (
<ReconnectStage
Expand All @@ -88,7 +73,7 @@ export default function ReconnectingOverlay({
title={
completed
? t("settings.backOnline")
: phase === "reconnecting"
: phase === "probing"
? t("settings.reconnecting")
: t("wizard.restarting")
}
Expand Down
40 changes: 22 additions & 18 deletions src/components/SettingsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { UpdateState } from "@/lib/updater";
import { RESTART_STEP_ID } from "@/lib/update-constants";
import { cleanVersion } from "@/lib/version-utils";
import { CLAWBOX_AI_TIER_LABEL, normalizeClawboxAiTier } from "@/lib/clawbox-ai-models";
import { useReconnect } from "@/hooks/useReconnect";
import { PORTAL_DASHBOARD_URL } from "@/lib/max-subscription";

/* ── Types ── */
Expand Down Expand Up @@ -733,31 +734,34 @@ export default function SettingsApp({ ui }: SettingsAppProps) {
setSysPasswordSaving(false);
}
};
useEffect(() => {
if (!hostnameRebootTo) return;
let cancelled = false;
const redirect = () => { if (!cancelled) window.location.replace(hostnameRebootTo); };
const probe = async () => {
if (cancelled) return;
// After a device rename the box reboots and reappears at a new .local origin;
// poll it, then redirect there — with a hard fallback so we never hang if the
// cross-origin probe is unreliable. Same grace/poll/settle engine as the
// setup handoff overlays (see useReconnect).
useReconnect({
enabled: !!hostnameRebootTo,
// no-cors: the response is opaque so we can't inspect status, but any
// fulfilled fetch means TCP+HTTP completed — enough signal the box is back.
probe: async () => {
try {
// no-cors: response is opaque so we can't inspect status. Any
// fulfilled fetch means TCP+HTTP completed, which is enough signal
// that the device is back — redirect deliberately on any success.
await fetch(`${hostnameRebootTo}setup-api/setup/status`, {
method: "GET",
mode: "no-cors",
cache: "no-store",
signal: AbortSignal.timeout(REBOOT_PROBE_TIMEOUT_MS),
});
redirect();
return;
} catch { /* not back yet */ }
if (!cancelled) setTimeout(probe, REBOOT_PROBE_INTERVAL_MS);
};
const probeStart = setTimeout(probe, REBOOT_PROBE_GRACE_MS);
const hardRedirect = setTimeout(redirect, REBOOT_HARD_REDIRECT_MS);
return () => { cancelled = true; clearTimeout(probeStart); clearTimeout(hardRedirect); };
}, [hostnameRebootTo]);
return true;
} catch {
return false;
}
},
onReady: () => {
if (hostnameRebootTo) window.location.replace(hostnameRebootTo);
},
graceMs: REBOOT_PROBE_GRACE_MS,
intervalMs: REBOOT_PROBE_INTERVAL_MS,
hardTimeoutMs: REBOOT_HARD_REDIRECT_MS,
});
const localUrl = hostname ? `${hostname}.local` : "";
const proto = typeof window !== "undefined" ? window.location.protocol : "http:";
const port = typeof window !== "undefined" && window.location.port ? `:${window.location.port}` : "";
Expand Down
50 changes: 13 additions & 37 deletions src/components/WifiHandoffOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"use client";

import { useEffect, useState } from "react";
import { useT } from "@/lib/i18n";
import ReconnectStage from "./ReconnectStage";
import { imgProbe } from "@/lib/handoff-probe";
import { useReconnect } from "@/hooks/useReconnect";

interface WifiHandoffOverlayProps {
/** The network the box is joining — shown in the copy. */
Expand All @@ -14,8 +14,6 @@ interface WifiHandoffOverlayProps {
graceMs?: number;
}

type Phase = "switching" | "waiting" | "found";

/**
* Full-screen overlay for the WiFi network-switch handoff (setup Step 1→2).
*
Expand All @@ -29,41 +27,19 @@ type Phase = "switching" | "waiting" | "found";
*/
export default function WifiHandoffOverlay({ ssid, targetUrl, graceMs = 4000 }: WifiHandoffOverlayProps) {
const { t } = useT();
const [phase, setPhase] = useState<Phase>("switching");

useEffect(() => {
let cancelled = false;
let loopTimer: ReturnType<typeof setTimeout> | null = null;

const graceTimer = setTimeout(() => {
if (cancelled) return;
setPhase("waiting");
let attempt = 0;
const loop = async () => {
if (cancelled) return;
attempt += 1;
const reachable = await imgProbe(targetUrl, attempt);
if (cancelled) return;
if (reachable) {
setPhase("found");
setTimeout(() => {
if (!cancelled) window.location.href = `${targetUrl}/setup`;
}, 1500);
return;
}
loopTimer = setTimeout(loop, 2500);
};
loop();
}, graceMs);

return () => {
cancelled = true;
clearTimeout(graceTimer);
if (loopTimer) clearTimeout(loopTimer);
};
}, [targetUrl, graceMs]);

const completed = phase === "found";
// Cross-origin <img> probe (the box reappears at a new address a fetch can't
// reach), then redirect to its setup page on the home network.
const phase = useReconnect({
probe: (attempt) => imgProbe(targetUrl, attempt),
onReady: () => {
window.location.href = `${targetUrl}/setup`;
},
graceMs,
readyDelayMs: 1500,
});

const completed = phase === "ready";
const phaseIndex = completed ? 1 : 0;

return (
Expand Down
Loading
Loading