From 197e351880e6515cd8da5b3a96a57fd510af838f Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:08:17 -0400 Subject: [PATCH 01/45] fix(companion): pairing QR advertises the LAN address, never a tailnet IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QR mirrored the browser origin — an operator browsing over Tailscale handed the phone a 100.x address it has no route to, so pairing silently never connected (reported 2026-07-22; the 'random password' alongside it was the device token working as designed). system.get-hostname now also returns the default-route LAN IPv4; the QR substitutes it (or the mDNS name) whenever the origin is localhost or a CGNAT/tailnet 100.64/10 IP. Co-Authored-By: Claude Fable 5 --- .../src/api/rpc/system/handlers.rs | 8 +++++ .../src/components/CompanionIntroOverlay.vue | 30 +++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 22e5a5c6..508d4f0f 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -139,9 +139,17 @@ impl RpcHandler { .await .map(|s| s.trim().to_string()) .unwrap_or_else(|_| "archipelago".to_string()); + // LAN IPv4 rides along for the companion pairing QR: when the + // operator's browser reaches this node over Tailscale/VPN or + // localhost, that origin is useless to a phone on the LAN — the QR + // must advertise an address the phone can actually dial + // (2026-07-22: a pairing QR carried a tailnet 100.x IP and the + // companion could never connect). + let lan_ip = crate::host_ip::primary_host_ipv4().await; Ok(serde_json::json!({ "hostname": hostname, "mdns_hostname": format!("{hostname}.local"), + "lan_ip": lan_ip, })) } diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index 537df118..cfb06cf7 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -252,21 +252,39 @@ watch(visible, async (isVisible) => { }) }, { immediate: true, flush: 'post' }) +/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */ +function isTailnetIp(host: string): boolean { + const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/) + return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127 +} + /** * The server URL the companion app should connect to. The demo advertises its - * public https origin; a real node advertises whatever address the browser is - * already using — except on the kiosk, where that is localhost and useless to - * a phone, so fall back to the node's mDNS .local name. + * public https origin; a real node advertises the browser's own origin ONLY + * when a phone could plausibly reach it too. Two origins that a phone on the + * LAN can never dial get substituted with the node's real LAN address: + * - localhost/127.0.0.1 (the kiosk browses itself) + * - a tailnet 100.x address (operator browsing over Tailscale — a scanned + * QR carried one of these on 2026-07-22 and the companion sat there + * dialing an IP the phone had no route to) */ async function resolveServerUrl(): Promise { if (IS_DEMO) return DEMO_SERVER_URL const { hostname, origin } = window.location - if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin + const phoneUnreachable = + hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname) + if (!phoneUnreachable) return origin try { - const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' }) + const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({ + method: 'system.get-hostname', + }) + // Prefer the LAN IP (phones resolve it without mDNS support — Android + // notoriously lacks .local); fall back to the mDNS name. + if (res?.lan_ip) return `http://${res.lan_ip}` if (res?.mdns_hostname) return `http://${res.mdns_hostname}` } catch { - // RPC unavailable — fall through to the (localhost) origin + // RPC unavailable — fall through to the (unreachable) origin; the app + // still lets the user edit the address by hand. } return origin } From cad68eb94192a8ae816aa43260bc1e1a22d308f5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:08:18 -0400 Subject: [PATCH 02/45] =?UTF-8?q?fix(mesh):=20setup=20modal=20re-fires=20o?= =?UTF-8?q?n=20EVERY=20plug=20=E2=80=94=20dismissals=20key=20on=20plug=20t?= =?UTF-8?q?ime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detected_device_info now carries plugged_at (the /dev node's creation time; udev recreates it per plug). Dismissals store (path -> plugged_at), so swapping sticks or a fast unplug/replug between status polls can no longer be suppressed by an old 'Not Now' (v1 path-only dismissals are abandoned wholesale). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/serial.rs | 13 +++++++++ neode-ui/src/stores/mesh.ts | 43 +++++++++++++++++++---------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index f87eb86d..8ce13f32 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -572,6 +572,12 @@ pub struct DetectedDeviceInfo { pub pid: Option, pub product: Option, pub manufacturer: Option, + /// Unix epoch seconds of the /dev node's creation — udev recreates the + /// node on every plug, so this changes on each replug. The UI keys its + /// "Not Now" dismissals on (path, plugged_at): swapping a stick (or + /// unplug/replug faster than a status poll) invalidates old dismissals + /// and the setup modal fires again, per the hot-swap UX (2026-07-22). + pub plugged_at: Option, } /// Like `detect_serial_devices`, but with USB metadata per port. @@ -579,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec { let mut out = Vec::new(); for path in detect_serial_devices().await { let usb = usb_info_for_tty(&path).await; + let plugged_at = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); out.push(DetectedDeviceInfo { path, vid: usb.0, pid: usb.1, product: usb.2, manufacturer: usb.3, + plugged_at, }); } out diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 6439479d..6220667e 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -34,6 +34,8 @@ export interface MeshStatus { pid?: string | null product?: string | null manufacturer?: string | null + /** Epoch seconds the /dev node appeared — changes on every replug. */ + plugged_at?: number | null }> /** Hot-swap "keep as is": false = archipelago never writes config to the radio. */ manage_radio?: boolean @@ -288,10 +290,20 @@ export const useMeshStore = defineStore('mesh', () => { // Dismissals are cleared the moment the port disappears (unplug), so the // modal re-appears on EVERY plug-in — including swapping a different stick // into the same /dev path — per the hot-swap UX (2026-07-22). - const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1' - const dismissedDetectedPaths = ref>(new Set( - JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[] - )) + // v2: dismissals key on (path → plugged_at). udev recreates the /dev node + // on every plug, so plugged_at changes on each replug — an old "Not Now" + // can never suppress a NEWLY plugged stick, even when the swap happens + // faster than a status poll (which absence-pruning alone missed) or when + // the same /dev path is reused. v1 (a bare path set) is intentionally + // abandoned: stale one-time dismissals from before 2026-07-22 shouldn't + // suppress anything either. + const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2' + const dismissedDetected = ref>( + JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record + ) + function pluggedAt(s: MeshStatus, path: string): number { + return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0 + } // Consecutive polls each candidate port has been present-but-not-connected. // The modal waits for 2 sightings so it doesn't flash during the couple of // seconds an ordinary reconnect (same radio, transient blip) needs. @@ -300,15 +312,15 @@ export const useMeshStore = defineStore('mesh', () => { const s = status.value if (!s) return [] return (s.detected_devices || []).filter(p => - !dismissedDetectedPaths.value.has(p) && + dismissedDetected.value[p] !== pluggedAt(s, p) && // The port the live session occupies is not a candidate… !(s.device_connected && s.device_path === p) && // …and a port only qualifies once it has survived the blip debounce. (detectSightings.value[p] ?? 0) >= 2 ) }) - /** Called after every status fetch: advance sighting counters and clear - * dismissals/counters for ports that vanished (unplug → replug reshows). */ + /** Called after every status fetch: advance sighting counters and drop + * dismissals for ports that vanished (belt-and-braces with plugged_at). */ function trackDetectedDevices(s: MeshStatus) { const present = new Set(s.detected_devices || []) const next: Record = {} @@ -319,21 +331,24 @@ export const useMeshStore = defineStore('mesh', () => { } detectSightings.value = next let dirty = false - for (const p of [...dismissedDetectedPaths.value]) { + for (const p of Object.keys(dismissedDetected.value)) { if (!present.has(p)) { - dismissedDetectedPaths.value.delete(p) + delete dismissedDetected.value[p] dirty = true } } if (dirty) { - dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) - localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) + dismissedDetected.value = { ...dismissedDetected.value } + localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value)) } } function dismissDetectedDevice(path: string) { - dismissedDetectedPaths.value.add(path) - dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) - localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) + const s = status.value + dismissedDetected.value = { + ...dismissedDetected.value, + [path]: s ? pluggedAt(s, path) : 0, + } + localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value)) } /** Read-only firmware/config probe of a detected port (hot-swap modal). */ async function probeDevice(path: string): Promise { From 30a4343b83df2e206bdedaa20aecf53b144f8aaf Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:08:31 -0400 Subject: [PATCH 03/45] =?UTF-8?q?fix(ui):=20kill=20console=20spam=20?= =?UTF-8?q?=E2=80=94=20pine/mempool=20icon=20404s=20+=20filebrowser=20401?= =?UTF-8?q?=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pine/mempool get explicit icon entries (their extensions aren't .png, so the default guess 404'd on every render). filebrowser-client: central authedFetch does ONE transparent re-login when the short-lived JWT expires (an expired cookie previously kept _authenticated=true and every Files-card poll 401'd forever), plus a 60s login cooldown so a missing filebrowser doesn't spam either. Co-Authored-By: Claude Fable 5 --- neode-ui/src/api/filebrowser-client.ts | 83 +++++++++++++------------- neode-ui/src/views/apps/appsConfig.ts | 6 ++ 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 7e743132..42faffb4 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -89,19 +89,43 @@ class FileBrowserClient { return h } + /** Don't hammer app.filebrowser-token after a failed login — one attempt + * per cooldown window, so a broken/missing filebrowser doesn't turn every + * poll of the Files card into a fresh login + 401 pair (console spam). */ + private _lastLoginFailure = 0 + private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000 + /** Ensure we're authenticated before making a request. Auto-logins if needed. */ private async ensureAuth(): Promise { if (this._authenticated && this.getAuthCookie()) return + if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) { + throw new Error('FileBrowser authentication failed — please open Cloud to log in') + } const ok = await this.login() - if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in') + if (!ok) { + this._lastLoginFailure = Date.now() + throw new Error('FileBrowser authentication failed — please open Cloud to log in') + } + } + + /** fetch() with auth headers + ONE transparent re-login on 401. The JWT + * from app.filebrowser-token is short-lived; before this, an expired + * cookie kept `_authenticated` true and every Files-card poll 401'd + * forever (the console-spam bug, 2026-07-22). */ + private async authedFetch(url: string, init?: RequestInit): Promise { + await this.ensureAuth() + let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record | undefined), ...this.headers() } }) + if (res.status === 401) { + this._authenticated = false + await this.ensureAuth() + res = await fetch(url, { ...init, headers: { ...(init?.headers as Record | undefined), ...this.headers() } }) + } + return res } async listDirectory(path: string): Promise { - await this.ensureAuth() const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { - headers: this.headers(), - }) + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`) if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`) // When File Browser isn't installed, nginx falls through to the SPA and // returns index.html (200, text/html); when it's down it returns 502. @@ -133,11 +157,8 @@ class FileBrowserClient { * For large files (video/audio), prefer streamUrl() instead. */ async fetchBlobUrl(path: string): Promise { - await this.ensureAuth() const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { - headers: this.headers(), - }) + const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`) if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`) const blob = await res.blob() return URL.createObjectURL(blob) @@ -171,17 +192,12 @@ class FileBrowserClient { } async upload(dirPath: string, file: File): Promise { - await this.ensureAuth() const sanitized = sanitizePath(dirPath) const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const encodedName = encodeURIComponent(file.name) - const res = await fetch( + const res = await this.authedFetch( `${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`, - { - method: 'POST', - headers: this.headers(), - body: file, - }, + { method: 'POST', body: file }, ) if (!res.ok) { const text = await res.text().catch(() => '') @@ -190,35 +206,31 @@ class FileBrowserClient { } async createFolder(parentPath: string, name: string): Promise { - await this.ensureAuth() const sanitized = sanitizePath(parentPath) const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '') - const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { method: 'POST', - headers: this.headers(), }) if (!res.ok) throw new Error(`Create folder failed: ${res.status}`) } async deleteItem(path: string): Promise { - await this.ensureAuth() const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, { method: 'DELETE', - headers: this.headers(), }) if (!res.ok) throw new Error(`Delete failed: ${res.status}`) } async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> { - if (!this._authenticated || !this.getAuthCookie()) { - const ok = await this.login() - if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } + let res: Response + try { + res = await this.authedFetch(`${this.baseUrl}/api/resources/`) + } catch { + // Not installed / login cooling down — the Files card shows zeros. + return { totalSize: 0, folderCount: 0, fileCount: 0 } } - const res = await fetch(`${this.baseUrl}/api/resources/`, { - headers: this.headers(), - }) if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } const data: FileBrowserListResponse = await res.json() const items = data.items || [] @@ -242,17 +254,11 @@ class FileBrowserClient { } async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> { - if (!this._authenticated || !this.getAuthCookie()) { - const ok = await this.login() - if (!ok) throw new Error('FileBrowser authentication failed') - } if (!this.isTextFile(path)) { throw new Error(`Cannot read binary file: ${path}`) } const safePath = sanitizePath(path) - const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { - headers: this.headers(), - }) + const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`) if (!res.ok) throw new Error(`Failed to read file: ${res.status}`) const blob = await res.blob() const size = blob.size @@ -266,12 +272,9 @@ class FileBrowserClient { const safePath = sanitizePath(oldPath) const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '') - const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { + const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, { method: 'PATCH', - headers: { - ...this.headers(), - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ destination: `${dir}${sanitizedName}` }), }) if (!res.ok) throw new Error(`Rename failed: ${res.status}`) diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index ef56d125..1b484f35 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean { // are explicit because icon extensions vary (.png / .webp / .svg). const APP_ICON_FALLBACKS: Record = { gitea: '/assets/img/app-icons/gitea.svg', + // Apps whose icon extension isn't .png: without an explicit entry the + // default `.png` guess 404s on every render (console spam, and for + // mempool the .png→.svg fallback chain 404s TWICE before giving up). + pine: '/assets/img/app-icons/pine.svg', + mempool: '/assets/img/app-icons/mempool.webp', + 'mempool-web': '/assets/img/app-icons/mempool.webp', 'fedimint-gateway': '/assets/img/app-icons/fedimint.png', 'fedimint-clientd': '/assets/img/app-icons/fedimint.png', // immich stack From 8f4c32b93f050db5754ccf0040147c3aff88aacd Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:08:38 -0400 Subject: [PATCH 04/45] =?UTF-8?q?feat(ui):=20modal=20contract=20=E2=80=94?= =?UTF-8?q?=20pinned=20title+tabs,=20scrolling=20middle,=20pinned=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseModal becomes a flex column: title row and the new #header slot (tabs) stay fixed at the top, #footer (action buttons) fixed at the bottom, only the default slot scrolls (default max-h 90vh unless the caller sets one). Wallet Settings migrates: tabs pinned, four duplicated in-pane Close rows collapse into one pinned footer that carries the active tab's primary action. Co-Authored-By: Claude Fable 5 --- neode-ui/src/components/BaseModal.vue | 28 ++++++- .../src/components/WalletSettingsModal.vue | 84 +++++++++---------- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/neode-ui/src/components/BaseModal.vue b/neode-ui/src/components/BaseModal.vue index 2815b1bf..49a1f21e 100644 --- a/neode-ui/src/components/BaseModal.vue +++ b/neode-ui/src/components/BaseModal.vue @@ -8,10 +8,16 @@ @click.self="close" >
+ - - +
+ +
+
+ +
+
+ +
@@ -60,6 +73,13 @@ const emit = defineEmits<{ const modalRef = ref(null) const zClass = computed(() => props.zIndex) +// The pinned-footer layout needs a height bound or tall content pushes the +// footer off-screen anyway. Callers that set their own max-h (e.g. the +// Transactions modal's visual-viewport calc on mobile) keep authority — +// adding a second max-h class would make the CSS winner order-dependent. +const defaultMaxH = computed(() => + props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]' +) function close() { emit('close') diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue index 580108bc..a9519d75 100644 --- a/neode-ui/src/components/WalletSettingsModal.vue +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -1,15 +1,18 @@ @@ -133,16 +123,6 @@
{{ fedError }}
Federation joined.
-
- - -

Joining federations lands with the Fedimint client backend. @@ -179,9 +159,6 @@ Don't warn me each time before opening the external explorer -

- -
@@ -269,16 +246,33 @@
{{ arkError }}
{{ arkOk }}
-
- - -
+ + From 770e3386f4e67b1ca755921f70efc9282de4c37c Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:44:31 -0400 Subject: [PATCH 05/45] =?UTF-8?q?perf(nodes):=20Connected=20Nodes=20refres?= =?UTF-8?q?h=20=E2=80=94=20parallel=20probes,=20instant=20list,=2012s=20he?= =?UTF-8?q?alth=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reachability probes ran one at a time with a 30s Tor timeout each, so Refresh sat disabled for N-offline-peers × 30s. Now the list renders and the button re-enables immediately; probes run concurrently and fill the dots in as they land. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/node_message.rs | 5 ++- neode-ui/src/api/rpc-client.ts | 3 +- .../src/views/web5/Web5ConnectedNodes.vue | 34 ++++++++++++------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/core/archipelago/src/node_message.rs b/core/archipelago/src/node_message.rs index 1b5feeda..c08df91e 100644 --- a/core/archipelago/src/node_message.rs +++ b/core/archipelago/src/node_message.rs @@ -406,7 +406,10 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul validate_onion(onion)?; match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health") .service(crate::settings::transport::PeerService::Messaging) - .timeout(std::time::Duration::from_secs(30)) + // 12s, not 30s: this is a liveness dot, not a data transfer. A Tor + // circuit that hasn't answered /health in 12s is "offline" for UI + // purposes; the old 30s made the Connected Nodes probes crawl. + .timeout(std::time::Duration::from_secs(12)) .send_get() .await { diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 13d5b6b9..ee4b553d 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -447,7 +447,8 @@ class RPCClient { return this.call({ method: 'node-check-peer', params: { onion }, - timeout: 35000, + // Backend health-dial timeout is 12s — 15s here covers RPC overhead. + timeout: 15000, }) } diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index 9d4db73d..bb57e5aa 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -379,19 +379,27 @@ async function loadPeers() { peers.value = peerList observers.value = observerList - for (const p of [...peers.value, ...observers.value]) { - try { - const check = await rpcClient.checkPeerReachable(p.onion) - peerReachableLocal.value[p.onion] = check.reachable - } catch { - peerReachableLocal.value[p.onion] = false - } - } - writeConnectedNodesCache({ - peers: peers.value, - observers: observers.value, - peerReachable: peerReachableLocal.value, - connectionRequests: connectionRequests.value, + // The list is ready — render it and re-enable Refresh NOW. The + // reachability dots fill in as probes resolve. Before 2026-07-22 the + // probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat + // disabled for N-offline-peers × 30s ("takes ages"). + loadingPeers.value = false + void Promise.allSettled( + [...peers.value, ...observers.value].map(async (p) => { + try { + const check = await rpcClient.checkPeerReachable(p.onion) + peerReachableLocal.value[p.onion] = check.reachable + } catch { + peerReachableLocal.value[p.onion] = false + } + }), + ).then(() => { + writeConnectedNodesCache({ + peers: peers.value, + observers: observers.value, + peerReachable: peerReachableLocal.value, + connectionRequests: connectionRequests.value, + }) }) } catch (e) { if (import.meta.env.DEV) console.error('Failed to load peers:', e) From 6a3b46b5a9cf758f619f74434c327cafa4725394 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:44:32 -0400 Subject: [PATCH 06/45] =?UTF-8?q?fix(handshake):=20peer=20requests=20actua?= =?UTF-8?q?lly=20arrive=20=E2=80=94=20background=20poll,=20unified=20relay?= =?UTF-8?q?s,=20real=20send=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three silent failure modes killed nostr peer requests (analyzed from the live code 2026-07-22): (1) nothing ever polled the relays except the manual Federation Poll button — a 5-minute background poll now fetches inbound requests and nudges the websocket when something lands (the handler's discoverability gate still applies); (2) handshake send/poll used only the two hardcoded config relays (one defunct) and ignored the user-managed relay list — every nostr op now uses the merged set; (3) publish errors were swallowed (let _ =) so 'ok' was reported even when no relay accepted the event — now a delivery failure is an error the UI shows. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/handshake.rs | 55 +++++++++++++++++++++-- core/archipelago/src/nostr_handshake.rs | 14 +++++- core/archipelago/src/nostr_relays.rs | 15 +++++++ core/archipelago/src/server.rs | 25 ++++++++++- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index 98e9fa31..d1275b95 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -86,7 +86,7 @@ impl RpcHandler { let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey) .unwrap_or_default(); let version = data.server_info.version.clone(); - let relays = self.config.nostr_relays.clone(); + let relays = self.handshake_relays().await; let tor_proxy = self.config.nostr_tor_proxy.clone(); tokio::spawn(async move { if let Err(e) = nostr_handshake::publish_presence( @@ -106,6 +106,17 @@ impl RpcHandler { Ok(serde_json::json!({ "enabled": enabled })) } + /// The relay set every handshake operation uses: the user-managed relay + /// list (Settings → Relays, `nostr_relays.json`) merged with the config + /// defaults. Before 2026-07-22 handshake send/poll used ONLY the two + /// hardcoded config relays (one of which is defunct) and ignored user + /// relay edits entirely — so a sender publishing where the receiver + /// never read was a routine, silent way for peer requests to vanish. + pub(super) async fn handshake_relays(&self) -> Vec { + crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays) + .await + } + /// Discover discoverable nodes via Nostr presence events. /// Returns (nostr_pubkey, npub, DID, version) only — never an onion. pub(super) async fn handle_handshake_discover(&self) -> Result { @@ -113,9 +124,10 @@ impl RpcHandler { // to query relays as long as the user is actively browsing — they're // an anonymous observer of presence events, not publishing anything. let identity_dir = self.config.data_dir.join("identity"); + let relays = self.handshake_relays().await; let nodes = nostr_handshake::discover_nodes( &identity_dir, - &self.config.nostr_relays, + &relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; @@ -161,7 +173,7 @@ impl RpcHandler { our_version, our_name, message, - &self.config.nostr_relays, + &self.handshake_relays().await, self.config.nostr_tor_proxy.as_deref(), ) .await?; @@ -191,6 +203,40 @@ impl RpcHandler { /// - `PeerReject` → mark matching outbound row as `Rejected` /// /// Never auto-adds peers, never auto-responds, never sends our onion. + /// Background relay poll (2026-07-22): before this, `handshake.poll` ran + /// ONLY when a user opened Federation and pressed the Poll button — a + /// peer request sat on the relay until the target's operator happened to + /// click, i.e. for most nodes forever ("requests never arrive"). Runs the + /// same poll+dispatch as the RPC (the disabled gate inside still applies) + /// and nudges the websocket revision when anything new lands so open UIs + /// refresh immediately. + pub async fn background_handshake_poll(self: &std::sync::Arc) { + match self.handle_handshake_poll().await { + Ok(res) => { + let new = res + .get("new_requests") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let applied = res + .get("applied_invites") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + if new > 0 || applied > 0 { + tracing::info!( + new_requests = new, + applied_invites = applied, + "handshake poll: inbound peer activity" + ); + let (data, _) = self.state_manager.get_snapshot().await; + self.state_manager.update_data(data).await; + } + } + Err(e) => tracing::debug!("background handshake poll failed: {e:#}"), + } + } + pub(super) async fn handle_handshake_poll(&self) -> Result { // Runtime gate: if the user hasn't enabled discoverability, don't // touch the relays. The poll endpoint is a hard no-op until they @@ -207,9 +253,10 @@ impl RpcHandler { })); } let identity_dir = self.config.data_dir.join("identity"); + let relays = self.handshake_relays().await; let handshakes = nostr_handshake::poll_handshakes( &identity_dir, - &self.config.nostr_relays, + &relays, self.config.nostr_tor_proxy.as_deref(), None, ) diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 69f1a708..4d0a2edf 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -307,9 +307,19 @@ async fn send_handshake_message( let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) .tag(Tag::public_key(recipient_pk)); - let _ = client.send_event_builder(builder).await; + // Surface real delivery failures. Before 2026-07-22 this was `let _ =`, + // so a request no relay accepted still reported ok:true to the UI and + // the sender believed it was delivered. + let send = client.send_event_builder(builder).await; client.disconnect().await; - Ok(()) + match send { + Ok(output) if !output.success.is_empty() => Ok(()), + Ok(output) => anyhow::bail!( + "no relay accepted the handshake event (tried {}, all failed)", + output.failed.len() + ), + Err(e) => anyhow::bail!("failed to publish handshake event: {e}"), + } } /// Send a `PeerRequest` to a discovered node's nostr pubkey. We never diff --git a/core/archipelago/src/nostr_relays.rs b/core/archipelago/src/nostr_relays.rs index 5a594019..dab7a190 100644 --- a/core/archipelago/src/nostr_relays.rs +++ b/core/archipelago/src/nostr_relays.rs @@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[ "wss://relay.current.fyi", ]; +/// The union of the boot-config relay list and the user-managed (enabled) +/// relays — the set every nostr-facing operation should use, so senders and +/// receivers always overlap regardless of which list the operator edited. +pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec { + let mut relays: Vec = config_relays.to_vec(); + if let Ok(store) = load_relays(data_dir).await { + for r in store.relays { + if r.enabled && !relays.contains(&r.url) { + relays.push(r.url); + } + } + } + relays +} + pub async fn load_relays(data_dir: &Path) -> Result { let path = data_dir.join(RELAYS_FILE); if !path.exists() { diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index a4e57433..973ef6ed 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -209,9 +209,15 @@ impl Server { let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); let version = data.server_info.version.clone(); - let relays = config.nostr_relays.clone(); + // Merged relay set (config + user-managed) — publish presence + // where handshake peers actually read (2026-07-22 unification). + let data_dir_for_relays = config.data_dir.clone(); + let config_relays = config.nostr_relays.clone(); let tor_proxy = config.nostr_tor_proxy.clone(); tokio::spawn(async move { + let relays = + crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays) + .await; if let Err(e) = nostr_handshake::publish_presence( &identity_dir, &did, @@ -253,6 +259,23 @@ impl Server { .await?, ); + // Background handshake poll: fetch inbound nostr peer requests every + // 5 minutes instead of only when a user presses the Federation Poll + // button (requests used to sit on relays unseen — 2026-07-22). The + // handler's own discoverability gate makes this a no-op until the + // user opts in. + { + let rpc = api_handler.rpc_handler().clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(300)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tick.tick().await; + rpc.background_handshake_poll().await; + } + }); + } + // Initialize mesh networking service (if config has enabled: true) { let data_dir = config.data_dir.clone(); From b193e64ffb1d92460784e4f29a7bdfcab470d4d9 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:44:32 -0400 Subject: [PATCH 07/45] fix(ui): kiosk dropdown flash + never inherit the OS light theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside-close listeners move from click to pointerdown: pointerdown fires before the open-toggle re-render, so the kiosk's slow software compositing can no longer detach the click target mid-flight and close the menu the instant it opens. color-scheme:dark (CSS + meta) pins every native control — select popups, scrollbars, pickers — dark regardless of the user's OS theme; explicit select/option colors cover Firefox. Co-Authored-By: Claude Fable 5 --- neode-ui/index.html | 2 ++ neode-ui/src/components/AppSwitcher.vue | 4 ++-- neode-ui/src/components/IdentityPicker.vue | 4 ++-- neode-ui/src/style.css | 16 ++++++++++++++++ neode-ui/src/views/Mesh.vue | 8 ++++---- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/neode-ui/index.html b/neode-ui/index.html index a486583f..c0e2ceb1 100644 --- a/neode-ui/index.html +++ b/neode-ui/index.html @@ -14,6 +14,8 @@ + + diff --git a/neode-ui/src/components/AppSwitcher.vue b/neode-ui/src/components/AppSwitcher.vue index 0bf5d32a..a3db347b 100644 --- a/neode-ui/src/components/AppSwitcher.vue +++ b/neode-ui/src/components/AppSwitcher.vue @@ -104,11 +104,11 @@ function handleClickOutside(e: MouseEvent) { } onMounted(() => { - document.addEventListener('click', handleClickOutside) + document.addEventListener('pointerdown', handleClickOutside) }) onBeforeUnmount(() => { - document.removeEventListener('click', handleClickOutside) + document.removeEventListener('pointerdown', handleClickOutside) }) diff --git a/neode-ui/src/components/IdentityPicker.vue b/neode-ui/src/components/IdentityPicker.vue index 4fdc892e..d42e8411 100644 --- a/neode-ui/src/components/IdentityPicker.vue +++ b/neode-ui/src/components/IdentityPicker.vue @@ -131,10 +131,10 @@ async function loadIdentities() { onMounted(() => { loadIdentities() - document.addEventListener('click', onClickOutside) + document.addEventListener('pointerdown', onClickOutside) }) onBeforeUnmount(() => { - document.removeEventListener('click', onClickOutside) + document.removeEventListener('pointerdown', onClickOutside) }) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index d17b1302..eaa2ceb8 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -2,6 +2,22 @@ @tailwind components; @tailwind utilities; +/* The app is dark-only — NEVER let the OS/browser theme leak into native + controls. Without this, select popups / scrollbars / date pickers follow + the user's OS setting (a light-mode ThinkPad rendered white dropdown + lists inside the dark UI, 2026-07-22). color-scheme pins Chromium's + native select popup dark; the explicit select/option rules cover + Firefox and the closed control itself. */ +:root { + color-scheme: dark; +} +select, +select option, +select optgroup { + background-color: #16181d; + color: #fff; +} + /* Montserrat - header font (used in neode present) */ @font-face { font-family: 'Montserrat'; diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 10bdac71..64902469 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -350,7 +350,7 @@ function updateKeyboardInset() { onMounted(async () => { window.addEventListener('resize', handleResize) - document.addEventListener('click', handleDocClickForMenu) + document.addEventListener('pointerdown', handleDocClickForMenu) window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession) if (window.visualViewport) { window.visualViewport.addEventListener('resize', updateKeyboardInset) @@ -403,7 +403,7 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('resize', handleResize) - document.removeEventListener('click', handleDocClickForMenu) + document.removeEventListener('pointerdown', handleDocClickForMenu) window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession) if (window.visualViewport) { window.visualViewport.removeEventListener('resize', updateKeyboardInset) @@ -1337,8 +1337,8 @@ function closeAttachMenuOnOutsideClick(ev: MouseEvent) { const target = ev.target as HTMLElement if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false } -onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick)) -onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick)) +onMounted(() => document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick)) +onUnmounted(() => document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick)) const attachError = ref(null) const fetchingCids = ref>(new Set()) const fetchedUrls = ref>(new Map()) From 6347d16a2f9252ec40d429e20dec74e2ec5852b0 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:44:32 -0400 Subject: [PATCH 08/45] =?UTF-8?q?fix(recovery):=20quiet=20the=20fleet=20lo?= =?UTF-8?q?gs=20=E2=80=94=20skip=20running=20containers,=20back=20off=20fa?= =?UTF-8?q?iled=20companion=20repairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet log sweep (2026-07-22): (1) recovery paths podman-start'ed containers that were already running, producing hundreds of benign but scary conmon 'Failed to create container' + cgroup Permission-denied pairs per node per day — both paths now check state first; (2) the 30s companion-reconcile loop retried registry pulls/builds forever against unreachable registries (~174×/image/day on an offline node) — failing rounds now back off exponentially to a 1h cap. Co-Authored-By: Claude Fable 5 --- .../src/container/boot_reconciler.rs | 22 ++++++++++++++++--- core/archipelago/src/crash_recovery.rs | 21 +++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/container/boot_reconciler.rs b/core/archipelago/src/container/boot_reconciler.rs index cb417fda..346fa02e 100644 --- a/core/archipelago/src/container/boot_reconciler.rs +++ b/core/archipelago/src/container/boot_reconciler.rs @@ -108,17 +108,33 @@ impl BootReconciler { let orchestrator = self.orchestrator.clone(); let interval = self.interval; Some(tokio::spawn(async move { + let mut failure_rounds: u32 = 0; loop { let installed = orchestrator.manifest_ids().await; - for (companion, err) in crate::container::companion::reconcile(&installed).await - { + let failures = + crate::container::companion::reconcile(&installed).await; + for (companion, err) in &failures { tracing::warn!( companion = %companion, error = %err, "companion reconcile failed" ); } - time::sleep(interval).await; + // A failed repair can involve registry pulls and full + // image builds; retrying every 30s hammered unreachable + // registries ~174×/image/day on an offline node + // (archy-x250-dev log sweep, 2026-07-22). Back off + // exponentially while rounds keep failing — 30s doubling + // to a 1h cap — and reset the moment a round is clean. + failure_rounds = if failures.is_empty() { + 0 + } else { + failure_rounds.saturating_add(1) + }; + let backoff = interval + .saturating_mul(2u32.saturating_pow(failure_rounds.min(7))) + .min(Duration::from_secs(3600)); + time::sleep(backoff).await; } })) } else { diff --git a/core/archipelago/src/crash_recovery.rs b/core/archipelago/src/crash_recovery.rs index 0aaa9e7e..a41e78f5 100644 --- a/core/archipelago/src/crash_recovery.rs +++ b/core/archipelago/src/crash_recovery.rs @@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove pending_boot_starts_add(containers.iter().map(|r| r.name.clone())); for (i, record) in containers.iter().enumerate() { + // Skip containers that are already up — `podman start` on a running + // container produces the noisy benign conmon "Failed to create + // container" + cgroup Permission-denied journal pair (fleet log + // sweep 2026-07-22). + if container_state(&record.name).await.as_deref() == Some("running") { + report.recovered += 1; + continue; + } info!( "Recovering container: {} (image: {})", record.name, record.image @@ -936,14 +944,21 @@ async fn container_state(container: &str) -> Option { } async fn start_existing_container(container: &str) -> bool { - info!("Recovering stack container: {}", container); let timeout = match container { "immich_server" | "netbird-server" => Duration::from_secs(120), _ => Duration::from_secs(90), }; - if container_state(container).await.as_deref() == Some("initialized") { - cleanup_container_runtime_state(container).await; + match container_state(container).await.as_deref() { + // Already up — `podman start` on a running container makes conmon + // try to join the live cgroup and fail with a scary "Failed to + // create container: exit status 1" + cgroup.procs Permission denied + // pair in the journal. Fleet log sweep 2026-07-22 found hundreds of + // these per day per node, all benign. Skip instead. + Some("running") => return true, + Some("initialized") => cleanup_container_runtime_state(container).await, + _ => {} } + info!("Recovering stack container: {}", container); match podman_output(&["start", container], timeout).await { Ok(output) if output.status.success() => { tokio::time::sleep(Duration::from_secs(3)).await; From bfd8bc5b9aa14dea3014c245ffbf309ee685d67e Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 20:02:35 -0400 Subject: [PATCH 09/45] fix(wallet): channel funding-tx link uses the explorer flow + consent modal above nested modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Channels panel's 'Funding tx in Mempool' link still hardcoded the local app (missed in the explorer rollout): now it routes like every other tx link — local Mempool when running, else the saved external explorer, with the first-time consent modal setting it up and then opening the tx. The consent modal moves to z-3600 so it renders above the Wallet Settings modal that can trigger it. Co-Authored-By: Claude Fable 5 --- neode-ui/src/components/ExternalExplorerModal.vue | 4 ++++ neode-ui/src/components/LightningChannelsPanel.vue | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/neode-ui/src/components/ExternalExplorerModal.vue b/neode-ui/src/components/ExternalExplorerModal.vue index 00542a44..59af1d7c 100644 --- a/neode-ui/src/components/ExternalExplorerModal.vue +++ b/neode-ui/src/components/ExternalExplorerModal.vue @@ -1,8 +1,12 @@