diff --git a/package.json b/package.json index 978d89d..57520cb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.3.3", + "version": "1.3.4", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/useCitrine.ts b/src/hooks/useCitrine.ts index 9a802de..7c889dd 100644 --- a/src/hooks/useCitrine.ts +++ b/src/hooks/useCitrine.ts @@ -30,7 +30,7 @@ export function useCitrine() { const possible = isCitrinePossible(); const enabled = config.useCitrineRelay ?? false; - const { data: reachable = false, refetch, isFetching, isFetched } = useQuery({ + const { data: probe, refetch, isFetching, isFetched } = useQuery({ queryKey: ['citrine-reachable', CITRINE_RELAY_URL], // Only bother probing when the platform could host Citrine AND the user has // opted in — no point connecting to localhost otherwise. @@ -42,13 +42,15 @@ export function useCitrine() { // background churn — a per-session localhost relay doesn't vanish silently, // and any failed read will naturally re-probe. This avoids opening a fresh // ws:// probe every few minutes for the whole session. - refetchInterval: (query) => (query.state.data ? false : 30_000), + refetchInterval: (query) => (query.state.data?.ok ? false : 30_000), // Re-verify on focus only until connected (e.g. user returns after starting // Citrine). No need to re-poke localhost on every Amber round-trip once up. - refetchOnWindowFocus: (query) => !query.state.data, + refetchOnWindowFocus: (query) => !query.state.data?.ok, retry: false, }); + const reachable = probe?.ok ?? false; + const active = possible && enabled && reachable; // DEV PREVIEW ONLY: a forced visual state for browser design review. Purely @@ -75,6 +77,10 @@ export function useCitrine() { * initial probe has had a chance to run. */ settled: isFetched, + /** Diagnostic reason from the last probe (shown on-device for debugging). */ + reason: probe?.reason ?? null, + /** The relay URL we probe/connect to — shown in the debug readout. */ + probeUrl: CITRINE_RELAY_URL, url: active ? CITRINE_RELAY_URL : null, /** * DEV PREVIEW ONLY — a forced UI state ('off'|'searching'|'live'|'waiting') diff --git a/src/lib/citrine.ts b/src/lib/citrine.ts index 77d917d..023a657 100644 --- a/src/lib/citrine.ts +++ b/src/lib/citrine.ts @@ -87,6 +87,13 @@ export function isCitrinePossible(): boolean { return isAndroidNative() || isCitrinePreview(); } +/** Result of a probe attempt — a boolean plus a human-readable diagnostic. */ +export interface CitrineProbeResult { + ok: boolean; + /** Short reason for the outcome, surfaced in the UI for debugging. */ + reason: string; +} + /** * Probe whether a Citrine relay is actually running AND speaking Nostr at `url`. * @@ -96,23 +103,25 @@ export function isCitrinePossible(): boolean { * open → send a tiny bounded REQ → require a protocol-shaped reply * (EVENT / EOSE / NOTICE / CLOSED) referencing our subscription id. * - * Resolves true only on a verified Nostr reply; false on error, timeout, or a - * non-Nostr response. Never rejects, never throws — safe from a query/effect. - * Short-circuits to false off-Android (and outside preview) to avoid pointless - * localhost attempts and console noise in the browser/desktop. + * Returns { ok, reason }. `reason` captures which stage failed so the UI can + * show it on-device (no USB/adb needed to diagnose). Never rejects, never + * throws. Short-circuits off-Android (and outside preview). */ export function probeCitrine( url: string = CITRINE_RELAY_URL, timeoutMs = 3000, -): Promise { - if (!isCitrinePossible()) return Promise.resolve(false); +): Promise { + if (!isCitrinePossible()) { + return Promise.resolve({ ok: false, reason: 'not-android' }); + } - return new Promise((resolve) => { + return new Promise((resolve) => { let settled = false; + let opened = false; let ws: WebSocket | undefined; let timer: ReturnType | undefined; - const done = (result: boolean) => { + const done = (ok: boolean, reason: string) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); @@ -121,36 +130,40 @@ export function probeCitrine( } catch { // ignore close errors } - resolve(result); + resolve({ ok, reason }); }; try { ws = new WebSocket(url); - } catch { - resolve(false); + } catch (err) { + // Constructor throwing usually means the WebView blocked the URL outright + // (e.g. mixed content: https page → insecure ws://). + resolve({ + ok: false, + reason: `blocked: ${err instanceof Error ? err.message : String(err)}`, + }); return; } - // Unique, throwaway subscription id we can match in the reply. const subId = `citrine-probe-${Math.random().toString(36).slice(2, 10)}`; - timer = setTimeout(() => done(false), timeoutMs); + timer = setTimeout(() => { + // Distinguish "never opened" (blocked/refused) from "opened but silent". + done(false, opened ? 'opened-but-no-nostr-reply' : 'timeout-no-open'); + }, timeoutMs); ws.onopen = () => { + opened = true; try { - // Ask for a single event of any kind — bounded and cheap. We don't care - // about the payload, only that the peer answers in the Nostr protocol. ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }])); - } catch { - done(false); + } catch (err) { + done(false, `send-failed: ${err instanceof Error ? err.message : String(err)}`); } }; ws.onmessage = (ev) => { try { const data = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); - // A Nostr relay replies with a JSON array whose first element is a known - // verb. EVENT/EOSE/CLOSED echo our subId; NOTICE is a generic message. if (Array.isArray(data) && typeof data[0] === 'string') { const verb = data[0]; const matchesSub = data[1] === subId; @@ -158,18 +171,23 @@ export function probeCitrine( ((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) || verb === 'NOTICE' ) { - done(true); + done(true, 'ok'); return; } } - // Any other shape: not a Nostr relay we recognise — keep waiting until - // timeout (a valid relay will still send EOSE). } catch { - // Non-JSON frame → not a Nostr relay; wait for timeout. + // Non-JSON frame → wait for timeout. } }; - ws.onerror = () => done(false); - ws.onclose = () => done(false); + ws.onerror = () => { + // If we never opened, this is a connection-level failure (blocked / + // refused / cleartext-not-permitted). If we had opened, it's a mid-stream + // error. + done(false, opened ? 'error-after-open' : 'connect-error (blocked/refused/cleartext)'); + }; + ws.onclose = (ev) => { + done(false, opened ? 'closed-after-open' : `closed-before-open (code ${ev.code})`); + }; }); } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ebf9f93..8ffb263 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -490,6 +490,17 @@ function CitrineCard() { )} + {/* Debug readout — shows the exact probe URL + failure reason on the + device so connectivity can be diagnosed without USB/adb. Only shown + while not connected. */} + {state !== 'live' && citrine.reason && citrine.reason !== 'not-android' && ( +
+
debug
+
url: {citrine.probeUrl}
+
result: {citrine.reason}
+
+ )} + {/* Privacy reassurance — always present when enabled */}