Citrine: on-device probe diagnostics (no USB needed) (1.3.4)

Citrine is running (listening on ws://127.0.0.1:4869) but the app can't connect
and shows 0 connections — a WebView-layer block, not our logic. To diagnose
without adb/USB, the probe now returns { ok, reason } capturing which stage
failed (blocked / connect-error / timeout-no-open / opened-but-no-nostr-reply /
closed-before-open with code), and the Settings card shows a small debug readout
(probe url + reason) whenever not connected. Purely additive; no behaviour
change to routing.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 10:44:38 +07:00
parent cd3a353c8b
commit 97adfba7e0
4 changed files with 64 additions and 29 deletions

View file

@ -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": {

View file

@ -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')

View file

@ -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<boolean> {
if (!isCitrinePossible()) return Promise.resolve(false);
): Promise<CitrineProbeResult> {
if (!isCitrinePossible()) {
return Promise.resolve({ ok: false, reason: 'not-android' });
}
return new Promise<boolean>((resolve) => {
return new Promise<CitrineProbeResult>((resolve) => {
let settled = false;
let opened = false;
let ws: WebSocket | undefined;
let timer: ReturnType<typeof setTimeout> | 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})`);
};
});
}

View file

@ -490,6 +490,17 @@ function CitrineCard() {
</div>
)}
{/* 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' && (
<div className="rounded-lg bg-black/40 border border-white/10 p-2.5 font-mono text-[10px] leading-relaxed text-gray-400">
<div className="text-gray-500">debug</div>
<div>url: <span className="text-gray-300">{citrine.probeUrl}</span></div>
<div>result: <span className="text-orange-300">{citrine.reason}</span></div>
</div>
)}
{/* Privacy reassurance — always present when enabled */}
<div className="flex items-start gap-2 rounded-xl bg-black/20 border border-white/5 p-2.5">
<Shield className="w-3.5 h-3.5 text-white/40 flex-shrink-0 mt-0.5" />