/** * citrine — integration helpers for a local on-device Nostr relay. * ───────────────────────────────────────────────────────────────────────────── * Citrine (github.com/greenart7c3/Citrine) is an Android app by the author of * Amber that runs a full Nostr relay locally on the phone, storing events in an * on-device SQLite database and listening on a localhost WebSocket. Because it * is "just a relay URL", it slots into our existing nostr.relay()/group() and * useSkateRelays machinery with no new protocol. * * Why we want it (SkateSpots-specific): * - Offline-in-the-field: read spots/tours/events at a spot with no signal, and * capture writes locally to publish when connectivity returns. * - Instant reads: a localhost query returns in ~1ms vs. 100–800ms remote. * - Resilience/backup: a durable local copy of the user's own events survives * public-relay outages and addressable-event eviction. * - Privacy: local-first event classes (e.g. Stash) can avoid public relays. * * Hard constraints (must be respected everywhere Citrine is used): * - Android-only. iOS/desktop web get none of this — always platform-gate. * - Opt-in only. Never route data through Citrine without an explicit setting. * - Local ≠ encrypted. Citrine stores whatever we give it verbatim; sensitive * data (Stash, DMs) must stay NIP-44 encrypted regardless of destination. */ /** Citrine's default localhost relay endpoint. */ export const CITRINE_RELAY_URL = 'ws://127.0.0.1:4869'; /** * Endpoints to try, in order. Citrine listens on the loopback interface, but * depending on the device/WebView the reachable form can be the numeric IP or * the `localhost` hostname — so we probe both and use whichever answers rather * than betting the feature on one address form. */ export const CITRINE_RELAY_CANDIDATES = [ 'ws://127.0.0.1:4869', 'ws://localhost:4869', ]; /** Forcible UI states for the dev preview flag. */ export type CitrinePreviewState = 'off' | 'searching' | 'live' | 'waiting'; const PREVIEW_KEY = 'citrine:preview'; /** * DEV PREVIEW ONLY — lets the (normally Android-only) Citrine UI render in a * browser so the design can be reviewed without an APK build. * * Set in the browser console: * localStorage.setItem('citrine:preview', '1') // show the card, real behaviour * localStorage.setItem('citrine:preview', 'live') // force the "connected" state * localStorage.setItem('citrine:preview', 'waiting') // force "not detected" * localStorage.setItem('citrine:preview', 'searching')// force "searching…" * localStorage.removeItem('citrine:preview') // back to normal * * CRITICAL: this unlocks RENDERING ONLY. It never makes the local relay * `active` and never yields a relay `url`, so no query or publish is ever routed * anywhere different — production relay behaviour is completely unchanged. A * forced state is purely cosmetic. */ export function citrinePreview(): CitrinePreviewState | null { try { const raw = (globalThis as { localStorage?: Storage }).localStorage?.getItem(PREVIEW_KEY); if (!raw) return null; if (raw === 'off' || raw === 'searching' || raw === 'live' || raw === 'waiting') { return raw; } // Any other truthy value ("1"/"true") = show the card with real behaviour. return 'off'; } catch { return null; } } /** True if the dev preview flag is set at all (any value). */ export function isCitrinePreview(): boolean { return citrinePreview() !== null; } /** * True when running inside a Capacitor native Android shell. Citrine is an * Android app, so the local relay can only exist there. This never throws in a * plain browser (window.Capacitor is simply undefined). */ export function isAndroidNative(): boolean { const cap = (globalThis as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; }).Capacitor; if (!cap?.isNativePlatform?.()) return false; return cap.getPlatform?.() === 'android'; } /** * True if the Citrine UI/feature should be offered on this device — native * Android, OR the dev preview flag is set (browser design review). Actual * reachability is still confirmed separately by probeCitrine(), which fails * cleanly in a browser, so preview never routes real traffic. */ 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; /** The endpoint that answered (on success), so routing uses the right one. */ url?: string; } /** * Probe whether a Citrine relay is actually running AND speaking Nostr at `url`. * * A bare "socket opened" check is not enough: any process squatting on the port * would open cleanly and we'd then wrongly route reads — and, worse, published * events — at a non-Nostr peer. So we complete a minimal Nostr handshake: * open → send a tiny bounded REQ → require a protocol-shaped reply * (EVENT / EOSE / NOTICE / CLOSED) referencing our subscription id. * * 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({ ok: false, reason: 'not-android' }); } return new Promise((resolve) => { let settled = false; let opened = false; let ws: WebSocket | undefined; const timers: ReturnType[] = []; const done = (ok: boolean, reason: string) => { if (settled) return; settled = true; for (const t of timers) clearTimeout(t); try { ws?.close(); } catch { // ignore close errors } resolve({ ok, reason, url: ok ? url : undefined }); }; try { ws = new WebSocket(url); } 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; } const subId = `citrine-probe-${Math.random().toString(36).slice(2, 10)}`; timers.push(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 { ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }])); } 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 : ''); if (Array.isArray(data) && typeof data[0] === 'string') { const verb = data[0]; const matchesSub = data[1] === subId; if ( ((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) || verb === 'NOTICE' ) { done(true, 'ok'); return; } } } catch { // Non-JSON frame → wait for timeout. } }; 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})`); }; }); } /** * Probe each candidate endpoint in order, returning the first that answers as a * real Nostr relay (with the winning `url`). If none answer, returns the last * failure's reason so the UI can still show a meaningful diagnostic. Tries them * sequentially (not in parallel) — the list is tiny and the first candidate * almost always wins, so this avoids opening several sockets at once. */ export async function probeCitrineEndpoints( candidates: readonly string[] = CITRINE_RELAY_CANDIDATES, timeoutMs = 3000, ): Promise { if (!isCitrinePossible()) { return { ok: false, reason: 'not-android' }; } let last: CitrineProbeResult = { ok: false, reason: 'no-endpoints' }; for (const candidate of candidates) { last = await probeCitrine(candidate, timeoutMs); if (last.ok) return last; } return last; }