diff --git a/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts new file mode 100644 index 0000000000..81e670f7a3 --- /dev/null +++ b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +/** + * Heartbeat interval (ms) for the mesh-LLM offer publisher. + * + * The Rust side stamps `expires_at = now + OFFER_TTL_SECS` (15 min) on + * each publish. We re-publish at ~1/3 of that so even a single missed + * heartbeat leaves the offer visible to consumers; only a crash that + * misses two consecutive heartbeats actually drops us out of the UI. + * + * Kept here rather than computed from a Rust constant because the value + * is fundamentally a *frontend timer* (Tauri-side has no concept of "the + * UI mounted"); we just need it to stay strictly less than the + * Rust-side TTL so the invariant holds. + */ +const HEARTBEAT_MS = 5 * 60 * 1000; + +/** + * While `enabled` is true, periodically re-invoke `mesh_publish_offer` so + * the kind:31990 event's `expires_at` stays fresh. Republishes are NIP-33 + * replaces under the same `(pubkey, d_tag)` address — consumers do not see + * a flicker; they just see the deadline advance. + * + * Errors are logged to the console and dropped on the floor — the user + * is not in front of the settings panel waiting for them, and the next + * heartbeat (or an explicit prefs change) will surface a fresh error if + * the relay is durably down. + */ +export function useMeshOfferHeartbeat(params: { + enabled: boolean; + irohRelayUrl: string | null; +}): void { + const { enabled, irohRelayUrl } = params; + + useEffect(() => { + if (!enabled || irohRelayUrl == null || irohRelayUrl === "") { + return; + } + let cancelled = false; + const tick = async () => { + if (cancelled) return; + try { + await invoke("mesh_publish_offer", { irohRelayUrl }); + } catch (e) { + // Heartbeat failure is non-fatal — log and let the next tick try. + console.warn("mesh-llm heartbeat failed:", e); + } + }; + const id = setInterval(tick, HEARTBEAT_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [enabled, irohRelayUrl]); +} diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx index ce482802f1..66594886e0 100644 --- a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Cpu, Users } from "lucide-react"; import { useMeshLlmOffers } from "@/features/settings/hooks/useMeshLlmOffers"; +import { useMeshOfferHeartbeat } from "@/features/settings/hooks/useMeshOfferHeartbeat"; import { Switch } from "@/shared/ui/switch"; import { Input } from "@/shared/ui/input"; @@ -57,11 +58,24 @@ function formatCap(value: number | null): string { export function MeshComputeSettingsCard() { const [prefs, setPrefs] = React.useState(null); const [endpoint, setEndpoint] = React.useState(null); + const [irohRelayUrl, setIrohRelayUrl] = React.useState(null); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); const { offers, error: offersError } = useMeshLlmOffers(); - // Load the persisted prefs + the iroh endpoint identity on mount. + // While the card is mounted AND the user has compute-sharing enabled, + // re-publish the offer every ~5 min so its `expires_at` doesn't lapse + // (see useMeshOfferHeartbeat for the rationale). The heartbeat hook + // no-ops when `enabled` is false or `irohRelayUrl` is missing. + useMeshOfferHeartbeat({ + enabled: prefs?.enabled === true, + irohRelayUrl, + }); + + // Load the persisted prefs + the iroh endpoint identity + the relay's + // iroh_relay_url on mount. The latter is what the heartbeat republishes + // against, so we have to know it before the heartbeat fires its first + // tick. React.useEffect(() => { let cancelled = false; (async () => { @@ -70,9 +84,20 @@ export function MeshComputeSettingsCard() { invoke("mesh_get_sharing_prefs"), invoke("mesh_get_endpoint_id"), ]); - if (!cancelled) { - setPrefs(p); - setEndpoint(e); + if (cancelled) return; + setPrefs(p); + setEndpoint(e); + // Probe the relay's iroh_relay_url so the heartbeat hook has + // something to publish against. Failures here are non-fatal — + // the user can still see/edit prefs; we just won't heartbeat. + try { + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + if (!cancelled) setIrohRelayUrl(irohUrl ?? null); + } catch (probeErr) { + console.warn("mesh-llm iroh url probe failed:", probeErr); } } catch (e) { if (!cancelled) setError(String(e)); @@ -99,6 +124,7 @@ export function MeshComputeSettingsCard() { const irohUrl = await invoke("mesh_relay_iroh_url", { relayWsUrl, }); + setIrohRelayUrl(irohUrl ?? null); if (irohUrl) { await invoke("mesh_publish_offer", { irohRelayUrl: irohUrl }); } else if (next.enabled) {