diff --git a/.gitignore b/.gitignore index 8cdaa07..1783e17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules dist +dist-firefox .vite .DS_Store *.log send-to-thebrain-*.zip +*.xpi diff --git a/manifest.json b/manifest.json index 628da53..130a77b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Send to TheBrain", - "version": "0.1.1", + "version": "0.1.2", "description": "Save the current page as a thought in your Brain.", "action": { "default_popup": "src/popup/index.html", diff --git a/package.json b/package.json index 7ba90b3..085b010 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "send-to-thebrain", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "description": "Chrome browser extension that saves the active page into TheBrain via the desktop app's local HTTP API.", diff --git a/src/api/TheBrainLocalClient.ts b/src/api/TheBrainLocalClient.ts index 182d111..c9de3d4 100644 --- a/src/api/TheBrainLocalClient.ts +++ b/src/api/TheBrainLocalClient.ts @@ -20,15 +20,30 @@ export interface TheBrainLocalClientOptions { /** Either the bare server origin (`http://localhost:52341`) or the URL shown * in the desktop app's Local API widget (`http://localhost:52341/api/`). */ endpoint: string; + /** Per-request timeout in milliseconds. A short bound is essential because + * TheBrain's desktop app picks a fresh listening port every session, so the + * saved endpoint frequently points at a stale port. Without a timeout, the + * popup can sit on a TCP RST/half-open socket for tens of seconds. */ + timeoutMs?: number; } +export const DEFAULT_REQUEST_TIMEOUT_MS = 4000; + export class TheBrainLocalClient { private readonly apiKey: string; private readonly baseUrl: string; + private readonly timeoutMs: number; - constructor({ apiKey, endpoint }: TheBrainLocalClientOptions) { + constructor({ apiKey, endpoint, timeoutMs }: TheBrainLocalClientOptions) { this.apiKey = apiKey; this.baseUrl = normalizeEndpoint(endpoint); + this.timeoutMs = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + } + + /** The normalized origin currently in use (e.g. `http://localhost:52341`). + * Exposed so callers can record the working port after a successful call. */ + getBaseUrl(): string { + return this.baseUrl; } private async request( @@ -40,7 +55,11 @@ export class TheBrainLocalClient { const headers: Record = { Authorization: `Bearer ${this.apiKey}`, }; - const init: RequestInit = { method, headers }; + const init: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(this.timeoutMs), + }; if(body !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); @@ -50,7 +69,8 @@ export class TheBrainLocalClient { try { response = await fetch(url, init); } catch { - // TypeError from fetch when the local server isn't reachable. + // TypeError from fetch when the local server isn't reachable, or + // AbortError when our timeout fires (e.g. stale port). throw new NotRunningError(); } diff --git a/src/lib/portDiscovery.test.ts b/src/lib/portDiscovery.test.ts new file mode 100644 index 0000000..2f068e8 --- /dev/null +++ b/src/lib/portDiscovery.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from "vitest"; +import { + buildCandidates, + discoverEndpoint, + portFromEndpoint, + DEFAULT_FALLBACK_PORTS, +} from "./portDiscovery"; + +describe("portFromEndpoint", () => { + it("extracts the port from a normalized endpoint", () => { + expect(portFromEndpoint("http://localhost:52341")).toBe(52341); + }); + it("works with the trailing /api form", () => { + expect(portFromEndpoint("http://localhost:8001/api/")).toBe(8001); + }); + it("returns null when no explicit port is present", () => { + expect(portFromEndpoint("http://localhost/api")).toBeNull(); + }); + it("returns null for unparseable input", () => { + expect(portFromEndpoint("not a url")).toBeNull(); + }); +}); + +describe("buildCandidates", () => { + it("starts with the current endpoint, then MRU, then fallbacks", () => { + const out = buildCandidates("http://localhost:52341/api/", [8001, 9000]); + const ports = out.map((c) => c.port); + expect(ports[0]).toBe(52341); + expect(ports.slice(1, 3)).toEqual([8001, 9000]); + // Defaults follow, with 8001 already deduped. + expect(ports).toContain(DEFAULT_FALLBACK_PORTS[1]); + }); + it("dedupes when the current port is also in the MRU", () => { + const out = buildCandidates("http://localhost:52341", [52341, 8001]); + const ports = out.map((c) => c.port); + expect(ports.filter((p) => p === 52341)).toHaveLength(1); + expect(ports[0]).toBe(52341); + expect(ports[1]).toBe(8001); + }); + it("falls back to defaults when nothing else is known", () => { + const out = buildCandidates("", []); + expect(out.map((c) => c.port)).toEqual([...DEFAULT_FALLBACK_PORTS]); + }); + it("ignores invalid ports", () => { + const out = buildCandidates("http://localhost:99999", [-1, 0, 70000]); + expect(out.every((c) => c.port > 0 && c.port < 65536)).toBe(true); + }); +}); + +describe("discoverEndpoint", () => { + it("returns the first ok candidate in candidate order", async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if(url.startsWith("http://localhost:8001/")) { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + if(url.startsWith("http://localhost:9000/")) { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + throw new TypeError("connection refused"); + }); + const result = await discoverEndpoint({ + apiKey: "k", + candidates: [ + { origin: "http://localhost:52341", port: 52341 }, + { origin: "http://localhost:8001", port: 8001 }, + { origin: "http://localhost:9000", port: 9000 }, + ], + timeoutMs: 100, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result.winner?.port).toBe(8001); + expect(result.all).toHaveLength(3); + }); + + it("reports auth when the port responds with 401 but no port is OK", async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if(url.startsWith("http://localhost:52341/")) { + return new Response("nope", { status: 401 }); + } + throw new TypeError("connection refused"); + }); + const result = await discoverEndpoint({ + apiKey: "k", + candidates: [ + { origin: "http://localhost:8001", port: 8001 }, + { origin: "http://localhost:52341", port: 52341 }, + ], + timeoutMs: 100, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result.winner).toBeNull(); + expect(result.all.some((r) => r.kind === "auth" && r.port === 52341)).toBe(true); + }); + + it("returns null winner when every probe fails", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("connection refused"); + }); + const result = await discoverEndpoint({ + apiKey: "k", + candidates: [ + { origin: "http://localhost:8001", port: 8001 }, + { origin: "http://localhost:9000", port: 9000 }, + ], + timeoutMs: 100, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result.winner).toBeNull(); + expect(result.all.every((r) => r.kind === "no")).toBe(true); + }); + + it("handles an empty candidate list", async () => { + const result = await discoverEndpoint({ + apiKey: "k", + candidates: [], + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + expect(result.winner).toBeNull(); + expect(result.all).toEqual([]); + }); +}); diff --git a/src/lib/portDiscovery.ts b/src/lib/portDiscovery.ts new file mode 100644 index 0000000..a95cc3b --- /dev/null +++ b/src/lib/portDiscovery.ts @@ -0,0 +1,130 @@ +// Probe localhost ports in parallel to find the one TheBrain's desktop app +// is currently listening on. Browser extensions can't enumerate processes +// (no equivalent of `Get-NetTCPConnection`), so the only signal available +// is "does an HTTP request to localhost:/api/app/state succeed". + +import { normalizeEndpoint } from "./endpoint"; + +/** Origin to probe and the port we extracted from it (handy for callers + * that want to record a successful port in the MRU list). */ +export interface PortCandidate { + origin: string; + port: number; +} + +/** Outcome for a single probe. `ok` means "TheBrain answered"; `auth` means + * the port has a TheBrain instance but the saved API key was rejected + * (probably stale). `no` means nothing usable on that port. */ +export type ProbeResult = + | { kind: "ok"; origin: string; port: number } + | { kind: "auth"; origin: string; port: number } + | { kind: "no"; origin: string; port: number }; + +export interface DiscoveryOptions { + apiKey: string; + candidates: PortCandidate[]; + /** Per-probe timeout in ms. Total wall time is bounded by this because + * all probes run in parallel. Default 700ms keeps the popup snappy. */ + timeoutMs?: number; + /** Injected for tests. Falls back to global fetch. */ + fetchImpl?: typeof fetch; +} + +export interface DiscoveryResult { + /** First "ok" probe — the endpoint to use. Null if none answered. */ + winner: ProbeResult | null; + /** All results, in the order they settled. Useful for diagnostics + * (e.g. to surface "key was rejected on port X" if no ok). */ + all: ProbeResult[]; +} + +const DEFAULT_PROBE_TIMEOUT_MS = 700; +// Default ports to try when the user has no history yet. 8001 is the +// observed default for self-hosted local APIs; 52341 is what TheBrain's +// desktop widget often shows; 8081 is a frequent collision-avoidance pick. +export const DEFAULT_FALLBACK_PORTS: readonly number[] = [8001, 52341, 8081]; + +/** Build the candidate list from settings + defaults, deduplicating while + * preserving order: current endpoint first, then MRU history, then + * hard-coded fallbacks. */ +export function buildCandidates( + currentEndpoint: string, + recentPorts: readonly number[], + fallbacks: readonly number[] = DEFAULT_FALLBACK_PORTS, +): PortCandidate[] { + const seen = new Set(); + const out: PortCandidate[] = []; + const add = (port: number) => { + if(!Number.isInteger(port) || port <= 0 || port >= 65536) return; + if(seen.has(port)) return; + seen.add(port); + out.push({ origin: `http://localhost:${port}`, port }); + }; + const currentPort = portFromEndpoint(currentEndpoint); + if(currentPort !== null) add(currentPort); + for(const p of recentPorts) add(p); + for(const p of fallbacks) add(p); + return out; +} + +/** Pull the port out of an endpoint string. Returns null if the string + * isn't a parseable URL or has no explicit port. */ +export function portFromEndpoint(endpoint: string): number | null { + const normalized = normalizeEndpoint(endpoint); + if(!normalized) return null; + try { + const url = new URL(normalized); + if(!url.port) return null; + const n = Number(url.port); + return Number.isInteger(n) && n > 0 && n < 65536 ? n : null; + } catch { + return null; + } +} + +/** Probe every candidate in parallel. Resolves once all probes have + * settled (success, failure, or timeout). The caller gets the first + * "ok" result if any; otherwise can inspect `all` for "auth" hits. */ +export async function discoverEndpoint(opts: DiscoveryOptions): Promise { + const { apiKey, candidates } = opts; + const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const fetchImpl = opts.fetchImpl ?? fetch; + + if(candidates.length === 0) { + return { winner: null, all: [] }; + } + + const probes = candidates.map((c) => probeOne(c, apiKey, timeoutMs, fetchImpl)); + const all = await Promise.all(probes); + // Pick winner in candidate order so "current endpoint" or top of MRU + // wins ties — this is the user's expected behavior, not "whichever + // race we won by 2ms". + const winner = all.find((r) => r.kind === "ok") ?? null; + return { winner, all }; +} + +async function probeOne( + candidate: PortCandidate, + apiKey: string, + timeoutMs: number, + fetchImpl: typeof fetch, +): Promise { + const url = `${candidate.origin}/api/app/state`; + try { + const response = await fetchImpl(url, { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + if(response.ok) { + return { kind: "ok", origin: candidate.origin, port: candidate.port }; + } + if(response.status === 401 || response.status === 403) { + return { kind: "auth", origin: candidate.origin, port: candidate.port }; + } + return { kind: "no", origin: candidate.origin, port: candidate.port }; + } catch { + // Connection refused / RST / our timeout fired. + return { kind: "no", origin: candidate.origin, port: candidate.port }; + } +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 91e52f6..55bae90 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -17,10 +17,16 @@ export interface Settings { // YouTube's ?v=VIDEO_ID). Entries are bare hostnames; subdomains match. trimQueryParamsExceptions: string[]; autoProceed: boolean; + // Most-recently-successful local API ports, MRU-ordered. Used by the + // port-discovery probe to find TheBrain after the desktop app rotates + // to a new port across sessions. + recentPorts: number[]; } export const AUTO_PROCEED_MS = 3000; +export const RECENT_PORTS_LIMIT = 8; + export const DEFAULT_TRIM_EXCEPTIONS: readonly string[] = [ "youtube.com", "youtu.be", @@ -34,6 +40,7 @@ const DEFAULTS: Settings = { trimQueryParams: false, trimQueryParamsExceptions: [...DEFAULT_TRIM_EXCEPTIONS], autoProceed: false, + recentPorts: [], }; const KEYS: (keyof Settings)[] = [ @@ -44,6 +51,7 @@ const KEYS: (keyof Settings)[] = [ "trimQueryParams", "trimQueryParamsExceptions", "autoProceed", + "recentPorts", ]; export async function getSettings(): Promise { @@ -72,9 +80,28 @@ export async function getSettings(): Promise { typeof stored.autoProceed === "boolean" ? stored.autoProceed : DEFAULTS.autoProceed, + recentPorts: Array.isArray(stored.recentPorts) + ? (stored.recentPorts as unknown[]) + .filter( + (v): v is number => + typeof v === "number" && + Number.isInteger(v) && + v > 0 && + v < 65536, + ) + .slice(0, RECENT_PORTS_LIMIT) + : [...DEFAULTS.recentPorts], }; } +// Push a port to the front of the recent-ports MRU list, deduping and +// capping the length. Returns the new list (not persisted — the caller +// passes it to updateSettings). +export function pushRecentPort(existing: number[], port: number): number[] { + const filtered = existing.filter((p) => p !== port); + return [port, ...filtered].slice(0, RECENT_PORTS_LIMIT); +} + // Parse a free-form list (newlines or commas) into normalized hostnames. // Strips scheme/path/whitespace and lowercases. Used by the options UI. export function parseExceptionList(input: string): string[] { diff --git a/src/popup/PopupApp.tsx b/src/popup/PopupApp.tsx index e1a5c4d..2011303 100644 --- a/src/popup/PopupApp.tsx +++ b/src/popup/PopupApp.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { TheBrainLocalClient } from "../api/TheBrainLocalClient"; -import { TheBrainError, NoBrainOpenError } from "../api/errors"; +import { + TheBrainError, + NoBrainOpenError, + NotRunningError, + InvalidKeyError, +} from "../api/errors"; import { Alert } from "../components/Alert"; import { Button } from "../components/Button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../components/Card"; @@ -9,11 +14,17 @@ import { Logo } from "../components/Logo"; import { Spinner } from "../components/Spinner"; import { tabs, runtime, type ActiveTab } from "../lib/browser"; import { DEFAULT_ENDPOINT, isValidEndpoint } from "../lib/endpoint"; +import { + buildCandidates, + discoverEndpoint, + portFromEndpoint, +} from "../lib/portDiscovery"; import { sendToBrain, type SendOutcome } from "../lib/sendToBrain"; import { stripUnreadCountPrefix } from "../lib/titleSplit"; import { AUTO_PROCEED_MS, getSettings, + pushRecentPort, updateSettings, type SendMode, type Settings, @@ -48,14 +59,38 @@ export function PopupApp() { // Verify that the desktop app is reachable, the API key works, and a brain // is open — so the user sees a problem immediately, not only after clicking // Send. Called on popup open and again when the user hits Try again. + // + // If the saved endpoint is unreachable (typical: TheBrain restarted and now + // listens on a different port), we transparently probe a small set of + // candidate ports (last-used + MRU history + a few defaults) and rotate + // onto the first one that answers. The new endpoint is persisted so the + // next launch starts on the right port. const probeConnection = useCallback(async (s: Settings) => { setView({ kind: "probing" }); + let effective = s; + let client = new TheBrainLocalClient({ + apiKey: s.apiKey, + endpoint: s.endpoint, + }); try { - const client = new TheBrainLocalClient({ apiKey: s.apiKey, endpoint: s.endpoint }); - const state = await client.getAppState(); + let state; + try { + state = await client.getAppState(); + } catch(error) { + if(!(error instanceof NotRunningError)) throw error; + const rotated = await tryDiscoverNewPort(s); + if(!rotated) throw error; + effective = rotated.settings; + client = rotated.client; + setSettings(rotated.settings); + state = await client.getAppState(); + } if(!state.currentBrainId || !state.activeThoughtId) { throw new NoBrainOpenError(); } + // Record the working port so future launches start on a known-good + // candidate even before the saved endpoint gets refreshed elsewhere. + await rememberWorkingPort(effective, client.getBaseUrl()); setActiveThought({ id: state.activeThoughtId, name: state.activeThoughtName ?? "active thought", @@ -65,11 +100,13 @@ export function PopupApp() { } catch(error) { setActiveThought(null); const message = - error instanceof TheBrainError - ? error.message - : error instanceof Error + error instanceof NotRunningError + ? "TheBrain isn't reachable on any known port. Open the desktop app, copy the Local API URL into Settings, then try again." + : error instanceof TheBrainError ? error.message - : "Could not reach TheBrain."; + : error instanceof Error + ? error.message + : "Could not reach TheBrain."; setView({ kind: "error", message, recoverable: true }); } }, []); @@ -270,6 +307,57 @@ export function PopupApp() { ); } +// When the saved endpoint goes silent (typical: TheBrain restarted with a +// new ephemeral port), fan out to a handful of likely ports in parallel +// and return a refreshed client + settings if any of them answers. Browser +// extensions can't enumerate listening sockets the way a PowerShell script +// can, so this best-effort probe is the closest analogue. +async function tryDiscoverNewPort(s: Settings): Promise<{ + client: TheBrainLocalClient; + settings: Settings; +} | null> { + const candidates = buildCandidates(s.endpoint, s.recentPorts); + if(candidates.length === 0) return null; + const result = await discoverEndpoint({ + apiKey: s.apiKey, + candidates, + }); + if(result.winner === null) { + // If every candidate that answered did so with 401, the port is + // fine but the saved key is stale — surface that explicitly so the + // user knows to refresh it instead of chasing port numbers. + const auth = result.all.find((r) => r.kind === "auth"); + if(auth) { + throw new InvalidKeyError(); + } + return null; + } + const newEndpoint = result.winner.origin; + const newRecent = pushRecentPort(s.recentPorts, result.winner.port); + await updateSettings({ endpoint: newEndpoint, recentPorts: newRecent }); + const next: Settings = { ...s, endpoint: newEndpoint, recentPorts: newRecent }; + const client = new TheBrainLocalClient({ + apiKey: next.apiKey, + endpoint: next.endpoint, + }); + return { client, settings: next }; +} + +// Push the active port to the front of the MRU list whenever a probe +// succeeds. We swallow storage errors silently — failing to update the +// history must never break a working send. +async function rememberWorkingPort(s: Settings, baseUrl: string): Promise { + const port = portFromEndpoint(baseUrl); + if(port === null) return; + if(s.recentPorts[0] === port) return; + const next = pushRecentPort(s.recentPorts, port); + try { + await updateSettings({ recentPorts: next }); + } catch(error) { + console.warn("[Send to TheBrain] failed to record recent port:", error); + } +} + function Header() { return (