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:
parent
cd3a353c8b
commit
97adfba7e0
4 changed files with 64 additions and 29 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function useCitrine() {
|
||||||
const possible = isCitrinePossible();
|
const possible = isCitrinePossible();
|
||||||
const enabled = config.useCitrineRelay ?? false;
|
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],
|
queryKey: ['citrine-reachable', CITRINE_RELAY_URL],
|
||||||
// Only bother probing when the platform could host Citrine AND the user has
|
// Only bother probing when the platform could host Citrine AND the user has
|
||||||
// opted in — no point connecting to localhost otherwise.
|
// 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,
|
// background churn — a per-session localhost relay doesn't vanish silently,
|
||||||
// and any failed read will naturally re-probe. This avoids opening a fresh
|
// and any failed read will naturally re-probe. This avoids opening a fresh
|
||||||
// ws:// probe every few minutes for the whole session.
|
// 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
|
// 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.
|
// 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,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reachable = probe?.ok ?? false;
|
||||||
|
|
||||||
const active = possible && enabled && reachable;
|
const active = possible && enabled && reachable;
|
||||||
|
|
||||||
// DEV PREVIEW ONLY: a forced visual state for browser design review. Purely
|
// 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.
|
* initial probe has had a chance to run.
|
||||||
*/
|
*/
|
||||||
settled: isFetched,
|
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,
|
url: active ? CITRINE_RELAY_URL : null,
|
||||||
/**
|
/**
|
||||||
* DEV PREVIEW ONLY — a forced UI state ('off'|'searching'|'live'|'waiting')
|
* DEV PREVIEW ONLY — a forced UI state ('off'|'searching'|'live'|'waiting')
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,13 @@ export function isCitrinePossible(): boolean {
|
||||||
return isAndroidNative() || isCitrinePreview();
|
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`.
|
* 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
|
* open → send a tiny bounded REQ → require a protocol-shaped reply
|
||||||
* (EVENT / EOSE / NOTICE / CLOSED) referencing our subscription id.
|
* (EVENT / EOSE / NOTICE / CLOSED) referencing our subscription id.
|
||||||
*
|
*
|
||||||
* Resolves true only on a verified Nostr reply; false on error, timeout, or a
|
* Returns { ok, reason }. `reason` captures which stage failed so the UI can
|
||||||
* non-Nostr response. Never rejects, never throws — safe from a query/effect.
|
* show it on-device (no USB/adb needed to diagnose). Never rejects, never
|
||||||
* Short-circuits to false off-Android (and outside preview) to avoid pointless
|
* throws. Short-circuits off-Android (and outside preview).
|
||||||
* localhost attempts and console noise in the browser/desktop.
|
|
||||||
*/
|
*/
|
||||||
export function probeCitrine(
|
export function probeCitrine(
|
||||||
url: string = CITRINE_RELAY_URL,
|
url: string = CITRINE_RELAY_URL,
|
||||||
timeoutMs = 3000,
|
timeoutMs = 3000,
|
||||||
): Promise<boolean> {
|
): Promise<CitrineProbeResult> {
|
||||||
if (!isCitrinePossible()) return Promise.resolve(false);
|
if (!isCitrinePossible()) {
|
||||||
|
return Promise.resolve({ ok: false, reason: 'not-android' });
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<CitrineProbeResult>((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let opened = false;
|
||||||
let ws: WebSocket | undefined;
|
let ws: WebSocket | undefined;
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const done = (result: boolean) => {
|
const done = (ok: boolean, reason: string) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
|
|
@ -121,36 +130,40 @@ export function probeCitrine(
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close errors
|
// ignore close errors
|
||||||
}
|
}
|
||||||
resolve(result);
|
resolve({ ok, reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
} catch {
|
} catch (err) {
|
||||||
resolve(false);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unique, throwaway subscription id we can match in the reply.
|
|
||||||
const subId = `citrine-probe-${Math.random().toString(36).slice(2, 10)}`;
|
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 = () => {
|
ws.onopen = () => {
|
||||||
|
opened = true;
|
||||||
try {
|
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 }]));
|
ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }]));
|
||||||
} catch {
|
} catch (err) {
|
||||||
done(false);
|
done(false, `send-failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
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') {
|
if (Array.isArray(data) && typeof data[0] === 'string') {
|
||||||
const verb = data[0];
|
const verb = data[0];
|
||||||
const matchesSub = data[1] === subId;
|
const matchesSub = data[1] === subId;
|
||||||
|
|
@ -158,18 +171,23 @@ export function probeCitrine(
|
||||||
((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) ||
|
((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) ||
|
||||||
verb === 'NOTICE'
|
verb === 'NOTICE'
|
||||||
) {
|
) {
|
||||||
done(true);
|
done(true, 'ok');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Any other shape: not a Nostr relay we recognise — keep waiting until
|
|
||||||
// timeout (a valid relay will still send EOSE).
|
|
||||||
} catch {
|
} catch {
|
||||||
// Non-JSON frame → not a Nostr relay; wait for timeout.
|
// Non-JSON frame → wait for timeout.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => done(false);
|
ws.onerror = () => {
|
||||||
ws.onclose = () => done(false);
|
// 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})`);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,17 @@ function CitrineCard() {
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
<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" />
|
<Shield className="w-3.5 h-3.5 text-white/40 flex-shrink-0 mt-0.5" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue