SkateSpots/src/lib/citrine.ts
shakespeare.diy ef86d858c2 fix: make relay-probe timers and effect hooks lint-clean across ESLint versions
- Replace single-assignment `let timer` with a `timers[]` array in
  citrine.ts and relayProbe.ts so stricter prefer-const builds pass
  (the newer ESLint pulled in by `npm audit fix` flagged the let as
  never-reassigned).
- Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps`
  directives in NostrProvider and MapPage. Newer react-hooks no longer
  reports those lines, so the directives were flagged as unused errors
  under reportUnusedDisableDirectives; removing them is safe in both
  old (warning only) and new (silent) ESLint.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:55:38 +07:00

229 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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