Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 7bea2f254d fix(server): stop blocking network/SSH calls from freezing the async runtime
Root cause of the actual, ongoing outage today (distinct from the connection-
semaphore issue fixed earlier on this branch): several RPC handlers ran
blocking, synchronous I/O directly inside async fns with no real await point,
occupying a tokio worker thread for the full duration of the call:

- network::router::check_upnp_available/check_tor_connectivity/check_dns —
  blocking std::net socket calls (check_dns had no timeout at all).
- api::rpc::openwrt's 5 handlers (get-status, provision-tollgate, scan-wifi,
  configure-wan, scan) — Router::connect_password uses plain
  std::net::TcpStream::connect with NO timeout, wrapped in a fully
  synchronous ssh2 session (connect, handshake, run commands).

Confirmed live via gdb: 3 of 4 tokio worker threads simultaneously blocked
in TcpStream::connect -> Router::connect_password, called from
openwrt.get-status. The frontend's home-dashboard polling loop calls
openwrt.get-status periodically (refreshTollgate); once the configured
router became unreachable (physical relocation to a new network today),
every poll hung for the OS's default multi-minute TCP connect timeout,
and polls arrived faster than they timed out — so stuck attempts
accumulated until every worker thread was blocked and the entire
process (not just openwrt/network calls — every RPC method, since they
share the same small worker pool) stopped responding.

Fix: move all the blocking I/O onto tokio's blocking pool via
spawn_blocking (with an explicit timeout added to check_dns, since unlike
the others it had no bound of its own). Added a regression test
(network::router::blocking_io_tests) proving a cheap concurrent task is
no longer starved while these checks run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 14:23:58 +00:00
ssmithxandClaude Sonnet 5 fac2682268 fix(server): stop federation peer congestion from freezing the web UI
The main (5678) and FIPS peer/federation (5679) listeners shared one
1024-permit connection semaphore, and accept_loop awaited that permit
before spawning the connection handler — inside the accept loop itself.
When federation peer connections piled up in CLOSE-WAIT without ever
completing, they exhausted the shared pool and froze accept() entirely,
taking the web UI down with them (production outage, 2026-07-24: no
login possible, auth.isSetup/server.echo hung indefinitely even hitting
the backend directly).

Two changes:
- Acquire the permit inside the spawned task (with a bounded 30s wait)
  instead of in the accept loop, so a saturated pool can no longer
  block accept() from continuing to accept new connections.
- Give the peer listener its own semaphore, separate from the main
  listener's, so peer/federation congestion can never starve local
  web UI connections again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 12:26:06 +00:00
ssmithx dcb0618012 Merge remote-tracking branch 'origin/main' into archy-hwconfig 2026-07-23 23:55:40 +00:00
ssmithx 8403f2233e probing issues with flashing 2026-07-23 23:43:09 +00:00
archipelago b28e5f2ef6 Merge remote-tracking branch 'gitea-ai/main' 2026-07-23 19:13:20 -04:00
archipelagoandClaude Fable 5 3037e9808e docs(handoff): app direct ports over mesh — DONE, mesh-verified
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:12:46 -04:00
lfg2025 881b9ee0bf Merge pull request 'feat(companion): instant off-LAN startup + same-node apps stay in-app' (#114) from feat/fast-start-same-node into main
Demo images / Build & push demo images (push) Successful in 3m2s
2026-07-23 23:12:15 +00:00
DorianandClaude Fable 5 5ba3c161c4 feat(companion): instant off-LAN startup + same-node apps stay in-app
Two field reports from tonight's 5G session:

- 'Stuck connecting': the kiosk WebView always loaded the LAN URL first,
  and Chromium burns a minute+ of connect retries on a dead LAN IP before
  the mesh fallback fires. WebViewScreen now races a raw TCP probe — LAN
  gets 2.5s, else the mesh ULA (patient, 12s) — BEFORE creating the
  WebView, so startup lands on the right origin in seconds either side.
  Retry re-races instead of blindly reloading the LAN URL.
- Pine/Home Assistant/BTCPay opened in the external browser over the
  mesh: same-node detection compared one host, but the node has two (LAN
  origin + mesh ULA) — kiosk loaded via the ULA meant app links failed
  isSameHost and routeOutbound bounced them to the browser. Same-node now
  matches either address, in the kiosk router, SSL allowances and the
  InAppBrowser.

Served APK: 0.5.6 (vc26). Note: the binary also carries the in-flight
listen_port groundwork from the dev-box session's archy-fips-core WIP
(compiles clean, default posture unchanged); its source lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:11:33 +01:00
archipelagoandClaude Fable 5 2ad57c63f1 feat(mesh): app direct ports reachable over the mesh — IPv4 listeners mirrored onto [::]
The companion reaches the node at its fips0 ULA, and the web UI builds app
links as http://[<ULA>]:<direct port> — but rootless-podman published ports
bind 0.0.0.0 only, so every app URL was refused over the mesh (:8334 first).
A reconcile loop in the backend mirrors public IPv4 listeners: any port
>=1024 on 0.0.0.0 without an IPv6 any-listener gets a v6-ONLY [::] forwarder
to 127.0.0.1, following /proc/net/tcp* so install/remove and hardcoded
companion ports are covered without touching a single container. Purely
additive: IPv4/LAN/Tor access paths are untouched, v6only listeners cannot
collide with or intercept v4 traffic, and foreign IPv6 listeners win.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:09:14 -04:00
lfg2025 6c48de20e3 Merge pull request 'docs(handoff): app direct ports are IPv4-only over the mesh — node-side task' (#113) from docs/mesh-app-ports-v2 into main 2026-07-23 22:56:40 +00:00
DorianandClaude Fable 5 e17de63016 docs(handoff): app direct ports are IPv4-only over the mesh — node-side task
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:55:43 +01:00
archipelagoandClaude Fable 5 6ba4540c53 Merge vc25 (PR #111) — handoff reconciled: vps2 daemon restart resolved the mesh path; private-tree withdrawn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:15 -04:00
archipelagoandClaude Fable 5 5454bed038 docs(handoff): RESOLVED — vps2 daemon degradation was the mesh blocker; privatization withdrawn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:40:51 -04:00
lfg2025 128b78d083 Merge pull request 'feat(companion): 60s mesh probe window + session pre-warm from the VPN service' (#111) from feat/mesh-probe-window-prewarm into main
Demo images / Build & push demo images (push) Successful in 2m47s
2026-07-23 22:36:45 +00:00
DorianandClaude Fable 5 75ecd1d3ea feat(companion): 60s mesh probe window + session pre-warm from the VPN service
Implements the app-side recommendations from the node diagnosis
(HANDOFF-2026-07-23): first session through the public tree takes 15s+
when it works at all, far beyond the old ~8s probe window.

- connect(): mesh ULA probed inside a 60s budget, 15s per-phase timeouts.
- ArchyVpnService: session warmer probes every saved node ULA as soon as
  the tunnel is up (5s cadence first minute, then 60s keep-warm) so the
  UI hits a warm session and it never idles out.
- Served APK: 0.5.5 (vc25); handoff updated with 25-ping evidence that
  public-tree discovery currently fails outright (supports the
  private-tree infra decision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:36:34 +01:00
archipelagoandClaude Fable 5 82fcccd113 docs(handoff): node-side mesh diagnosis — nginx v6 fixed, real blocker is session path quality
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:25:18 -04:00
archipelagoandClaude Fable 5 1e89362e71 fix(nginx): IPv6 listeners — companion mesh HTTP to the node's ULA never connected
Phones on the FIPS mesh reach the node at http://[<fips0 ULA>], but every
shipped nginx config listened on IPv4 only — nothing answered [::]:80/443,
so mesh HTTP failed with the tunnel fully healthy (found live on
framework-pt during the vc24 5G session, 2026-07-23). Canonical conf gains
listen [::] lines and patch_nginx_conf self-heals deployed nodes (covers
the sites-enabled -> archipelago-http dev variant via the existing
canonicalize pass); validated by the existing nginx -t + rollback flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:57:58 -04:00
lfg2025 908e6185c8 Merge pull request 'fix(companion): TUN reader no longer dies on non-blocking fd + VPN not metered' (#110) from fix/tun-blocking-fd-metered into main
Demo images / Build & push demo images (push) Successful in 2m54s
2026-07-23 21:51:11 +00:00
DorianandClaude Fable 5 0064cfccac chore(companion): lockfile for libc dep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:48:42 +01:00
DorianandClaude Fable 5 53037b2b71 fix(companion): TUN reader no longer dies on non-blocking fd + VPN not metered
On-device diagnosis (phone on adb, 5G): mesh sessions established but no
packet ever entered the tunnel — Android hands the VpnService TUN fd over
non-blocking, and the fips fork's dedicated blocking-read thread treats
EAGAIN as fatal, dying at startup. archy-fips-core now forces the fd into
blocking mode before Node::start_with_tun_fd. Verified on-device: reader
survives, packets flow into the mesh, and the 30s anchor-link flap is
gone (stable 8+ min on 5G).

Also setMetered(false): Android 10+ defaults VPNs to metered, flipping
the phone into data-restricted behaviour whenever the mesh is up.

Served APK: 0.5.4 (vc24). Handoff updated with the remaining node-side
blocker: phone->anchor works, phone->node ULA gets no reply — needs
fipsctl introspection on Framework PT (suspect fork/daemon session or
routing mismatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:47:26 +01:00
ssmithx 078f3b3619 pulled in main 2026-07-23 21:42:02 +00:00
ssmithx 4222a8507c Merge remote-tracking branch 'origin/main' into archy-hwconfig 2026-07-23 21:40:06 +00:00
lfg2025 db2cafc657 Merge pull request 'fix(companion): guarantee dual-path peering — LAN p2p + public anchor, always' (#109) from fix/guaranteed-anchor-peering into main
Demo images / Build & push demo images (push) Successful in 2m56s
2026-07-23 21:18:00 +00:00
DorianandClaude Fable 5 e49af6ff40 fix(companion): guarantee dual-path peering — LAN p2p + public anchor, always
The whole point of FIPS: the phone peers with the node's npub using the
LAN endpoint as a direct p2p dial hint AND rendezvouses via a public
anchor when away — neither LAN nor 5G location may matter. The anchor
half only existed when the scanned QR carried fanchors, so pairing
against an older node left the phone LAN-only and everything died on 5G.

The Archipelago vps2 anchor (npub + 146.59.87.168:8444/tcp, lockstep
with core fips/anchors.rs) is now baked into upsertNodePeer as a
guaranteed peer. Mesh connect retry window widened so a first-ever
pairing survives the VPN consent dialog. Served APK: 0.5.3 (vc23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:17:24 +01:00
lfg2025 a865306488 Merge pull request 'fix(companion): mesh VPN no longer blocks IPv4 + off-LAN connect via mesh ULA' (#108) from fix/mesh-vpn-and-offlan-connect into main
Demo images / Build & push demo images (push) Successful in 2m54s
2026-07-23 21:00:39 +00:00
DorianandClaude Fable 5 979113c598 fix(companion): mesh VPN no longer blocks IPv4 + off-LAN connect via mesh ULA
Field test on 5G (Framework PT): pairing scanned fine but connect
hard-failed on the scanned LAN IP, and enabling the mesh VPN killed the
phone's internet.

- ArchyVpnService: the TUN is IPv6-only, and Android blocks every
  address family the VPN holds no address for — allowFamily(AF_INET)
  (+ allowBypass) so normal traffic flows while only fd00::/8 is routed.
- ServerConnectScreen.connect(): when the LAN address doesn't answer and
  the entry carries a mesh ULA, bring the tunnel up (autoStartIfReady)
  and probe the ULA with retries before reporting failure — the scanned
  IP is a dial hint, the npub/ULA is the identity (npub-first contract).
- Served companion APK refreshed as 0.5.2 (vc22); handoff doc updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:00:15 +01:00
lfg2025 420f756947 Merge pull request 'chore(android): companion 0.5.1 (vc21) — refresh served APK + deploy handoff' (#107) from chore/companion-apk-0.5.1 into main
Demo images / Build & push demo images (push) Successful in 2m57s
2026-07-23 20:55:28 +00:00
DorianandClaude Fable 5 35849c88c6 chore(android): companion 0.5.1 (vc21) — refresh served APK + deploy handoff
The APK served from the pairing QR predated today's scanner fixes, so
the download-then-scan flow still hit the laggy scanner. Bumps the
version so phones update in place, refreshes the served artifact
(signed v1+v2+v3, verified), and adds the archi-dev-box deploy handoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:53:29 +01:00
lfg2025 08927b88a8 Merge pull request 'feat(companion): saved servers keyed by node npub — pairing contract item 1' (#106) from feat/npub-keyed-server-entries into main 2026-07-23 20:47:52 +00:00
archipelago 0b7a1b84e4 Merge remote-tracking branch 'gitea-ai/main'
Demo images / Build & push demo images (push) Successful in 2m51s
2026-07-23 16:47:13 -04:00
DorianandClaude Fable 5 bb4166954a feat(companion): saved servers keyed by node npub — pairing contract item 1
Re-scanning a node after a LAN renumber updated the FIPS peer (npub-
matched) but duplicated the saved-server entry (address-matched).
ServerEntry now carries the node's fnpub as a trailing serialization
field (legacy entries still parse) and every saved/active-entry match
goes through sameNode: npub identity first, address/port/scheme only as
the LAN-only fallback. Edit forms that don't carry the npub keep the
stored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:47:10 +01:00
archipelagoandClaude Fable 5 ed2c14c88a fix(companion): pairing QR scannable again — cap anchors at 2, EC level L, 768px source
Three npub-bearing anchors pushed the pairing URI to ~600 chars (QR ~v23 at
EC-M) — phone cameras couldn't lock onto it off a screen. The phone only
needs the node itself (LAN p2p) plus one public rendezvous (vps2 preferred),
and screen scans don't need EC-M's damage tolerance — L drops a full
version tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:46:51 -04:00
lfg2025 42e98d6a86 Merge pull request 'fix(ci): demo-images path filter watched .github, file lives in .gitea' (#105) from fix/demo-images-path-filter into main
Demo images / Build & push demo images (push) Successful in 3m4s
2026-07-23 20:30:13 +00:00
lfg2025andClaude Fable 5 3e5feebd49 fix(ci): demo-images path filter watched .github, file lives in .gitea
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:29:48 +00:00
archipelagoandClaude Fable 5 4199c43cc9 Merge gitea-ai/main — companion native-scan PR #104 (newer scanner rev wins in WalletScanModal)
Demo images / Build & push demo images (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:25:02 -04:00
archipelagoandClaude Fable 5 54ddd043bd feat(tv-input): gamepad->keyboard bridge daemon + controller-navigable modals
- archipelago-gamepad-keys: evdev->uinput daemon (pure python3 stdlib) that
  mirrors any attached controller as a virtual keyboard — D-pad/stick to
  arrows, A/B to Enter/Escape, X/Y to Space/f, shoulders to Tab/Shift+Tab.
  The browser sees real keys, so controllers work inside EVERY app iframe
  (IndeeHub, Jellyfin…) with zero per-app code. Ships via the ISO splice and
  bootstrap self-heal (kiosk nodes only), hotplug via 5s rescans.
  Implements docs/tv-input-iframe-apps.md.
- useControllerNav: an open [role=dialog][aria-modal] owns navigation —
  focus is pulled inside, arrows move spatially between its controls, Enter
  activates, Escape stays with the modal's own close handling. One standard
  mapping for every modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:17:11 -04:00
archipelagoandClaude Fable 5 402183c0ae feat(appsession): mode switches never reload the iframe + strictly per-app display modes + TV focus
- The session subtree now ALWAYS lives under <body>; inline panel mode is
  emulated with a fixed-position rect synced from the layout placeholder
  (ResizeObserver + resize), so toggling panel/overlay/fullscreen no longer
  re-parents — and therefore no longer reloads — the app iframe.
- Display modes are strictly per-app: saved per-app pick → app default
  (IndeeHub: fullscreen) → panel. The global fallback is gone, so one app's
  mode can never change how another opens.
- The iframe takes focus when the app loads, so keyboard / the gamepad
  bridge work inside it immediately (TV).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:15:43 -04:00
archipelagoandClaude Fable 5 80875a2ed4 feat(wallet): invoice-first lightning send — amount locks from the pasted invoice
A pasted fixed-amount invoice auto-fills and locks the amount ('set by
invoice' — nothing to type, just review and send); zero-amount invoices get
an explicit 'enter how many sats' hint. Plus a clipboard Paste button on the
destination field (hidden where clipboard read can't work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:15:43 -04:00
archipelagoandClaude Fable 5 f72d4b92ac feat(content): seller-picked payment methods + music always in the bottom bar + video PiP
- Paid sharing: AccessControl::Paid gains an accepted-methods list
  (lightning/onchain/ecash/fedimint; empty = all, back-compat). Sellers pick
  methods in ShareModal, gated on what the node can actually receive (LND
  running/channel open, ecash wallet, fedimint joined) with an ⓘ that
  explains exactly how to enable a missing rail. Enforced server-side (the
  invoice/onchain mints refuse non-accepted methods; the serve gate only
  honors tokens/hashes for accepted rails) and the buyer's pay modal only
  offers what the seller accepts.
- Purchased music: the two remaining lightbox paths now use the bottom-bar
  player — the immediate post-ecash-purchase viewer and the Paid Files
  tab's window.open.
- Picture-in-picture buttons on the peer video player and the cloud media
  lightbox (utils/pip.ts; Chromium/Safari, no-op elsewhere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:15:11 -04:00
lfg2025 5d2c28da7b Merge pull request 'Companion: three-finger menu gesture, native wallet QR scanner, scan/upload chooser' (#104) from feat/companion-3finger-native-scan into main
Demo images / Build & push demo images (push) Failing after 33s
2026-07-23 20:01:08 +00:00
DorianandClaude Fable 5 fbed32f95d feat(wallet): hand live scanning to the companion's native camera when present
The scan/upload chooser (already on main) keeps the first move; when the
Android companion's window.ArchipelagoQr bridge exists, 'Scan with
camera' opens the native CameraX modal instead of getUserMedia — which
the companion's plain-http origin doesn't even have, so the button now
shows there too. Decodes come back via window.__archyQrResult, status
lines mirror out via setStatus, and stopScanning closes the native
modal once a code is accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:00:28 +01:00
archipelagoandClaude Fable 5 d5fc3d01a4 Merge companion native-scan branch — scan/upload choose pane supersedes today's interstitial
Another agent's feat/companion-3finger-native-scan: native wallet QR scanner
in the companion (WalletQrScannerModal.kt), three-finger menu gesture,
WebView file uploads, and the same every-open scan/upload chooser as a
dedicated pane with native-scanner handoff. Conflict in WalletScanModal.vue
resolved in the branch's favor — its choose-pane implementation replaces the
in-pane interstitial from 6502f13e (same UX, plus the native handoff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:58:17 -04:00
DorianandClaude Fable 5 87c324e36c feat(companion): native wallet QR scanner, gesture hint overlay, WebView file uploads
- WalletQrScannerModal: native CameraX+zxing scanner styled like the web
  wallet modal, with an upload-image decode path; opened by the web UI
  through the new window.ArchipelagoQr JS bridge. Decodes stream back via
  window.__archyQrResult; the page mirrors status lines out and closes
  the modal when it accepts a code. Detection/spend logic stays in the
  web modal.
- onShowFileChooser: <input type=file> was silently ignored by the
  WebView — file pickers (incl. the wallet's upload option) now work.
- GestureHintOverlay: one-time animation teaching the three-finger hold,
  armed ~2 minutes after login (gesture_hint_seen flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:56:01 +01:00
DorianandClaude Fable 5 15954aec14 fix(companion): smooth camera preview + no flash on scanner open
ZXing TRY_HARDER (plus the inverted retry) ran on every camera frame,
pegging a core — that CPU contention is what made the preview stutter.
Decode attempts are now gated to ~7/s; KEEP_ONLY_LATEST drops the rest.
PreviewView switches to TextureView (COMPATIBLE): the SurfaceView
default punches a window hole that black-flashes inside Compose fades
and ignores rounded-corner clipping. CameraQrPreview is now shared with
the wallet scan modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:56:01 +01:00
DorianandClaude Fable 5 1df075f7b1 feat(companion): menu gesture is now a three-finger hold
Two-finger hold collided with two-finger scrolling — any scroll longer
than 500ms popped the NESMenu. Three fingers hold for the menu; two
fingers scroll, on the trackpad, both controllers and the gamepad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:56:01 +01:00
DorianandClaude Fable 5 ff4013bd3b feat(wallet): scan/upload chooser on every open + native scanner handoff
The scan modal now always lands on a chooser pane — Scan with camera /
Upload image / paste — instead of auto-starting the camera. When the
Android companion's ArchipelagoQr bridge is present, live scanning is
delegated to the native camera modal (WebView getUserMedia lags);
animated-QR progress and errors mirror onto it via setStatus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:52:42 +01:00
DorianandClaude Fable 5 37c8992d21 feat(companion): native wallet QR scanner, gesture hint overlay, WebView file uploads
- WalletQrScannerModal: native CameraX+zxing scanner styled like the web
  wallet modal, with an upload-image decode path; opened by the web UI
  through the new window.ArchipelagoQr JS bridge. Decodes stream back via
  window.__archyQrResult; the page mirrors status lines out and closes
  the modal when it accepts a code. Detection/spend logic stays in the
  web modal.
- onShowFileChooser: <input type=file> was silently ignored by the
  WebView — file pickers (incl. the wallet's upload option) now work.
- GestureHintOverlay: one-time animation teaching the three-finger hold,
  armed ~2 minutes after login (gesture_hint_seen flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:52:42 +01:00
DorianandClaude Fable 5 1ecb5c9aa3 fix(companion): smooth camera preview + no flash on scanner open
ZXing TRY_HARDER (plus the inverted retry) ran on every camera frame,
pegging a core — that CPU contention is what made the preview stutter.
Decode attempts are now gated to ~7/s; KEEP_ONLY_LATEST drops the rest.
PreviewView switches to TextureView (COMPATIBLE): the SurfaceView
default punches a window hole that black-flashes inside Compose fades
and ignores rounded-corner clipping. CameraQrPreview is now shared with
the wallet scan modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:51:57 +01:00
DorianandClaude Fable 5 5bae8273a2 feat(companion): menu gesture is now a three-finger hold
Two-finger hold collided with two-finger scrolling — any scroll longer
than 500ms popped the NESMenu. Three fingers hold for the menu; two
fingers scroll, on the trackpad, both controllers and the gamepad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:51:49 +01:00
archipelagoandClaude Fable 5 265196bc2c docs: TV input design — keyboard/gamepad inside iframe apps, globally
Demo images / Build & push demo images (push) Failing after 36s
evdev->uinput gamepad-as-keyboard daemon on kiosk nodes (works in any
iframe, any origin) + shell-side focus handoff via useControllerNav;
CDP/per-app injection rejected. Implementation order included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:31:03 -04:00
archipelagoandClaude Fable 5 9f1daa0f8c feat(ui): per-app display-mode defaults (IndeeHub fullscreen) + gamepad plays media + node list scales
- Per-app default display mode: explicit user pick (remembered per app) ->
  app default -> last global -> panel; IndeeHub defaults to fullscreen.
- data-controller-primary: a media file card's gamepad-select clicks the
  card (audio -> bottom-bar player, video -> lightbox) instead of the
  card's download link.
- Discovered-nodes list scales with the viewport (max(14rem,45vh)) instead
  of cramming a long list into a fixed 224px box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:31:02 -04:00
archipelagoandClaude Fable 5 960e41e164 fix(content): audio always plays in the bottom-bar player + ecash purchase is explicit two-step
- Owned/purchased and cloud audio never opens a lightbox: PeerFiles'
  owned-content viewer and the Cloud/CloudFolder preview route audio to the
  global bottom-bar player (video keeps the lightbox).
- Web5 shared-content 'Buy' no longer fires the node-ecash payment on
  click: it opens a confirmation with balance -> price -> balance-after,
  and pays only on explicit confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:31:01 -04:00
archipelagoandClaude Fable 5 6502f13e29 feat(wallet): paste-send confirmation step + every-time scan/upload chooser
- Pasting an invoice/address now gets the same second-step confirmation the
  scan flow has: method, destination, live balance -> amount -> balance
  after (invoice-fixed amounts win over the typed one), for every rail
  including token minting. Nothing pays until Confirm & Send.
- The scan modal opens on a chooser every time: scan with camera or
  upload/take a photo of the QR — the camera no longer auto-starts, and
  Back only relights it if camera was the user's choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:42 -04:00
archipelagoandClaude Fable 5 c58f84c6e9 feat(companion): npub-first pairing — node self-anchor + guaranteed public anchor in the QR
The pairing QR was effectively IP-first: the phone dialed the scanned LAN
host and went dark when the LAN renumbered or it left home. FIPS peers on
npubs; IPs are only dial hints. fanchors now leads with the paired node
ITSELF (fnpub @ current host, npub-keyed so LAN contact is direct p2p over
FIPS and survives DHCP), and fips.pair-info always includes the Archipelago
vps2 public anchor (pairing hint only — the node's own anchor file is not
modified) so the phone can rendezvous through the public mesh when away.
Contract doc updated with the REQUIRED app-side changes (built on the Mac).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:40 -04:00
archipelagoandClaude Fable 5 e59ea1da69 fix(mesh): never show mojibake device names — strict decode with readable fallback
Name fields sit at firmware-version-dependent offsets; when an offset lands
in binary data, from_utf8_lossy handed the UI replacement-character soup in
the mesh modals and settings panel. decode_mesh_name(): strict UTF-8 to the
first NUL, no control chars, else a readable fallback (short pubkey /
node-<id>).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 -04:00
archipelagoandClaude Fable 5 be6f06a573 fix(kiosk): overlay scrollbars — no more fat X11 scrollbar on scrollable views
--enable-features=OverlayScrollbar gives the thin auto-hiding scrollbars a
remote browser shows (Peers view had a permanent classic scrollbar on the
TV). Rides the existing kiosk self-heal + ISO splice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 -04:00
archipelagoandClaude Fable 5 8a450bb8f8 feat(audio): HDMI audio works out of the box — pipewire in ISO + router daemon + ELD boot-race heal
Root cause of silent HDMI on kiosk TVs (framework-pt, LG TV):
1) fresh ISOs installed NO audio stack even though the kiosk launcher
   expects PipeWire-Pulse — add pipewire/pipewire-pulse/pipewire-alsa/
   wireplumber/alsa-utils to the rootfs and put the archipelago user in
   'audio' (linger user manager gets no logind seat ACLs on /dev/snd);
2) the kiosk's boot-time Xorg modeset races the i915→HDA audio-component
   bind, the ELD notify is lost, and every HDMI profile stays
   'available: no' forever. Verified live: one off/on modeset re-delivers
   the ELD and WirePlumber immediately switches to HDMI.

New archipelago-audio-router daemon (same splice-from-configs pattern as
the kiosk, ISO + include_str! self-heal): routes the card to the best
available HDMI profile, keeps the default sink on it, migrates live
streams on hot-plug, unmutes (HDMI forced 100% — the TV owns volume), and
re-modesets a connected external output once when no ELD reports a
monitor. bootstrap::ensure_audio_stack() installs packages + router on
already-deployed kiosk nodes via OTA (apt through systemd-run — the
service sandbox keeps /usr and dpkg read-only). main.rs also spawns the
pine satellite keeper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:28:47 -04:00
archipelagoandClaude Fable 5 852ffc5b18 feat(pine): wyoming satellite keeper — re-point speaker entries when DHCP strands them
HA stores a voice satellite as a pinned LAN IP and never re-resolves; when
the LAN renumbers (framework-pt: entry at 192.168.1.241, LAN now
192.168.63.0/24) the speaker silently drops forever. A periodic keeper
probes IP-pinned wyoming entries, sweeps the node's local /24s for the
satellite's port when one dies, rewrites core.config_entries (HA stopped
around the edit so its shutdown flush can't clobber it) and starts HA again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:28:07 -04:00
ssmithxandClaude Sonnet 5 5799c37111 fix(mesh): DTR/RTS reset settle time was far shorter than real boot time
Every one of Reticulum/Meshcore/Meshtastic's open() deasserts DTR/RTS on
every connection attempt (needed to clear stale line state, but the
transition itself resets ESP32-S3 native-USB boards and CP2102/CH340-
bridged boards wired for Arduino-style auto-reset — acknowledged in the
existing code comments). Each only waited 300ms before expecting a
handshake response — nowhere near real firmware boot time (LoRa radio
init alone routinely takes longer).

A single auto-detect cycle tries multiple protocols in sequence
(Reticulum, then Meshcore, then Meshtastic), each with its own open() and
thus its own reset. With only 300ms of settle per attempt, a board could
plausibly never finish booting from one attempt's reset before the next
attempt's open() reset it again — a self-sustaining "never finishes
booting" loop that would look identical to firmware/hardware flakiness
from the logs, regardless of which firmware family was actually flashed.
Confirmed live 2026-07-23 on both a Heltec V3 (Meshtastic) and V4
(Meshcore): continuous device-side FROM_RADIO_REBOOTED / boot-loop
symptoms with zero config-write-triggered reboots (manage_radio was false
for part of the test), pointing at the connection layer itself rather
than firmware config provisioning.

Bumped the settle delay from 300ms to 2s in Meshcore's and Meshtastic's
open() — full protocol handshakes that need real boot time. Left
reticulum.rs's probe_rnode settle at 300ms deliberately: it's a
cheap/fast KISS-detect gate designed to fail quickly for non-RNode
firmware (documented elsewhere as "~1s"), not a full handshake, and each
subsequent protocol's own open()+settle is what actually needs to cover
real boot time regardless of what probe_rnode did moments before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:10:06 +00:00
ssmithxandClaude Sonnet 5 b14af20d1a fix(mesh): orphaned listener task could race a fresh one on the same port
Traced why a device that's alive and USB-enumerating correctly could still
never complete a single protocol handshake, indefinitely: journal logs
showed genuinely concurrent connection attempts on the same port
(duplicate "Opened serial port"/"Starting X handshake" lines within
microseconds of each other, from what should be sequential probe steps) —
two independent listener sessions were racing on the same tty, each
corrupting the other's reads/writes.

Root cause was in MeshService::stop() combined with mesh::flash's earlier
STOP_LISTENER_TIMEOUT fix: stop() calls `self.listener_handle.take()`
(clearing the field to None immediately) and then awaits the handle with
no bound of its own. When a caller wraps the whole stop() call in a
timeout (as the flash job does, to avoid hanging the RPC response), a slow
listener — mid multi-candidate probe when the shutdown signal arrives —
would cause that outer timeout to cancel the await. But by then
`listener_handle` was already None, so MeshService believed the listener
was stopped, while the actual task kept running, orphaned (dropping a
JoinHandle does not abort the task). A later start() then spawned a
genuinely new listener, racing the orphaned one forever on the same port.

Fixed at the source: stop() now awaits its own handle through a mutable
reference (not by value) with its own bounded timeout, so on timeout it
still owns the handle and can call .abort() on it directly — guaranteeing
the task is actually gone before stop() returns, regardless of how any
caller wraps it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:39:33 +00:00
ssmithxandClaude Sonnet 5 7547d03166 fix(mesh): configure step after a successful flash could race and fail
Traced a real "Operation failed" on the post-flash configure step: the
flash job's own post-completion code unconditionally called
MeshService::start() after configure(), regardless of the persisted
enabled flag. In this incident, the user had toggled mesh off shortly
before the flash (config.enabled saved as false), but the job's forced
start() ran anyway — leaving the listener actually running while
self.config.enabled stayed false. When the user then clicked "Keep As Is"
on the fresh post-flash probe, their mesh.configure call correctly saw a
false→true transition and tried to start the listener itself, hitting
"Mesh listener already running" — the listener had already been
force-started behind the config's back.

Two fixes:
1. The flash job now only restarts the listener if the freshly-loaded
   config still says enabled — respecting a user's own concurrent choice
   to disable mesh instead of silently overriding it.
2. MeshService::start() is now idempotent: finding the listener already
   running is treated as success, not an error. Ensuring the listener is
   running is what every caller actually wants; whichever caller's start()
   wins a race, the other finding it already satisfied is the correct
   outcome, not a failure to surface to the user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:59:41 +00:00
ssmithxandClaude Sonnet 5 d6019e47a5 fix(mesh): restore esptool's esp32s3 stub instead of routing around it
--erase-all hit the exact same "ROM does not support function erase_flash"
error as a standalone erase_flash — confirmed live: esptool's --erase-all
is implemented as the same full-chip-erase command, not a per-sector loop,
so it needs the stub just as much. The real fix isn't finding another way
around the ROM bootloader's limitations — it's restoring the stub Debian's
package is missing.

Fetched the exact missing file (stub_flasher_32s3.json) from the matching
upstream esptool release tag and installed it directly on archy-x250-dev;
verified live with a read-only `flash_id` that stub mode now loads
("Uploading stub... Running stub..."). Removed --no-stub from flash.rs now
that normal stub-loader mode works correctly (faster and fully-featured vs.
the ROM-only fallback). scripts/self-update.sh now fetches and installs
this same file automatically whenever it's missing, so this isn't a
one-off manual fix tied to a single node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:12:15 +00:00
ssmithxandClaude Sonnet 5 0ca8f25b1b fix(mesh): standalone erase_flash unsupported in --no-stub ROM mode
Real esptool output on a live Heltec V4 exposed the next layer of the
--no-stub fix: "A fatal error occurred: ESP32-S3 ROM does not support
function erase_flash." The ESP32-S3 ROM bootloader only implements
per-sector erase (used internally as write_flash streams data), not a bulk
full-chip-erase opcode — that's a stub-firmware-only feature, and Debian's
esptool package ships without the stub (hence --no-stub in the first
place).

write_flash's own --erase-all flag gets the same "erase everything before
writing" outcome our "always erase before write" default requires, but via
the per-sector mechanism the ROM bootloader actually supports — one esptool
invocation instead of a separate erase_flash + write_flash pair.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 12:28:51 +00:00
ssmithxandClaude Sonnet 5 76d14c3bf9 fix(mesh): flash RPC could hang forever if the listener wouldn't stop
Traced a real "Operation failed" report to its root: MeshService::stop()
ran synchronously inside start_flash_job, BEFORE the background task was
even spawned — so the RPC call itself blocked on it. The mesh listener's
reconnect/multi-candidate-probe loop doesn't check its shutdown signal
between candidates, and in this incident it was mid a Meshcore-then-
Meshtastic probe cycle when the flash request came in, so stop() never
returned. The HTTP request timed out client-side ("connection closed
before message completed" in the server log), the frontend surfaced a
generic "Operation failed", and — worse — the job had already been
inserted into the single-job-guard slot before stop() ran, so with nothing
ever spawned to complete it, every subsequent flash attempt failed with
"already in progress" until a full service restart.

The existing MAX_JOB_DURATION ceiling didn't help here: it only wraps
run_flash(), which is reached AFTER stop() returns — so a hang in stop()
itself was completely unguarded.

Fixed by moving the stop() call inside the spawned background task with
its own 20s timeout. The RPC call now always returns immediately once the
job is registered, and a slow-to-stop listener fails the job cleanly with
a clear message instead of hanging the request and wedging every future
attempt behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:50:34 +00:00
archipelagoandClaude Fable 5 db6003aef6 fix(lightning): actionable expired-invoice error + payment failures reach the user
- payments.rs: an expired invoice can never succeed on retry, so the
  error now says so and tells the payer the way out (ask the recipient
  for a fresh invoice) instead of echoing LND's raw reason.
- middleware.rs: the error sanitizer masked every Lightning payment
  failure as 'Operation failed. Check server logs' — but LND's reasons
  ('invoice expired', 'unable to find a path') are written for the
  payer and are all actionable. 'Payment failed', 'Invalid payment
  request' and 'Missing payment_request' now pass through, with a
  regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 04:51:57 -04:00
archipelagoandClaude Fable 5 b991c18e73 fix(aiui): color-scheme meta keeps iframe transparent so background art survives dark shell
Demo images / Build & push demo images (push) Failing after 34s
Browsers only honor a transparent same-origin iframe canvas when the
embedder and the iframe agree on color-scheme; the neode-ui shell's
:root{color-scheme:dark} was forcing the AIUI frame opaque and hiding
the background art behind the chat.

Also adds neode-ui/vite.preview.config.mts (preview on :8100 against
the mock backend on :5959).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 04:43:42 -04:00
ssmithxandClaude Sonnet 5 a1cb83dfb2 fix(mesh): esptool --no-stub + fix retry's broken --baud arg ordering
The improved error logging from the last fix immediately paid off — real
esptool output on the next attempt showed two distinct bugs:

1. Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
   "stub flasher" blobs (stripped for DFSG compliance), so the default
   stub-loader flash mode fails immediately: FileNotFoundError: ... No such
   file or directory: '.../stub_flasher_32s3.json'. Fixed with --no-stub,
   which talks directly to the ROM bootloader instead — the standard
   workaround for this exact Debian packaging gap.

2. The fallback-baud retry appended `--baud 115200` AFTER the subcommand
   token (erase_flash/write_flash), but esptool requires global flags
   before the subcommand — every retry failed with "esptool: error:
   unrecognized arguments: --baud 115200", so the retry logic added
   earlier never actually got a chance to run. esptool_with_retry now
   builds global args (--chip/--port/--baud/--no-stub) and subcommand args
   separately and always concatenates them in the right order, instead of
   relying on call-site ordering of a single flat arg list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 03:21:39 +00:00
ssmithxandClaude Sonnet 5 ff532465cf fix(mesh): capture actual esptool/rnodeconf output in the failure error
run_streamed's failure path only reported "Command exited with exit status:
N" — the real stderr (already captured line-by-line into job.log_tail for
the UI's live poll) never made it into the error that gets logged to
journald. A real esptool failure just now showed only the bare exit code,
with esptool's actual error text sitting in-memory, visible in the UI but
not diagnosable from the server logs.

Now includes the last 10 log_tail lines in the returned error, so the
actual tool output shows up in journalctl too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:51:58 +00:00
ssmithxandClaude Sonnet 5 39e88529b3 fix(mesh): failed flash silently bounced back to the picker, hiding the error
The step-3 template's condition for showing the picker vs. the progress/
result view was `!flashJob?.active && flashJob?.stage !== 'done'`. That's
true for "no job started yet" (flashJob is null) — but ALSO true for a job
that just FAILED (active:false, stage:'failed'), since 'failed' !== 'done'.
So a failed flash silently reverted to the family/board picker instead of
showing the failure state (error message + log tail) — reported live as
"it went to the download screen and then back to the flash screen" with no
visible error.

Now shows the picker only when no job has been started yet (`!flashJob`);
once a job exists, the progress/result view is always shown, whether it's
still running, succeeded, or failed — the existing error/log-tail markup in
that view already handles all three, it just wasn't being reached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:43:10 +00:00
ssmithxandClaude Sonnet 5 992bf636e0 fix(mesh): board auto-detect used the wrong signal, false-flagged Heltec V3
The "couldn't confirm the board automatically" warning was checking the
display label text (/v3/i, /v4/i) instead of the same vid:pid table the
backend actually uses to resolve the board. A Heltec V3's CP2102 bridge
chip reports "CP2102 USB to UART Bridge Controller" in its USB strings, not
"Heltec", so meshDeviceImages.ts's fallback label ("LoRa radio (CP2102
serial)") never matched the regex — showing the low-confidence warning (and
leaving the board picker empty) even though the backend's
resolve_flash_board can safely auto-detect V3 via vid:pid 10c4:ea60.

Now checks the same vid:pid directly from detected_device_info, matching
mesh::flash::resolve_flash_board exactly. V4 still has no auto-match entry,
same as the backend — its vid:pid (303a:1001) is the ESP32-S3's generic
native-USB descriptor, not V4-specific, so manual selection is still
required there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:31:46 +00:00
ssmithxandClaude Sonnet 5 beff5dd577 fix(mesh): stop the flash-triggered device boot-loop, fix stuck/wedged jobs
Three real incidents from live-testing the flash feature on archy-x250-dev,
each traced through logs on the node and fixed at the root:

- A failed flash left the mesh listener auto-resuming into a reconnect loop
  whose backoff reset to its 5s minimum on any momentary connection, even
  mid-boot-loop — every retry's open() toggles DTR/RTS, which resets many
  ESP32 boards, so the retries were themselves sustaining the loop. Backoff
  now only resets after a session runs stably for 20s+, and the flash job
  no longer auto-resumes the listener after a failure (only on success,
  after a settle delay).

- The HTTP client's blanket 30s request timeout covered entire downloads
  (Meshtastic's zip is ~170MB), killing large transfers mid-stream; fixed
  with a per-chunk stall timeout instead of a fixed total-transfer cap. But
  the download's initial request had no timeout at all, so a slow-to-start
  server hung it forever — wedging the single-flash-job guard permanently
  ("already in progress" on every subsequent attempt). Fixed with a bounded
  wait for the response to start, plus an absolute ceiling around the whole
  job as a last-resort safety net.

- archy-rnodeconf's PyInstaller freeze broke its own internal esptool
  invocation (sys.executable pointed at the frozen binary itself instead of
  a real interpreter) — fixed via a new runtime hook. esptool's own
  auto-reset-into-bootloader handshake is separately known to be flaky on
  some CP2102/CH340 boards; it now retries once at a conservative baud
  rate instead of failing outright, and failure logs show the full error
  chain instead of just the outer context message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:18:55 +00:00
archipelagoandClaude Fable 5 fb72e3e159 fix(fedimint): gateway/guardian follow the running bitcoin container via {{BITCOIN_HOST}}
The gateway crash-looped dialing bitcoind at host.archipelago:8332 (the
host gateway IP, where bitcoind does not listen). Root-cause fix, three
parts:

- fedimint-gateway manifest: --bitcoind-url now comes from
  $FM_BITCOIND_URL, filled by a {{BITCOIN_HOST}} derived-env — works on
  Knots, Core, or any future Bitcoin distro.
- fedimint manifest: same derived-env replaces the hardcoded
  FM_BITCOIND_URL=http://bitcoin-knots:8332 environment entry.
- orchestrator: removed the hardcoded FM_BITCOIND_URL=bitcoin-knots
  override that clobbered the derived-env, and bitcoin_host() now
  accepts any running bitcoin*/bitcoin container (excluding -ui and
  archy-* sidecars), preferring the known names in order.

Catalog regenerated + signed (nodes install from the signed catalog, so
the manifest fixes only reach them via this regen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:07:15 -04:00
archipelagoandClaude Fable 5 f339358109 fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab
Demo images / Build & push demo images (push) Failing after 32s
The double-share/double-pay chain (user hit it live, 2026-07-22):
ShareModal's already-shared lookup compared the slash-stripped stored
path against the leading-slash filepath — never matched — so every
re-share minted a NEW catalog entry with a new id, and the buyer's
owned-guard (keyed by id) saw the duplicate as unowned and paid again.
Three independent walls now: (1) ShareModal normalizes both sides so
re-shares reuse the entry; (2) content_server::add_item dedupes by
filename server-side (updates in place, keeps the id so buyers' owned
records stay valid); (3) the buyer REFUSES to pay for content it
already owns — matched by (onion, content_id) OR (onion, filename) —
and serves the cached copy instead, before any ecash is minted.

Purchases also auto-file into Photos/Music/Documents on the node (same
buckets as the Cloud view, collision-safe naming) so bought files show
up where files live on every device, and Cloud gains a Paid Files tab
listing every purchase (name, size, sats paid, date) with in-app view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:10:48 -04:00
archipelagoandClaude Fable 5 df9b9905b6 feat(wallet): LND wedge watchdog + Fedi send rail + token QRs + Auto hidden
Demo images / Build & push demo images (push) Failing after 33s
- LND watchdog: framework-pt's LND sat wedged 14h (RPC up, server never
  ready, channels inactive) with nothing noticing. 15 consecutive bad
  minutes now bounce the container automatically (30min cooldown) —
  silent multi-hour Lightning outages become self-healed blips.
- Send modal: Auto tab hidden; Ecash splits into Cashu and Fedi (new
  wallet.fedimint-send RPC wrapping the existing spend_from_any); both
  rails render the generated token as a scannable QR alongside the
  copyable text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:55:37 -04:00
archipelagoandClaude Fable 5 e68fe071a7 docs: v1.7.112-alpha changelog + What's New sync + flasher integration point
Demo images / Build & push demo images (push) Failing after 32s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:40:05 -04:00
ssmithx 7d31ca5d65 First commit 2026-07-23 00:33:55 +00:00
archipelagoandClaude Fable 5 04876f3bef feat(iso): bundle archy-reticulum-daemon — RNode radios work on fresh images
The mesh listener spawns /usr/local/bin/archy-reticulum-daemon for
Reticulum sticks, but the ISO never shipped it (framework-pt connected
its RNode only after a hand-copy, 2026-07-22). Bundled from the build
host with a loud warning when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:30:37 -04:00
archipelagoandClaude Fable 5 05fc49820c fix(rpc): stop masking user-actionable wallet errors
'Insufficient balance: need 80 sats, have 0 sats' reached the UI as
'Operation failed. Check server logs.' — the sanitizer allowlist didn't
know wallet errors. Also allowlists the calm Lightning still-starting
notice, which it would have swallowed the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:22:41 -04:00
archipelagoandClaude Fable 5 cbb108a636 fix(wallet): photo QR decode uses native BarcodeDetector first — dense Lightning invoices scan reliably
Demo images / Build & push demo images (push) Failing after 33s
On the companion (plain-http LAN = no secure context = no live camera)
the photo path IS the scanner, and qr-scanner's single wasm pass often
misses the dense QR of a full BOLT11 invoice. Android WebView's native
BarcodeDetector handles them easily — try it first, wasm as fallback,
plus a 'Reading photo…' status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:16:35 -04:00
archipelagoandClaude Fable 5 bfd8bc5b9a fix(wallet): channel funding-tx link uses the explorer flow + consent modal above nested modals
Demo images / Build & push demo images (push) Failing after 35s
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 <noreply@anthropic.com>
2026-07-22 20:02:35 -04:00
archipelagoandClaude Fable 5 6347d16a2f fix(recovery): quiet the fleet logs — skip running containers, back off failed companion repairs
Demo images / Build & push demo images (push) Failing after 32s
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 <noreply@anthropic.com>
2026-07-22 19:44:32 -04:00
archipelagoandClaude Fable 5 b193e64ffb fix(ui): kiosk dropdown flash + never inherit the OS light theme
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 <noreply@anthropic.com>
2026-07-22 19:44:32 -04:00
archipelagoandClaude Fable 5 6a3b46b5a9 fix(handshake): peer requests actually arrive — background poll, unified relays, real send errors
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 <noreply@anthropic.com>
2026-07-22 19:44:32 -04:00
archipelagoandClaude Fable 5 770e3386f4 perf(nodes): Connected Nodes refresh — parallel probes, instant list, 12s health timeout
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 <noreply@anthropic.com>
2026-07-22 19:44:31 -04:00
archipelagoandClaude Fable 5 8f4c32b93f feat(ui): modal contract — pinned title+tabs, scrolling middle, pinned footer
Demo images / Build & push demo images (push) Failing after 33s
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 <noreply@anthropic.com>
2026-07-22 19:08:38 -04:00
archipelagoandClaude Fable 5 30a4343b83 fix(ui): kill console spam — pine/mempool icon 404s + filebrowser 401 loop
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 <noreply@anthropic.com>
2026-07-22 19:08:31 -04:00
archipelagoandClaude Fable 5 cad68eb941 fix(mesh): setup modal re-fires on EVERY plug — dismissals key on plug time
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 <noreply@anthropic.com>
2026-07-22 19:08:18 -04:00
archipelagoandClaude Fable 5 197e351880 fix(companion): pairing QR advertises the LAN address, never a tailnet IP
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 <noreply@anthropic.com>
2026-07-22 19:08:17 -04:00
archipelagoandClaude Fable 5 8213b0aee3 docs: test plan section F — companion pairing set merged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:16:12 -04:00
archipelagoandClaude Fable 5 5f01ec31ed fix(mesh): probe retries across the listener's port-hold windows
Linux double-opens ttys, so a probe racing the reconnect loop corrupted
both handshakes into silence (framework-pt, real Reticulum stick). Three
attempts 7s apart land in the listener's backoff gaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:48:04 -04:00
archipelagoandClaude Fable 5 d86043193b feat(mock): mesh.probe-device + manage_radio for the hot-swap modal demo
Demo images / Build & push demo images (push) Failing after 2m53s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:35:55 -04:00
archipelagoandClaude Fable 5 b705ed7715 docs: combined test plan — wallet/explorer/overlay additions
Demo images / Build & push demo images (push) Successful in 2m53s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:48 -04:00
archipelagoandClaude Fable 5 581dbd6337 fix(install): mempool no longer blocked by a resyncing ElectrumX + slower podman probes tolerated
Two real install failures from .116 today: (1) the hard "ElectrumX must
be running" gate — but mempool-api reconnects to electrum on its own and
a resync can take days, so it's now a warning; (2) a 10s podman-ps probe
timeout tripped by ElectrumX compaction disk load — now 45s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:48 -04:00
archipelagoandClaude Fable 5 b3796546aa fix(ui): apps open ABOVE the modal that launched them
AppLauncherOverlay sat at z-2400 under BaseModal's z-3000, so an app
opened from e.g. the Transactions modal loaded invisibly underneath it.
z-4000 lets the existing enter animation play over the modal; closing
the app returns to the modal you came from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:48 -04:00
archipelagoandClaude Fable 5 c0aef2f03e feat(wallet): external tx-explorer fallback with consent + On-chain settings tab
Pruned nodes can't run the Mempool app, but tx links blindly opened it
anyway. Now: local app when running; otherwise an external explorer
(default tx1138.com) behind a one-time amber consent modal that spells
out what the other server's operator learns (tx of interest + IP) and
lets the user point at their own instance (placeholder mempool.guide).
Wallet Settings gains an On-chain tab (explorer URL + don't-warn toggle);
tabs renamed Cashu/Fedi so five fit in the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:48 -04:00
archipelagoandClaude Fable 5 5e7e928650 feat(wallet): real-time tx push — 0-conf shows in seconds, not on poll
Backend streams LND /v1/transactions/subscribe (fires on mempool arrival
and each confirmation) and nudges the /ws/db revision per event; the Home
wallet card subscribes and refetches (debounced 800ms). An incoming
broadcast now appears while the 30s poll is still asleep. Reconnects
forever with capped backoff when LND is down/locked/absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:47 -04:00
archipelagoandClaude Fable 5 088b3e255a fix(lnd): retry channel opens during LND startup + calm non-red notice
LND's RPC answers before its p2p server finishes loading, so connect/open
during that window failed red with the raw "server is still in the
process of starting". Now: quiet ~30s retry first; if still starting, a
calm amber "still finishing its startup" notice instead of an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:31:47 -04:00
Dorian 97464779d4 chore(android): update companion APK download [skip ci] 2026-07-22 22:06:32 +01:00
DorianandClaude Fable 5 72c1bdd57d test: anchors removal test iterates the full default set
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:06:32 +01:00
DorianandClaude Fable 5 1a30f984d5 chore(companion): commit archy-fips-core Cargo.lock — reproducible mesh builds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:06:32 +01:00
DorianandClaude Fable 5 4b91765cc7 feat(companion): embedded FIPS mesh replaces WireGuard for remote access
- Android/rust/archy-fips-core: leaf-only fips node as a JNI cdylib (fips
  pinned to the fips-native fork rev with VpnService fd support), built by
  gradle via cargo-ndk (arm64), tested on host
- ArchyVpnService: split-tunnel VpnService routing only fd00::/8 (MTU 1280,
  foreground specialUse); FipsManager handles the one-time VPN consent and
  silent auto-start — no settings surface at all
- pairing QR now fully configures the mesh: fnpub/fip/fhost/fudp/ftcp plus
  fanchors (the node's seed-anchor list, npub@addr/transport) so the phone
  can rendezvous through public anchors when the LAN endpoint is unreachable
- default seed anchors gain the two dual-transport join.fips.network test
  anchors (23.182.128.74:443/tcp, 217.77.8.91:443/tcp); anchor adverts are
  Nostr kind-37195 events
- device token rides the password field end-to-end: backend accepts tokens
  wherever it accepts the password, so scan = instant login (WebSocket auth
  + WebView form injection unchanged); token logins skip TOTP
- ServerEntry.meshIp + IPv6-bracketed URLs; WebView retries the mesh address
  on main-frame errors, auto-login and origin checks honor it
- companion v0.5.0 (versionCode 20), arm64 abiFilter; FipsNative.available
  gates everything so non-arm64 still runs as a plain companion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:06:32 +01:00
DorianandClaude Fable 5 b88609e0ff feat: instant companion pairing — device tokens, named QR, FIPS pair-info
- auth.createDeviceToken / listDeviceTokens / revokeDeviceToken RPCs; only
  SHA-256 hashes persist in data_dir/device-tokens.json
- auth.login accepts {token} (same rate limiter, skips TOTP like remember-me)
- pairing QR now carries name (server name, fallback "My Archipelago"),
  tok (instant login), and FIPS mesh params from new fips.pair-info RPC
  (npub, fips0 ULA, transport ports)
- companion onboarding drops the WireGuard install/tunnel screens — remote
  access moves to the FIPS mesh embedded in the companion app
- fix: fips daemon UDP bind now 2121, matching the published container port
  and fleet rosters (was upstream's 8668 — inbound UDP was dead on bridged
  installs, mesh silently rode TCP 8443)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:04:59 +01:00
archipelagoandClaude Fable 5 8b744d377c feat(mesh): radio hot-swap — probe on plug-in, keep-as-is vs apply-our-settings
Demo images / Build & push demo images (push) Failing after 1m49s
Plugging in any LoRa radio now surfaces the device setup modal EVERY time
(dismissals clear on unplug; works while mesh is enabled; 2-poll debounce
so ordinary reconnect blips don't flash it), showing what's currently on
the stick before anything is written:

- mesh.probe-device RPC: identifies the firmware read-only in the same
  Reticulum->Meshcore->Meshtastic order as auto-detect. Meshtastic yields
  region/modem-preset/channels (want_config is a read); MeshCore yields
  name/node-id + firmware version via the previously-unused
  CMD_DEVICE_QUERY; RNode via the bare KISS DETECT (no daemon spawn).
  Path must be a detected candidate port; the live session's port refuses.
- "Keep As Is": manage_radio=false persists in MeshConfig and the session
  skips every on-connect config write (region, channel, PHY params, advert
  name) — the radio runs exactly as flashed, hot-swappable.
- "Set Up with Archipelago Settings": second screen shows the params that
  will be applied (channel, region, the node's persisted RF params - e.g.
  the validated Portugal preset - with the per-region community plan as
  fallback) before writing anything.
- Stale-type fix: disconnect now resets device_type/firmware_version, so
  a swapped stick no longer shows the previous radio's firmware; probed
  kind pins device_kind so the right probe runs first on connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:25:19 -04:00
archipelagoandClaude Fable 5 2f26cb2bc4 fix(mesh): persist message history + send-seq counters across restarts
Both lived only in RAM: every archipelago restart/reboot wiped the whole
chat history (user-reported), and — worse — reset the per-target outbound
sequence counters, so peers' (sender_pubkey, sender_seq) dedup silently
dropped the first messages sent after a reboot as replays.

mesh-messages.json in the data dir (0600 — DM plaintext), restored in
MeshService::new before the listener spawns; a 5s debounced persister
task snapshots at one choke point instead of hooking every mutation path
(store, delivered/transport/encrypted stamps, edits, deletes, prunes).
Atomic write-then-rename; corrupt/missing file skips restore instead of
blocking mesh startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:51:32 -04:00
archipelagoandClaude Fable 5 27331d66e4 perf(pine): whisper --beam-size 1 — ~45% faster STT, same transcripts
Image default is beam 5 on x86 (1 on ARM). Benchmarked on framework-pt
(i5-1135G7, base-int8, real speech): beam 1 transcribes identical text
~45% faster; extra CPU threads made it slower, so only the beam changes.
App revision 3.4.2 > image 3.4.1 so catalog-driven nodes roll the args
change; a 3.4.1-1 suffix would semver-compare LOWER and never ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:51:32 -04:00
archipelagoandClaude Fable 5 a9cd164301 fix(pine): announce the first-ever mesh message + halve announce lag
The seeded announce automation blocked state transitions out of None/
unavailable — but None is exactly the sensor's state before the first
received message (and after the in-memory store restarts empty), so a
fresh node's first DM was silently swallowed (framework-pt 2026-07-22).
Only 'unknown' (the HA-restart marker) still blocks; rest sensors don't
restore state, so restart announce-storms remain impossible. Seeder
upgrades an already-seeded automations.yaml by replacing exactly the v1
clause, leaving user edits untouched.

Sensor scan_interval 30->15s: the sensor only carries the latest message
id, so two messages inside one poll window coalesce into one announce —
halving the window halves both the lag and the coalescing odds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:51:18 -04:00
archipelagoandClaude Fable 5 6171168927 docs: Pine voice command book — every phrase that works, local vs Claude-routed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:58:47 -04:00
archipelagoandClaude Fable 5 7e7b6bd474 fix(neode-ui): instant payments no longer stuck as 'Incoming' forever
Demo images / Build & push demo images (push) Failing after 1m42s
The Home wallet card counted every incoming tx with num_confirmations < 3
in the pulsing 'Incoming N' badge. Lightning history hardcodes 1 conf and
ecash/fedimint/ark receives are normalized to 1 conf, so instant payments
qualified permanently — old receives showed as 'incoming' forever (seen
on .228 with week-old lightning orders).

- On-chain keeps confirmation-based logic (< 3 confs, expires naturally).
- Instant rails (lightning/cashu/fedimint/ark) now surface in the incoming
  panel only for 5 minutes after receipt — the nice arrival moment without
  the perpetual pending state.
- Panel rows: 'Unconfirmed / N conf' badge and mempool link are now
  on-chain-only; instant rows get a rail badge instead.
- Home now polls wallet balances+transactions every 30s (like Web5.vue),
  so pending on-chain receives actually flip to confirmed and the badge
  appears/expires without a manual wallet action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:43:53 -04:00
archipelago 1931371058 chore: release v1.7.111-alpha
Demo images / Build & push demo images (push) Failing after 1m45s
2026-07-22 05:40:40 -04:00
archipelagoandClaude Fable 5 693f4bb947 docs: v1.7.111-alpha changelog + What's New sync
Demo images / Build & push demo images (push) Failing after 1m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 05:04:11 -04:00
archipelagoandClaude Fable 5 bb9ebb1138 fix(pine): availability guards on numeric HA voice sensors
Sensors with a unit (sync %, lightning/onchain sats) rendered the string
'None' when bitcoind is early in IBD or LND is down, which HA rejects
with a ValueError on every 30s scan. Mark them unavailable instead; the
intent scripts already speak a fallback for unavailable states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 05:00:10 -04:00
archipelagoandClaude Fable 5 cc2c06c6dc fix(neode-ui): companion app opens every app in the native WebView, never an iframe
Demo images / Build & push demo images (push) Failing after 1m42s
Regression: only X-Frame-Options apps were routed to the companion's
in-app WebView; iframeable apps fell through to the iframe session, which
is slower on the phone and loses the native back/forward/reload controls.
Now any launch inside the companion (ArchipelagoNative.openInApp bridge
present) goes to the WebView — both openSession and the legacy open()
overlay fallback. Mobile web/PWA keeps the iframe session. With regression
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 04:54:20 -04:00
archipelagoandClaude Fable 5 97fbaf8818 fix(pine): node-status mesh_message is {} instead of null when empty
HA's seeded REST sensor reads attributes via json_attributes_path
"$.mesh_message"; a null there makes HA log a "JSON result was not a
dictionary" warning on every 30s scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 04:54:20 -04:00
archipelagoandClaude Fable 5 2116600e24 fix(pine): write HA bookkeeping fields in seeded config entries
HA's config-entries store already carries the current schema minor_version,
so entries we append are never migrated — a missing created_at is an
unguarded KeyError in config_entries.async_initialize that crash-loops HA
at boot (hit on framework-pt during install verify). Write created_at/
modified_at/discovery_keys/subentries on both the anthropic and wyoming
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 04:54:20 -04:00
archipelagoandClaude Fable 5 72f7c38701 chore(catalog): sign app-catalog — pine 1.3.0 voice stack + bitcoind rpc.conf fix live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:49:19 -04:00
archipelagoandClaude Fable 5 745e180102 fix(quadlet): escape % for systemd specifiers — bitcoind RPC creds were /bin/bash
The creds-off-argv script used printf "rpcuser=%s" in the manifest's
custom_args; quadlet copies Exec= into the generated service's ExecStart,
where systemd expands %s (user's shell) at load time — bitcoind's rpc.conf
came out as rpcuser=/bin/bash and every RPC consumer got 401s on quadlet
nodes (framework-pt: bitcoin-status, HA block-height sensor, LND chain RPC).

Two-layer fix: quadlet.rs now escapes % -> %% in Exec/Entrypoint/HealthCmd/
Environment emission (with regression test), and the bitcoin manifests write
rpc.conf with plain echo so the catalog also heals nodes still running older
binaries. Release catalog regenerated with the fixed scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:47:06 -04:00
archipelagoandClaude Fable 5 317a72aafe fix(federation): presence-sign modal scales and scrolls on small phones
Demo images / Build & push demo images (push) Failing after 1m37s
The info card above the action buttons now shrinks with the viewport and
scrolls internally (min-h-0 + overflow-y-auto in a max-h-full flex column),
keeping Cancel / Sign & Publish always visible. The hero disc and viz ring
scale down on narrow or short screens via a --seg-radius CSS variable so
the animation honors the smaller ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:12:15 -04:00
archipelagoandClaude Fable 5 184257390d feat(pine): 1.3.0 manifests — openwakeword member + live node status on the launcher page
Demo images / Build & push demo images (push) Failing after 1m40s
pine-openwakeword manifest (wyoming-openwakeword 2.1.0, :10400, /custom model
dir for the future Yo Archy model). Pine 1.3.0: launcher page gains a live
node-status card fed by /api/pine/status via a same-origin nginx proxy, and
copy for the new intents / Claude fallback / mesh announcements. Catalogs,
app-session config, drift ids regenerated; release catalog embeds 56
manifests (unsigned until the ceremony). Framework PT test plan in docs/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:59:34 -04:00
archipelagoandClaude Fable 5 e074a117d2 feat(pine): node-status endpoint + Claude voice brain + mesh announcements + wake-word groundwork
- GET /api/pine/status: public tier (version/uptime/bitcoin/mesh facts) for
  the Pine page; bearer-token tier (Lightning balances, latest mesh message)
  for the seeded Home Assistant sensors. Token minted 0600 at
  secrets/pine-status-token; nginx location ships via the bootstrap
  self-heal + canonical ISO conf. Replaces the per-node socat forwarder and
  bitcoind RPC credentials living in HA's configuration.yaml.
- pine_ha seeder: REST sensors + four ask-Archy intents (block height,
  peers, sync %, Lightning balance) with LLM tool descriptions; Claude
  conversation agent (anthropic entry from the node's claude-api-key) set as
  pipeline default with prefer_local_intents so exact phrases stay local and
  fuzzy ones tool-route through Claude; mesh-message announce automation on
  every Assist satellite; bounded config markers with legacy-block migration.
- pine-openwakeword joins the pine stack (all lifecycle sites) — on-node
  wake-word engine groundwork for the custom "Yo Archy" model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:59:34 -04:00
archipelagoandClaude Fable 5 b288be314c feat(federation): presence-signing overlay on discovery enable
Demo images / Build & push demo images (push) Failing after 1m43s
Enabling Nostr discoverability now opens a signer overlay (same visual
language as the app NIP-07 identity picker) before anything is published:
a locked signer row fixed to the node's discovery key — deliberately not
pickable, personal identities are never used — plus the exact signed
content (DID, npub, software version, kind 30078/NIP-33 format) and a
Sign & Publish confirm. Disabling stays immediate. Also seeds the mock
with discovery OFF to match the production default, and defaults
dwnSyncLabel to 'Unknown' when the backend omits sync_status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:55:06 -04:00
archipelagoandClaude Fable 5 72fcf96016 chore(mock): node.nostr-pubkey returns exact real shape (hex + genuine npub)
Demo images / Build & push demo images (push) Failing after 1m38s
The mock previously returned a placeholder string with no nostr_npub, so the
new Federation signing-details panel showed 'loading…' forever against the
mock. Now returns a distinct discovery key — 64-char hex plus its real
NIP-19 bech32 encoding (verified against the official test vector) —
mirroring production where the node discovery key is separate from the
personal identity keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:38:52 -04:00
archipelagoandClaude Fable 5 50d5d4d132 feat(federation): signing-details panel on the Nostr discoverability strip
Demo images / Build & push demo images (push) Failing after 1m41s
Adds a 'Signing details' disclosure showing who signs the presence event —
a locked identity row (styled like the NostrIdentityPicker overlay) fixed to
the node's own discovery key, deliberately not pickable so personal
identities can never be used for node discovery — plus a human-readable
breakdown of the signed content: DID, signing npub, software version, and
the event format (kind 30078, NIP-33 replaceable, d-tag archipelago-node),
with a note that every event carries a NIP-01 Schnorr signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:18:58 -04:00
archipelagoandClaude Fable 5 73923d0ffd feat(ui): mobile home — wallet card moves up under My Apps
Demo images / Build & push demo images (push) Failing after 1m40s
On single-column (mobile) the dashboard cards now order My Apps, Wallet,
Cloud, Network; desktop keeps its existing two-column layout via
lg:order-none resets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:11:39 -04:00
archipelagoandClaude Fable 5 905e9cdb98 chore: Cargo.lock version bump missed in v1.7.110-alpha release commit
Demo images / Build & push demo images (push) Failing after 2m53s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 01:25:55 -04:00
archipelagoandClaude Fable 5 46cfc2ccd7 chore(catalog): sync presentation catalogs — HA 2026.7.3, Pine 1.2.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 01:25:55 -04:00
DorianandClaude Fable 5 a597c1d946 feat: scan-modal polish, Pine 1.2.0 copy, bitcoind RPC creds off argv
Demo images / Build & push demo images (push) Failing after 2m53s
- Wallet scan modal: fallback buttons no longer flash on open (spinner while
  the camera auto-starts, video hidden until live — also kills the Android
  WebView play-glyph), decode rate 10/s -> 4/s (preview visibly lagged on
  phones), clear messages for BOLT12 offers and LNURL/lightning addresses
  (unsupported by the LND backend; full LNURL-pay is a queued feature),
  zero-amount invoices in BIP21 URIs prefill from amount=.
- Pine 1.2.0: launch-page copy reflects the auto-seeded HA wiring (only
  speaker pairing stays manual), adds the ask-Archy hint and the
  silent-answers power-cycle troubleshooting tip (firmware FIFO bug).
- bitcoin-core/knots: rpcuser/rpcpassword move from bitcoind argv (world-
  readable in host ps) to a 0600 config file inside the container tmpfs;
  the salted txrelay rpcauth hash stays on argv. Catalog regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:45:53 +01:00
84bc3d3138 feat(pine): seed Home Assistant voice defaults when Pine + HA are installed
Demo images / Build & push demo images (push) Successful in 2m42s
Out of the box, HA needed manual clicks for each Wyoming engine, a
hand-built Assist pipeline, and knew nothing about the node. Now installing
the Pine stack (or HA, whichever comes second) seeds:

- wyoming config entries for pine-whisper (:10300) and pine-piper (:10200)
- an Assist pipeline wired to stt.faster_whisper + tts.piper, and a repair
  for the legacy 'homeassistant' conversation-agent id that modern HA
  rejects (intent-not-supported on every wake word otherwise)
- 'ask Archy' voice intents (custom_sentences + intent_script), starting
  with block height via a mempool REST sensor when that app is present

Seeding is best-effort, idempotent, and never overwrites an existing store
it can't parse; HA restarts only when something was written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:39:04 +00:00
856708e353 feat(ui): one-click display-mode buttons in the app-session bar (desktop)
Replace the display-mode dropdown with three inline buttons (right panel /
over whole app / fullscreen) so switching iframe modes is a single click.
Active mode is highlighted orange. Mobile keeps its own bar untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:39:04 +00:00
0a99de75f9 fix(pine): bump Home Assistant to 2026.7.3 — satellite keepalive support
HA 2024.1's Wyoming integration predates ping/pong keepalives. The PineVoice
satellite firmware (wyoming 1.7.2) drops connections idle for ~8s, so HA
looped on 'Satellite disconnected' forever and the speaker never worked.
HA 2026.7.3 pings satellites and holds the connection (verified on
framework-pt: link stays ESTABLISHED, wake word reaches Assist).

Catalog regenerated with the bump — needs the signing ceremony before the
origin publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:39:04 +00:00
archipelago aaec25e53b chore: release v1.7.110-alpha
Demo images / Build & push demo images (push) Successful in 2m50s
2026-07-21 14:50:29 -04:00
archipelagoandClaude Fable 5 172b1f3e9c docs: sync What's New modal with v1.7.110-alpha changelog
Demo images / Build & push demo images (push) Successful in 2m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:22:57 -04:00
archipelagoandClaude Fable 5 c7fe688607 docs: changelog for v1.7.110-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:20:16 -04:00
Dorian 61d37c9798 chore(android): update companion apk download
Demo images / Build & push demo images (push) Successful in 2m53s
2026-07-21 18:56:52 +01:00
DorianandClaude Fable 5 d76f91a62f chore(android): bump companion to 0.4.15 (versionCode 19)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 18:56:52 +01:00
archipelagoandClaude Fable 5 24b3d33ac3 fix(ui): Cashu/Ark emoji rendered as tofu boxes on kiosk TVs
Demo images / Build & push demo images (push) Successful in 2m50s
The kiosk image ships no color-emoji font, so the wallet card's 🥜 and 
drew as empty squares on TVs. Swap them for inline stroke SVGs matching
the neighbouring rows (wallet card + scan modal), and add
fonts-noto-color-emoji to the ISO package set so remaining emoji across
the UI (peer files, chat, content) render on future installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:55:36 -04:00
archipelagoandClaude Fable 5 b23f46c3d8 fix(kiosk): companion remote control now reaches kiosk displays
Demo images / Build & push demo images (push) Successful in 2m54s
App.vue skipped starting the browser-side remote relay on kiosks, citing a
system-level xdotool injector that no longer exists — the backend's
remote-input path is relay-only (validate + broadcast). With no consumer,
companion input was silently dropped on TVs. Start the relay on kiosks
like everywhere else; rides the next release + ISO via the web bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:49:10 -04:00
archipelagoandClaude Fable 5 2a376ab275 feat(scan): camera on companion + insecure origins — WebView bridge & photo fallback
Demo images / Build & push demo images (push) Successful in 2m55s
Two gaps kept the wallet QR scanner camera-less outside a desktop browser:

- Companion app: the WebView's default WebChromeClient silently denies
  getUserMedia. Both WebViews (main + in-app overlay) now implement
  onPermissionRequest — granting video capture only, requesting the
  app-level CAMERA permission on first use (manifest already declares it).

- Plain-http origins (mobile web/PWA on a LAN node): browsers hide
  navigator.mediaDevices entirely, no permission can bring it back. New
  "Take photo of QR" fallback uses <input capture=environment> — the
  native camera needs no secure context — and decodes the shot locally
  with qr-scanner's scanImage. Live preview still used when available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:44:13 -04:00
archipelagoandClaude Fable 5 5982fceb7c copy: unify Zeus channel text — Olympus node + on-chain sats limits
Demo images / Build & push demo images (push) Successful in 2m47s
All Zeus channel surfaces now read: "Open a channel with Zeus Olympus
node and start sending and receiving Lightning payments. Minimum
150,000 · maximum 1,500,000 on-chain sats required."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:27:05 -04:00
archipelagoandClaude Fable 5 5951d31637 fix(kiosk): pin AIUI to dark theme on kiosk displays
AIUI persists aiui-theme in localStorage and a stored value beats its
prefers-color-scheme fallback, so kiosk profiles from before the launcher
forced a dark color scheme keep white panels forever. Kiosk boot now pins
the same-origin key to dark, healing existing profiles on next load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:27:04 -04:00
archipelagoandClaude Fable 5 dc55ef8a54 fix(kiosk): restore tab-change animations with 2D transitions
The onboarding-era :global(html.kiosk-mode .view-wrapper) transform:none
rule also matched the dashboard's route wrappers, freezing every list<->
detail depth transition into a jump cut on kiosk. Scope that rule to the
onboarding viewport and give the dashboard kiosk-safe 2D equivalents
(scale + opacity, no translateZ/blur/preserve-3d) that the software
compositor can animate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:27:04 -04:00
archipelagoandClaude Fable 5 97358d2314 feat(kiosk): display-size presets in Settings (Auto/Large/Balanced/Native)
system.kiosk-display.get/set RPCs write /etc/archipelago/kiosk-display.conf
(sourced by the kiosk launcher) and try-restart the kiosk so the choice
applies immediately. The Settings section only appears on nodes that have
the kiosk unit installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:27:04 -04:00
archipelagoandClaude Fable 5 1b78da1d7a feat(lnd): accept amount_sats on lnd.payinvoice for zero-amount invoices
Zero-amount BOLT11 invoices need the payer to supply the amount; forward
it to LND REST as the amt field. The wallet scan modal unlocks its amount
field for such invoices and passes the user's entry through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:15:35 -04:00
archipelagoandClaude Fable 5 0213da3fc5 fix(kiosk): background art rendered black — never paint a background on <html>
Demo images / Build & push demo images (push) Successful in 2m44s
html.kiosk-safe-area set background:#000 on the root element. With a root
background, body's background stops propagating to the canvas and paints
as a normal block background instead — which by CSS paint order covers
negative-z-index descendants. The dashboard/login art lives in
.bg-perspective-container at z-index:-10, so every kiosk display showed
solid black behind the UI while desktop browsers (no root background)
showed the art. Verified live on the Framework 4K TV via CDP: making the
root transparent restores the art instantly. Keep the black on body only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:14:49 -04:00
archipelagoandClaude Fable 5 8968d41ca9 feat(wallet): QR scan modal — camera + animated QR, wired into Home/Send/Receive
New WalletScanModal scans QR codes with the device camera (BarcodeDetector
with jsQR fallback) including animated/multi-frame QRs via qrloop, then
routes what it saw: BOLT11 invoices -> lightning pay (amount locked from
the invoice when present), bitcoin:/BIP21 URIs -> on-chain send, Cashu
tokens -> redeem, Fedimint invites -> join. Entry points: camera button on
the wallet card (far right), and a Scan button centered between the
Close/Send and Close/Receive buttons of both modals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:14:31 -04:00
archipelagoandClaude Fable 5 be5122048b fix(kiosk): correct --window-size to DIPs + panel-derived layout target
The kiosk window was sized in physical pixels, but Chromium interprets
--window-size in DIPs: with any device-scale > 1 the window painted
scale-times larger than the panel, and with no window manager in the
kiosk X session --kiosk/--start-fullscreen could not snap it back. On a
4K TV at scale 3 that meant an 11520x6480 window — 9x the panel area —
which blew the compositor tile-memory budget ("tile memory limits
exceeded, some content may not draw") and froze the screen on the last
painted frame; smaller scales cropped content off the right/bottom.

- Size the window in DIPs (panel / scale) so DIPs x scale = the panel.
- Panels >=2560 wide now target a 1920-wide layout (4K -> scale 2.0,
  validated on the Framework's 72" 4K TV: spacious desktop layout at 2x
  sharpness); smaller panels keep the 1280 target (1080p TV -> 1.5).
- Optional ARCHIPELAGO_KIOSK_MAX_WIDTH cap: drop to the best <=N-wide
  xrandr mode for weak hardware (TV upscales instead of us rasterizing).
- Source /etc/archipelago/kiosk-display.conf so the upcoming Settings
  display-resolution picker can manage these knobs per node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 12:46:15 -04:00
archipelagoandClaude Fable 5 918b0275a9 feat(ui): MeshCore RF frequency-plan presets by country + Custom entry
Demo images / Build & push demo images (push) Successful in 2m47s
The RF section shipped as four bare fields; restore a prepopulated plan
list (dropdown) with Custom unlocking free entry, per operator direction.
Preset labels carry the full values, so the number fields render only in
Custom mode — no duplicated display. Presets limited to verifiable
sources: the repo's MeshCore community plans (EU 868/433), the MeshCore
firmware's own build defaults (platformio.ini arduino_base 869.618/62.5/
SF8 CR5; companion example 915.0/250/SF10/CR5), and the operator-attested
Portugal plan (869.618/62.5/SF8/CR8). Stored params are matched back to a
named preset on load; hand-editing flips the dropdown to Custom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 08:36:59 -04:00
archipelagoandClaude Opus 4.8 a71a18bffd fix(pine): serve the Connect-to-WiFi button over an http->https redirect
Demo images / Build & push demo images (push) Successful in 2m47s
The launcher must be a secure context for Web Bluetooth, but the UI opens local
apps as http://host:port (resolveAppUrl), so an https-only launcher (v1.1.0)
would break the Open button with a TLS mismatch. Serve http on 10380 (the Open
target) that 301-redirects to the https listener on 10381, so the new tab lands
on https where navigator.bluetooth is available. Bump pine 1.1.0 -> 1.1.1;
re-sign the catalog (only the pine entry changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 08:32:52 -04:00
archipelagoandClaude Opus 4.8 24cab326e2 feat(pine): interactive "Connect Pine to WiFi" button + republish catalog
Demo images / Build & push demo images (push) Successful in 2m47s
Replace the launcher's text instructions with the real Improv-over-BLE
Web-Bluetooth provisioner (a "Connect Pine to WiFi" button, SSID/password
fields, live log) ported from the pine repo. Web Bluetooth needs a secure
context, so the launcher now serves HTTPS (self-signed): :80 (published 10380,
the Open target) 301-redirects to :10381, and open_in_new_tab keeps BLE out of
an iframe. Bump pine 1.0.0 -> 1.1.0.

Regenerate + re-sign releases/app-catalog.json (only the pine entry changes vs
v1.7.109) so nodes reconcile the button page live. Signed by the release-root
key did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 08:25:24 -04:00
archipelagoandClaude Fable 5 811ac1ce50 chore(ota): go live with the signed v1.7.109-alpha manifest
Supersedes the v1.7.108 hold now that the manifest is signed and the
release assets are being published.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:53:12 -04:00
archipelagoandClaude Fable 5 69b1a45872 chore(ota): hold the live manifest at v1.7.108 until v1.7.109 is signed and its assets exist
A prep commit accidentally carried the in-progress v1.7.109 manifest onto
main, so nodes saw an unsigned 1.7.109 with download URLs that 404. Restore
the signed v1.7.108 manifest until the v1.7.109 publish flow completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:52:09 -04:00
archipelago 9823e76e8f chore: release v1.7.109-alpha 2026-07-21 07:50:10 -04:00
archipelagoandClaude Fable 5 8d4f6cb75e docs(release): third v1.7.109 bullet (Pine voice assistant) + version-bump prep
Demo images / Build & push demo images (push) Successful in 2m44s
The release gate requires ≥3 curated changelog bullets; the Pine app is
user-facing and deserved one anyway. Carries the version bumps and manifest
from the aborted cut so the tree is clean for the re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:34:26 -04:00
archipelagoandClaude Fable 5 4bac839fb3 test(ui): did-wallet launch port follows its manifest (8088)
Demo images / Build & push demo images (push) Successful in 2m49s
The pine catalog regeneration (c1dfc667) refreshed generatedAppSessionConfig
from the manifests, where did-wallet publishes host port 8088 — the test's
8083 expectation was stale. The test asserts manifest-generated ports are
used, so it must track the manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:59:47 -04:00
archipelagoandClaude Fable 5 ea80815c98 style: cargo fmt over the pine container list
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:55:19 -04:00
archipelagoandClaude Fable 5 4348581681 docs(release): curated v1.7.109-alpha changelog + What's New
Demo images / Build & push demo images (push) Successful in 2m47s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:54:05 -04:00
archipelagoandClaude Fable 5 999a100531 feat(mesh): RF-params settings UI + expose lora_radio_params in mesh.status
Demo images / Build & push demo images (push) Has been cancelled
Device panel gets MeshCore RF fields (frequency MHz, bandwidth kHz, SF, CR)
wired to mesh.configure's validated lora_radio_params; shown only for
MeshCore radios, all-empty leaves the radio's flashed settings untouched.
Human units in the UI, firmware field units on the wire. Per operator
direction the panel's static stat rows duplicating editable fields (name,
region, channel) are dropped — the editable control is the display. Stale
'adjust via a MeshCore client' hints updated: the node programs the radio now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:53:10 -04:00
archipelagoandClaude Opus 4.8 a5810d4e51 release: publish signed app-catalog with the Pine voice-assistant app
Regenerate + sign releases/app-catalog.json (65 apps) with the pine,
pine-whisper and pine-piper manifests embedded, so nodes can install the Pine
stack straight from the signed registry (raw URL on main). Signed by the
release-root key did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 06:29:48 -04:00
archipelagoandClaude Fable 5 4c3cd4b6ad feat(mesh): operator-configurable Meshcore LoRa PHY params (freq/bw/sf/cr)
Archy set no radio params on Meshcore devices — SF/CR/BW/freq lived only in
device firmware, so a radio on the wrong preset could not be fixed from the
node (it hears RF energy but demodulates nothing; the fleet hit exactly this
on an RNode: docs/RETICULUM-TRANSPORT-PROGRESS.md item 4).

- protocol.rs: build_set_radio_params — wire format verified against the
  MeshCore companion firmware handler (CMD_SET_RADIO_PARAMS=11:
  [11][freq:u32 LE MHz*1000][bw:u32 LE kHz*1000][sf][cr]); byte-layout test
  pinned to the Portugal preset 869.618 MHz / 62.5 kHz / SF8 / CR8.
- serial.rs: MeshcoreDevice::set_radio_params (device reboots on OK).
- MeshConfig.lora_radio_params: Option — None (default) leaves the radio
  untouched, so only explicitly-configured deployments are affected.
- session.rs: provision on connect, gated on a persisted last-applied marker
  (SELF_INFO offsets shift across firmware versions) + the same attempt cap
  as region/channel so a refusing radio never reboot-loops.
- mesh.configure RPC: lora_radio_params arm with firmware-range validation.

mesh tests: 114/114 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:10:45 -04:00
archipelagoandClaude Opus 4.8 c1dfc6672c feat(pine): surface Pine in the app catalog + UI
Demo images / Build & push demo images (push) Successful in 2m48s
- catalog.json (app-catalog + neode-ui/public): one "pine" entry, home
  category; engines stay out of the catalog
- appsConfig.ts: pine → home category, pine-whisper/pine-piper → SERVICE_NAMES
  (render in Services, not as extra cards), pine-* icon fallback
- generatedAppSessionConfig.ts: regenerated (pine port 10380 + titles +
  open-in-new-tab), via scripts/generate-app-catalog.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:14:32 -04:00
archipelagoandClaude Opus 4.8 8791ef60f5 feat(pine): wire the Pine 3-container stack into the orchestrator
Register "pine" as a manifest-driven stack (like netbird) across every
lifecycle site so install/stop/start/restart/uninstall/crash-recovery treat
the launcher + two Wyoming engines as one app:

- stacks.rs: pine_stack_app_ids() + install_pine_stack() (orchestrator-first,
  adopt existing, else bail — no hardcoded fallback)
- install.rs: dispatch package_id == "pine" to the stack installer
- app_ops.rs: canonical stack-member table + owning_package STACKS
- dependencies.rs: startup_order + needs_archy_net
- config.rs: all_container_names
- crash_recovery.rs: auto-start members + a StackRecoverySpec (anchor
  pine-whisper) so a genuinely-installed stack self-heals but orphan debris
  does not crash-loop
- docker_packages.rs: exclude the engines from the apps list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:14:32 -04:00
archipelagoandClaude Opus 4.8 1730d02003 feat(pine): add Pine voice-assistant app package (manifests + icon)
Package the PineVoice/Wyoming local voice assistant as ONE store app "Pine"
that runs three rootless containers:

- pine-whisper — Wyoming faster-whisper STT (docker.io/rhasspy/wyoming-whisper
  3.4.1, model base-int8/en), publishes :10300
- pine-piper   — Wyoming Piper TTS (2.2.2, voice en_GB-alba-medium),
  publishes :10200
- pine         — nginx launcher serving a self-contained setup/status page
  (WiFi provisioning + Home Assistant wiring instructions), the Open target

The two engines publish their Wyoming ports so Home Assistant reaches them via
host.containers.internal. All rootless (cap-drop=ALL, no_new_privileges),
manifest-driven — no hardcoded podman/installer. The engines are internal stack
members (added to the drift-check INTERNAL_MANIFEST_IDS allowlist); only "pine"
carries a catalog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:14:32 -04:00
archipelago 146a3bc738 chore: release v1.7.108-alpha
Demo images / Build & push demo images (push) Successful in 2m48s
2026-07-21 03:42:21 -04:00
archipelagoandClaude Fable 5 302f880d99 docs(release): fold everything into v1.7.108-alpha changelog + What's New
Demo images / Build & push demo images (push) Successful in 2m46s
Supersedes the unpublished v1.7.107. Covers the multi-anchor FIPS fix, WiFi
self-heal, mesh fast-rejoin, kiosk TV-scaling, kiosk dark theme / VT hints /
UTF-8 logo / power-button hardening, and the ISO build fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:59:23 -04:00
archipelagoandClaude Fable 5 217eedc093 fix(kiosk): correct VT hints, UTF-8 logo, ignore accidental power-off
Three fresh-install kiosk issues found on a Framework node:
- MOTD said 'Ctrl+Alt+F7 for kiosk' / 'F1 for terminal', but the kiosk's
  Xorg runs on vt1 — so F7 was an empty black VT (the reported black screen
  and the failed 'return to kiosk'). Corrected: kiosk=F1, terminal=F2.
- The UTF-8 block-glyph logo corrupted intermittently because returning from
  the kiosk can leave the console VT out of UTF-8 mode; force it with ESC%G.
- A short power-button press shut the node down instantly. Ignore the short
  press, keep long-press poweroff, so accidental brushes don't kill the node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:48:14 -04:00
archipelagoandClaude Fable 5 d79edb3a5c fix(ui): censor 'Fuck IPs Mesh' -> 'F*ck IPs Mesh'
Demo images / Build & push demo images (push) Successful in 2m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:42:19 -04:00
archipelagoandClaude Fable 5 0f13545375 fix(fips): ship a second, reachable default anchor (once-and-for-all)
Nodes depended on a single public anchor (185.18.221.160) that is
unreachable from most real networks — confirmed on the thinkpad WiFi and a
fresh Framework node, both of which islanded (is_root=true, depth=0). Its
DNS even resolves IPv6-first while the daemon is IPv4-only.

Stand up an Archipelago-operated FIPS anchor on vps2 (146.59.87.168, the
OTA host every node already reaches) and ship it as an additional default
anchor. default_public_anchor() -> default_public_anchors() -> Vec; load()
returns the set; the apply loop already dials each, so whichever the node's
network can reach wins. Verified end-to-end: a fresh node joined the tree
via vps2 in 3s (depth 1). anchors tests: 7/7 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:42:18 -04:00
archipelago 7eb4d6a7bf chore: release v1.7.107-alpha 2026-07-20 17:00:20 -04:00
archipelagoandClaude Fable 5 db008abdfd fix(kiosk): force dark color-scheme so bundled AIUI renders its dark theme
AIUI themes via @media (prefers-color-scheme) and falls back to its light
variant (white panels) when the browser reports no preference — which the
minimal kiosk X session does, so AIUI came up light instead of its designed
dark. The main UI hardcodes dark and is unaffected. Push two independent
dark signals on the kiosk Chromium: GTK_THEME=Adwaita:dark (version-
independent, can never force light) and --blink-settings=preferredColorScheme=0
(0 = kDark in modern Chromium). Needs on-device kiosk verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:57:46 -04:00
archipelagoandClaude Fable 5 35e9c62441 docs(release): curated v1.7.107-alpha changelog + What's New
Demo images / Build & push demo images (push) Successful in 2m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:39:48 -04:00
archipelagoandClaude Fable 5 cb301b1aef fix(wifi,fips): self-heal polkit rule + fast-retry anchors after daemon restart
Two fleet fixes that OTA can deliver (unlike the ISO-only polkit rule):

- WiFi (issue #99): nodes drive NetworkManager from a system-level service
  with no logind seat, so the stock polkit rule denies them and Wi-Fi setup
  fails with 'Insufficient privileges'. Fresh ISOs since 2026-05 ship the
  scoped rule, but OTA'd nodes never got it (OTA replaces binary + web UI,
  not host system config). Add a startup self-heal that installs the rule if
  missing and best-effort ensures polkitd — both wrapped so an offline/locked
  apt can never fail startup; the rule is written regardless so it activates
  once polkitd lands. Idempotent on the rule's unique subject.user marker.

- FIPS: regenerating fips.yaml (this build does, once, on first boot after
  the OTA) restarts the fips daemon; for a few seconds /run/fips/control.sock
  is gone and every seed-anchor dial fails, islanding the node until the next
  5-min tick. Detect that exact failure (all dials fail on control.sock) and
  retry in 15s instead — bounded to 8 fast retries/episode so a node with no
  fips daemon falls back to the steady cadence rather than busy-looping.

FIPS unit suite: 33/33 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:39:02 -04:00
archipelagoandClaude Fable 5 6a848c38cc fix(iso): extract nostr-rs-relay from its real path /usr/src/app
The relay image was rebuilt to the standard nostr-rs-relay layout — WORKDIR
/usr/src/app, execs ./nostr-rs-relay — but the build still cp'd from the old
/usr/local/bin path, so extraction silently produced nothing. Once the
missing-binary guard was added, that silent miss became a hard build
failure. Verified the binary is a valid amd64 ELF (GLIBC 2.34, under Debian
13's 2.40) at /usr/src/app/nostr-rs-relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:15:59 -04:00
archipelagoandClaude Fable 5 830811372b fix(iso): stop requiring the removed NostrVPN binary
The ISO build hard-failed at 'Required binaries not extracted: nvpn'
because it still pulled 146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7 — an
image that was deleted from the registry when NostrVPN was removed from
the product. The native nvpn daemon is already masked here (ln -sf
/dev/null nostr-vpn.service) and is never spawned at runtime (core vpn.rs
manages WireGuard/Tailscale, only reading a legacy nvpn config as
fallback), so extracting its binary was dead weight that bricked every
build once the image went away.

Drop the nvpn extraction and remove it from the missing-binary guard.
nostr-rs-relay stays required — its nostr-relay unit is still enabled and
the image still pulls. Change is in STEP 3, so the cached rootfs is
unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:05:34 -04:00
archipelago 38cb3dd252 chore: release v1.7.106-alpha
Demo images / Build & push demo images (push) Successful in 2m52s
2026-07-20 15:36:00 -04:00
archipelagoandClaude Fable 5 50170b866e docs(release): curated v1.7.106-alpha changelog + What's New; pin ISO FIPS to v0.4.1
The ISO built FIPS from an unpinned --depth 1 clone of upstream main, so
every build shipped whatever happened to be on main that day. Pin to
v0.4.1 via a FIPS_VERSION build arg — the version fips/config.rs renders
its typed config against, and the one validated in the field on .198 and
the thinkpad. The pin lands inside the STEP 1 recipe-hash range, so the
cached rootfs correctly invalidates on the next build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:09:03 -04:00
archipelagoandClaude Fable 5 c91887a397 chore: commit Cargo.lock version bump left over from v1.7.105-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:05:31 -04:00
archipelagoandClaude Opus 4.8 4fb200e938 docs: handoff for the v1.7.106-alpha OTA + ISO release
Captures the three commits this release carries, the FIPS 0.4.1 upgrade
state (2 nodes done, fleet not rolled), the peer-files diagnosis including
what is explicitly NOT explained, the open decisions not taken, and the
exact release + ISO ritual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:01:38 -04:00
archipelagoandClaude Opus 4.8 5fd0d6c3c8 refactor(fips): generate fips.yaml from typed structs; enable mDNS LAN discovery
The daemon config was built by format!-ing a YAML string literal. Upstream's
config structs are #[serde(deny_unknown_fields)], so a key we get wrong does
not degrade — the daemon refuses to start and the node drops off the mesh.
String-built config made that a runtime discovery on a live node.

Replace it with a typed struct tree serialised via serde_yaml, verified
field-by-field against jmcorgan/fips v0.4.1, plus an exact-output snapshot
test so schema drift fails in CI rather than at boot. Also adds tests for
determinism (server.rs compares the render against disk to detect drift, so
instability would cause a reinstall+restart loop) and for the mDNS key path.

Enables node.discovery.lan.enabled (mDNS/DNS-SD, added upstream in v0.4.0)
so co-located nodes peer directly instead of depending on the public anchor
being reachable — an anchor blackhole on one segment currently islands a node
completely. Emitted unconditionally rather than version-gated: v0.3.0's
DiscoveryConfig has no `lan` field and no deny_unknown_fields, so a v0.3.0
daemon ignores it harmlessly and it activates on upgrade with no second
config migration.

Note: the first startup after this lands renders a config that differs from
disk, so the existing drift check reinstalls it and restarts the daemon once.
That is the intended self-healing path and settles immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:47:22 -04:00
archipelagoandClaude Opus 4.8 3ab7fb521a fix(rpc): log the full error chain, not just the outermost context
RPC failures logged only the top-level anyhow context, so a peer-files
failure produced exactly "RPC error on content.browse-peer: Failed to
connect to peer" with the real cause discarded. That made it impossible
to tell a dead peer from a slow Tor circuit from a FIPS resolve failure
without reproducing by hand.

Switch the log line to `{:#}` so the whole context chain is rendered.
The client-facing message still uses `{}` through sanitize_error_message,
so no internal detail is leaked to callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:03:33 -04:00
archipelagoandClaude Opus 4.8 9e3ac9ba8f fix(ui): show the FIPS/Tor transport pill on mobile peer files
Demo images / Build & push demo images (push) Successful in 2m48s
The peer-files header title block is `hidden md:block` (the global header
carries the peer name on mobile), which also hid the transport pill nested
inside it — so mobile users browsing a peer's files had no indication of
whether they were on FIPS or Tor.

Render a `md:hidden` pill alongside the peer icon so the transport is
visible at every width, without duplicating the peer name on desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:47:29 -04:00
archipelago e2f83c0157 chore: release v1.7.105-alpha 2026-07-20 03:40:38 -04:00
archipelagoandClaude Fable 5 d5f709a3c3 docs(release): curated v1.7.105-alpha changelog + What's New block
Demo images / Build & push demo images (push) Successful in 2m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:38:23 -04:00
archipelagoandClaude Fable 5 710f576c77 fix(ui): satisfy noUncheckedIndexedAccess in the WG retry ladder
Demo images / Build & push demo images (push) Successful in 2m43s
vue-tsc in the release build (unlike the gate's type-check) rejects
indexing WG_RETRY_DELAYS_MS with a bare counter; hoist the delay into a
local and gate on undefined instead of the length check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:52:43 -04:00
archipelagoandClaude Fable 5 c1d309f21f style: cargo fmt crash_recovery.rs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:28:49 -04:00
archipelago 9c39243969 Merge remote-tracking branch 'gitea-ai/fix/companion-autologin-replay-intro'
Demo images / Build & push demo images (push) Successful in 2m49s
2026-07-20 01:57:19 -04:00
archipelagoandClaude Fable 5 f25febf3bb fix(vpn): refresh a stored peer's WireGuard endpoint to the node's current IP
vpn.peer-config returned the config exactly as stored at creation time, so
after a node moves networks the reused companion peer's QR encodes the old
location's address (seen on .116: Endpoint = 10.125.9.0 from the previous
LAN) and the tunnel can never connect. Rewrite the Endpoint line with the
node's current address before rendering the QR, and persist it back so the
downloadable .conf matches. Endpoint detection is shared with
vpn.create-peer via current_wg_endpoint_host().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:57:08 -04:00
DorianandClaude Fable 5 0636d611e0 fix(ui): auto-retry the tunnel-QR provisioning while a first install settles
On a fresh node the wgqr step is often reached while the backend is still
settling (services starting, backend restarting during orchestration): a
single 'Failed to fetch' left a permanently blank QR. Retry network-class
failures on a 2s/4s/8s ladder while the step is still on screen, then fall
back to the friendly error + Try again button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:45:34 +01:00
archipelagoandClaude Fable 5 f13fdc6451 fix(recovery): don't brick-loop startup on stale crash-snapshot containers
After an unclean shutdown, crash recovery walked the running-containers
snapshot and retried 'no such container' failures (2 attempts, 10s
backoff) for containers that no longer exist. Recovery runs before the
server binds :5678 and notifies systemd ready, so a stale snapshot
pushed startup past TimeoutStartSec=5min — systemd killed the daemon
mid-recovery, the next boot saw a crash again, and the node looped
forever (151 restarts on .116 after a hard poweroff).

- pre-filter the snapshot against 'podman ps -a' and skip vanished
  containers outright (fail-open if the query fails)
- never retry a 'no such container' failure
- extend the systemd start timeout ahead of each container start so a
  heavy node recovering dozens of real containers isn't killed while
  making progress

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:32:42 -04:00
archipelagoandClaude Fable 5 163bc3af01 fix(tor): stop listing/pre-baking hidden services for uninstalled apps (#79)
Backend: tor.list-services now hides services whose name maps to a
known-but-uninstalled catalog app (aliases covered: bitcoin/knots/core,
electrumx/electrs, btcpay, mempool). The node's own service, the relay,
and custom user services always show.

ISO: first-boot tor setup no longer pre-creates hidden services for six
apps that may never be installed — only the node's own onion is baked.
It also only seeds services.json/torrc on FIRST boot instead of
clobbering backend-managed services on every boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:32:33 -04:00
DorianandClaude Fable 5 ae12ff2517 fix(iso): fail builds on missing VPN binaries, skip units cleanly, invalidate stale rootfs cache
v1.7.104 shipped ISOs whose units crash-looped (nostr-relay 3s loop,
archipelago-diag 203/EXEC) because build-time extraction failures were mere
warnings and the cached rootfs never tracked its recipe:

- hash the rootfs-defining region of the build script and rebuild when it
  changes (stale cache shipped ISOs without wpasupplicant/iw/rfkill)
- refuse to build without nvpn / nostr-rs-relay unless
  ALLOW_MISSING_VPN_BINARIES=1
- ConditionPathExists on nostr-relay/nostr-vpn/diag units so a stripped
  image skips them instead of crash-looping
- post-install test: every enabled archipelago*/nostr* unit must have an
  existing ExecStart payload

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:08:04 +01:00
DorianandClaude Fable 5 cf4a8eef0e fix(core): throttle volume-ownership sweep to first-seen + hourly per container
The sweep podman-execs into every running container; doing that on each 30s
reconcile tick was a permanent conmon 'Failed to create container' storm on
hosts where exec from the backend's cgroup context fails (Debian 13 first
boot). Ownership drift is an install/OTA-time event — sweep on first pass
after a container appears, then at most hourly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:56 +01:00
DorianandClaude Fable 5 e5f8b5d789 fix(ui): keep kiosk-mode selectors fully inside :global() (v1.7.104 white screen)
With :global(html.kiosk-mode) .bg-layer the SFC compiler drops the
descendant part and emits bare html.kiosk-mode rules — including
display: none !important — blanking the whole document on kiosk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:42 +01:00
DorianandClaude Fable 5 aad6faa6d2 fix(ui): harden companion-overlay WireGuard provisioning and hide it in the companion app
- Never show the get-the-app pitch inside the companion WebView itself
- Don't guess peer-exists when list-peers is unreachable: try create,
  fall back to peer-config on already-exists errors
- Translate raw 'Failed to fetch' into an actionable network hint and
  add a Try again button instead of a dead-end error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:35 +01:00
DorianandClaude Fable 5 20edd31abb fix(ui): play the intro on backend-confirmed fresh nodes despite stale browser flags
neode_intro_seen / neode_onboarding_complete are per-origin browser state:
after a reinstall (or another node on a DHCP-recycled IP) they describe the
previous node and muted a genuinely fresh install's intro. Root boots now
always ask the backend; a confirmed-fresh answer plays the intro and clears
the stale flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:28 +01:00
archipelagoandClaude Fable 5 9a7331cead fix(cli): --version/-V and --help print and exit instead of booting the daemon
A stray `archipelago --version` used to start a full second instance next
to the systemd one (issue #74). Handle -V/--version, -h/--help, and reject
unknown dashed options before any tracing/state init, mirroring the
ceremony subcommand's clean-stdout precedent. Version output matches the
health RPC format: <pkg-version>-<git-hash|dev>.

Fixes #74

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:49:12 -04:00
194 changed files with 19033 additions and 3742 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ on:
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
- '.gitea/workflows/demo-images.yml'
workflow_dispatch:
jobs:
+4
View File
@@ -19,3 +19,7 @@ local.properties
# updates then install over the top without an uninstall. Debug keys are not
# secret (well-known password "android"); never commit a real release keystore.
!/app/debug.keystore
# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64)
/rust/archy-fips-core/target
/app/src/main/jniLibs
+45 -2
View File
@@ -11,12 +11,17 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 18
versionName = "0.4.14"
versionCode = 26
versionName = "0.5.6"
vectorDrawables {
useSupportLibrary = true
}
// The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only,
// matching real handsets. FipsNative.available gates every call, so
// the app still runs as a plain companion elsewhere (e.g. x86 emu).
ndk { abiFilters += "arm64-v8a" }
}
signingConfigs {
@@ -80,6 +85,44 @@ android {
}
}
// ---------------------------------------------------------------------------
// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk
// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug`
// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk,
// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir).
// ---------------------------------------------------------------------------
val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core")
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs")
tasks.register<Exec>("buildRustArm64") {
workingDir = rustCrateDir.asFile
inputs.dir(rustCrateDir.dir("src"))
inputs.file(rustCrateDir.file("Cargo.toml"))
outputs.dir(jniLibsDir)
// cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have.
val home = System.getProperty("user.home")
environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}")
if (System.getenv("ANDROID_NDK_HOME") == null) {
val sdkNdk = file("$home/Library/Android/sdk/ndk")
.listFiles()?.maxByOrNull { it.name }
if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath)
}
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"--platform", "26",
"-o", jniLibsDir.asFile.absolutePath,
"build", "--release",
)
}
tasks.matching {
it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
)
}.configureEach { dependsOn("buildRustArm64") }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
+19
View File
@@ -7,6 +7,10 @@
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".ArchipelagoApp"
@@ -39,6 +43,21 @@
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
</activity>
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
configured entirely by scanning the node's pairing QR. -->
<service
android:name=".fips.ArchyVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="mesh-vpn-tunnel" />
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -19,25 +19,45 @@ data class ServerEntry(
val port: String = "",
val password: String = "",
val name: String = "",
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
val meshIp: String = "",
/** Node's FIPS npub — the durable identity. When present it, not the
* address, is what identifies the entry: FIPS peers on npubs, IPs are
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
val npub: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
private fun urlHost(host: String): String =
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
fun toUrl(): String {
val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix"
return "$scheme://${urlHost(address)}$portSuffix"
}
fun toWsUrl(): String {
val scheme = if (useHttps) "wss" else "ws"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix"
return "$scheme://${urlHost(address)}$portSuffix"
}
// name is the trailing field so entries saved before naming existed
// (4 fields) still deserialize, with name defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
/** Mesh-address UI URL, or null when the node never advertised one. */
fun toMeshUrl(): String? =
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
// name/meshIp/npub are trailing fields so entries saved before they
// existed (4/5/6 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
/** Same node as [other]? npub identity wins; address/port/scheme is the
* fallback for LAN-only entries that never advertised FIPS. */
fun sameNode(other: ServerEntry): Boolean =
(npub.isNotBlank() && npub == other.npub) ||
(address == other.address && port == other.port && useHttps == other.useHttps)
companion object {
fun deserialize(raw: String): ServerEntry? {
@@ -49,6 +69,8 @@ data class ServerEntry(
port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
meshIp = parts.getOrElse(5) { "" },
npub = parts.getOrElse(6) { "" },
)
}
}
@@ -61,8 +83,11 @@ class ServerPreferences(private val context: Context) {
private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val activeNpubKey = stringPreferencesKey("active_npub")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return@map null
@@ -72,6 +97,8 @@ class ServerPreferences(private val context: Context) {
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
npub = prefs[activeNpubKey] ?: "",
)
}
@@ -84,6 +111,11 @@ class ServerPreferences(private val context: Context) {
prefs[introSeenKey] ?: false
}
/** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[gestureHintSeenKey] ?: false
}
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
prefs[activeAddressKey] = server.address
@@ -91,6 +123,8 @@ class ServerPreferences(private val context: Context) {
prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
prefs[activeMeshIpKey] = server.meshIp
prefs[activeNpubKey] = server.npub
}
addSavedServer(server)
}
@@ -102,6 +136,8 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activePortKey)
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
prefs.remove(activeMeshIpKey)
prefs.remove(activeNpubKey)
}
}
@@ -113,63 +149,66 @@ class ServerPreferences(private val context: Context) {
}
/**
* Replace a saved server in place. Matches the existing entry by connection
* identity (address/port/scheme) so edits that change the name or password —
* or that touch a legacy 4-field entry — still update the right record. If the
* edited server is also the active one, the active record is kept in sync.
* Replace a saved server in place. Matches the existing entry by node
* identity — npub first, address/port/scheme as the LAN-only fallback
* (ServerEntry.sameNode) — so edits that change the name, password or even
* every address still update the right record. An edit form that doesn't
* carry the npub keeps the stored one. If the edited server is also the
* active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == original.address &&
e.port == original.port &&
e.useHttps == original.useHttps
ServerEntry.deserialize(raw)?.sameNode(original) == true
}.toSet()
prefs[savedServersKey] = filtered + updated.serialize()
prefs[savedServersKey] = filtered + toStore.serialize()
val isActive = prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
val activeNpub = prefs[activeNpubKey] ?: ""
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
(
prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
)
if (isActive) {
prefs[activeAddressKey] = updated.address
prefs[activeHttpsKey] = updated.useHttps
prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name
prefs[activeAddressKey] = toStore.address
prefs[activeHttpsKey] = toStore.useHttps
prefs[activePortKey] = toStore.port
prefs[activePasswordKey] = toStore.password
prefs[activeNameKey] = toStore.name
prefs[activeMeshIpKey] = toStore.meshIp
prefs[activeNpubKey] = toStore.npub
}
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) — used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
* Add a server, or update the entry for the same node — npub first,
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
* used by QR pairing so re-scanning a node never duplicates it, even after
* the LAN renumbered and every address changed (npub-first contract in
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
* stored value (a real node's QR never carries the password). Returns the
* merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
.firstOrNull { it.sameNode(server) }
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
meshIp = server.meshIp.ifBlank { existing.meshIp },
npub = server.npub.ifBlank { existing.npub },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(merged) == true
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
@@ -179,15 +218,11 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
// Match by node identity (npub, else address/port/scheme) rather
// than the exact serialized string, so a rename — or a legacy
// short-format entry — still removes the right record.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(server) == true
}.toSet()
}
}
@@ -197,4 +232,10 @@ class ServerPreferences(private val context: Context) {
prefs[introSeenKey] = true
}
}
suspend fun markGestureHintSeen() {
context.dataStore.edit { prefs ->
prefs[gestureHintSeenKey] = true
}
}
}
@@ -1,6 +1,8 @@
package com.archipelago.app.data
import android.net.Uri
import com.archipelago.app.fips.AnchorPeer
import com.archipelago.app.fips.FipsPairInfo
/**
* Result of parsing a pairing QR / deep link.
@@ -10,7 +12,11 @@ import android.net.Uri
* user to update the app rather than call the code invalid.
*/
sealed class PairResult {
data class Success(val server: ServerEntry) : PairResult()
data class Success(
val server: ServerEntry,
/** Mesh info when the node advertises FIPS (fnpub present). */
val fips: FipsPairInfo? = null,
) : PairResult()
object UnsupportedVersion : PairResult()
object Invalid : PairResult()
}
@@ -19,13 +25,18 @@ sealed class PairResult {
* Parser for the companion pairing QR / OS deep link. Contract:
* docs/companion-pairing-qr.md (repo root).
*
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
* archipelago://pair?v=1&url=<origin>&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
*
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
* for the public demo); trailing slashes are normalized away.
* - `pw` is only ever present for the public demo.
* - Unknown extra query params are tolerated (forward compat under v=1 —
* `name` is already honored if present).
* - `tok` is a device token minted by the node; it goes through the password
* field on purpose — the whole password auto-login path (WebSocket auth +
* WebView form injection) then works unchanged, and the backend accepts
* device tokens wherever it accepts the password. `pw` (demo only) wins if
* both are ever present.
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
* - Unknown extra query params are tolerated (forward compat under v=1).
*/
object ServerQrParser {
private const val SUPPORTED_MAJOR = 1
@@ -54,14 +65,54 @@ object ServerQrParser {
val host = server.host
if (host.isNullOrBlank()) return PairResult.Invalid
val fips = parseFips(uri)
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
?: uri.getQueryParameter("tok")
?: ""
return PairResult.Success(
ServerEntry(
server = ServerEntry(
address = host,
useHttps = scheme == "https",
port = if (server.port != -1) server.port.toString() else "",
password = uri.getQueryParameter("pw") ?: "",
password = credential,
name = uri.getQueryParameter("name") ?: "",
)
meshIp = fips?.ula ?: "",
// npub is the durable identity — saved-server upserts match on
// it, so re-scanning after a LAN renumber updates in place.
npub = fips?.npub ?: "",
),
fips = fips,
)
}
private fun parseFips(uri: Uri): FipsPairInfo? {
val npub = uri.getQueryParameter("fnpub")?.trim()
val host = uri.getQueryParameter("fhost")?.trim()
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
return FipsPairInfo(
npub = npub,
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
host = host,
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
)
}
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
private fun parseAnchors(raw: String?): List<AnchorPeer> {
if (raw.isNullOrBlank()) return emptyList()
return raw.split(",").mapNotNull { item ->
val at = item.indexOf('@')
val slash = item.lastIndexOf('/')
if (at <= 0 || slash <= at) return@mapNotNull null
val npub = item.substring(0, at).trim()
val addr = item.substring(at + 1, slash).trim()
val transport = item.substring(slash + 1).trim().lowercase()
if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null
if (transport != "udp" && transport != "tcp") return@mapNotNull null
AnchorPeer(npub = npub, addr = addr, transport = transport)
}
}
}
@@ -0,0 +1,197 @@
package com.archipelago.app.fips
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.util.Log
import com.archipelago.app.MainActivity
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* VpnService hosting the embedded FIPS mesh node.
*
* Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so
* normal phone traffic is untouched — this is mesh reachability, not a
* default-route VPN. The established fd is detached and handed to the Rust
* node (Node::start_with_tun_fd); the node owns it until stop.
*/
class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
scope.launch { startMesh() }
return START_STICKY
}
private suspend fun startMesh() {
if (!FipsNative.available) {
shutdown()
return
}
val prefs = FipsPreferences(this)
val identity = FipsManager.ensureIdentity(prefs)
val peersJson = prefs.peersJson()
if (identity == null || peersJson == "[]") {
Log.w(TAG, "mesh not configured — stopping")
shutdown()
return
}
// Re-establishing while running would strand the old fd; restart clean.
if (FipsNative.isRunning()) FipsNative.stop()
val pfd = try {
Builder()
.setSession("Archipelago Mesh")
.setMtu(1280)
.addAddress(identity.address, 128)
.addRoute("fd00::", 8)
// The TUN is IPv6-only. Android blocks every address family
// the VPN has no address for — without this, bringing the
// mesh up cut ALL of the phone's IPv4 internet.
.allowFamily(android.system.OsConstants.AF_INET)
// And let apps that bind their own network skip the TUN
// entirely — this is mesh reachability, not a privacy VPN.
.allowBypass()
.apply {
// Android 10+ treats VPN networks as METERED by default,
// which flips the whole phone into data-saver behaviour
// (background sync off, "metered" warnings) while the
// mesh is up. It inherits the underlying network's real
// metered state instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false)
}
.establish()
} catch (e: Exception) {
Log.e(TAG, "VPN establish failed", e)
null
}
if (pfd == null) {
shutdown()
return
}
val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd)
Log.i(TAG, "mesh start: $result")
if (result.contains("\"error\"")) {
shutdown()
} else {
startSessionWarmer()
}
}
/**
* Pre-warm + keep-warm mesh sessions to every known node ULA.
*
* Discovery + first session through the public tree can take 15s+
* (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the
* moment the tunnel is up, means the connect probe and WebView hit an
* established session instead of timing out on a cold one. The periodic
* touch afterwards keeps the session from idling out. Failed connects
* are expected and cheap; the attempt itself is what drives discovery.
*/
private fun startSessionWarmer() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
val ulas = try {
prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct()
} catch (_: Exception) {
emptyList()
}
for (ula in ulas) {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80),
20_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the connect attempt still
// drove session establishment; try again next round.
}
}
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
delay(if (round < 12) 5_000 else 60_000)
}
}
}
private fun shutdown() {
warmerJob?.cancel()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
FipsNative.stop()
scope.cancel()
super.onDestroy()
}
override fun onRevoke() {
// User pulled VPN permission from system settings.
shutdown()
}
private fun buildNotification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Mesh connection",
NotificationManager.IMPORTANCE_MIN,
).apply { description = "Keeps the node reachable from anywhere" }
)
}
val tapIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Connected to your Archipelago")
.setContentText("Secure mesh link active")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
}
companion object {
const val ACTION_STOP = "com.archipelago.app.fips.STOP"
private const val CHANNEL_ID = "archy_mesh"
private const val NOTIFICATION_ID = 4841
private const val TAG = "ArchyVpnService"
}
}
@@ -0,0 +1,72 @@
package com.archipelago.app.fips
import android.content.Context
import android.content.Intent
import android.net.VpnService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Glue between pairing and the mesh: persists the node peer from a scanned
* QR and asks the UI to bring the tunnel up. There is deliberately no
* settings surface — scanning a node's QR is the entire configuration.
*
* The one unavoidable interaction is Android's VPN consent dialog
* (VpnService.prepare), which only an Activity can launch; [consentNeeded]
* signals AppNavHost to run it, once, on first pairing.
*/
object FipsManager {
/** Set when a pairing registered mesh info and the VPN needs starting. */
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow<Boolean> = _consentNeeded
fun consentHandled() {
_consentNeeded.value = false
}
/**
* Persist mesh info from a pairing scan and request tunnel start.
* No-op on devices without the native lib (non-arm64).
*/
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
if (info == null || !FipsNative.available) return
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
_consentNeeded.value = true
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? {
prefs.identity()?.let { return it }
val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null
prefs.saveIdentity(generated)
return generated
}
/**
* Start the mesh service if this device is paired and the user has already
* consented to the VPN (prepare() == null). Called on app start so the
* tunnel comes back without any interaction; first-time consent goes
* through AppNavHost instead.
*/
suspend fun autoStartIfReady(context: Context) {
if (!FipsNative.available) return
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
startService(context)
}
fun startService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
context.startForegroundService(intent)
}
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)
context.startService(intent)
}
}
@@ -0,0 +1,43 @@
package com.archipelago.app.fips
import org.json.JSONObject
/**
* JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core,
* built into libarchy_fips_core.so by the buildRustArm64 gradle task).
*
* All calls return JSON strings; failures come back as {"error": "…"} rather
* than exceptions. [available] is false on ABIs the .so isn't built for
* (anything but arm64) — every caller must gate on it so the app still runs
* as a plain companion there.
*/
object FipsNative {
val available: Boolean = try {
System.loadLibrary("archy_fips_core")
true
} catch (_: Throwable) {
false
}
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
external fun start(secret: String, peersJson: String, tunFd: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String
data class Identity(val secret: String, val npub: String, val address: String)
/** Parse an identity JSON reply; null on {"error": …} or malformed. */
fun parseIdentity(json: String): Identity? = try {
val obj = JSONObject(json)
if (obj.has("error")) null
else Identity(
secret = obj.getString("secret"),
npub = obj.getString("npub"),
address = obj.getString("address"),
)
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,29 @@
package com.archipelago.app.fips
/**
* Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp —
* docs/companion-pairing-qr.md). The phone's embedded FIPS node dials
* host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers
* without registration, so possession of these params is all pairing takes.
*/
data class FipsPairInfo(
val npub: String,
/** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */
val ula: String,
val host: String,
val udpPort: Int,
val tcpPort: Int,
/**
* Public rendezvous anchors (the node's seed-anchor list). The phone
* peers with these too, so it can route to the node via the mesh when
* the node's LAN endpoint isn't directly dialable.
*/
val anchors: List<AnchorPeer> = emptyList(),
)
/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */
data class AnchorPeer(
val npub: String,
val addr: String,
val transport: String,
)
@@ -0,0 +1,131 @@
package com.archipelago.app.fips
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
// both paths — direct LAN p2p to the node AND a public rendezvous for
// away-from-home — even when the scanned node is old enough that its QR
// carries no fanchors. Keep in lockstep with
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
internal const val ARCHY_ANCHOR_NPUB =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
* same way); the mesh secret only grants mesh membership, not node login.
*/
class FipsPreferences(private val context: Context) {
private val secretKey = stringPreferencesKey("fips_secret")
private val npubKey = stringPreferencesKey("fips_npub")
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
val secret = prefs[secretKey] ?: return null
return FipsNative.Identity(
secret = secret,
npub = prefs[npubKey] ?: "",
address = prefs[addressKey] ?: "",
)
}
suspend fun saveIdentity(identity: FipsNative.Identity) {
context.fipsDataStore.edit { prefs ->
prefs[secretKey] = identity.secret
prefs[npubKey] = identity.npub
prefs[addressKey] = identity.address
}
}
suspend fun peersJson(): String {
val prefs = context.fipsDataStore.data.first()
return prefs[peersKey] ?: "[]"
}
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
* Stored directly in the fips PeerConfig JSON shape the Rust side
* deserializes. The node's direct addresses get the best priorities;
* anchors trail so the mesh prefers the direct path when it works.
*/
suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) {
val incoming = mutableListOf<JSONObject>()
incoming += JSONObject().apply {
put("npub", info.npub)
put("alias", alias.ifBlank { "Archipelago" })
val addresses = JSONArray()
if (info.udpPort > 0) {
addresses.put(JSONObject().apply {
put("transport", "udp")
put("addr", "${info.host}:${info.udpPort}")
put("priority", 10)
})
}
if (info.tcpPort > 0) {
addresses.put(JSONObject().apply {
put("transport", "tcp")
put("addr", "${info.host}:${info.tcpPort}")
put("priority", 20)
})
}
put("addresses", addresses)
}
for (anchor in info.anchors) {
if (anchor.npub == info.npub) continue
incoming += JSONObject().apply {
put("npub", anchor.npub)
put("alias", "Mesh anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", anchor.transport)
put("addr", anchor.addr)
put("priority", 30)
}))
}
}
// Guarantee the public anchor: without it, a QR from an older node
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
if (info.npub != ARCHY_ANCHOR_NPUB &&
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "Archipelago anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
val merged = JSONArray()
for (i in 0 until current.length()) {
val existing = current.optJSONObject(i) ?: continue
if (existing.optString("npub") !in incomingNpubs) merged.put(existing)
}
incoming.forEach { merged.put(it) }
prefs[peersKey] = merged.toString()
}
}
}
@@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
@Composable
fun GamepadLayout(
onKey: (String) -> Unit,
onTwoFingerHold: () -> Unit,
onThreeFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
val surface = Neo.surface()
@@ -54,9 +54,9 @@ fun GamepadLayout(
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
if (a.size < 2) t = 0L
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
if (a.size < 3) t = 0L
} while (ev.changes.any { it.pressed })
}
}
@@ -0,0 +1,147 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
/**
* First-launch teaching overlay for the three-finger hold gesture. Three
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
* short caption explains what the gesture opens. Dismissed by tapping
* anywhere (or automatically after a few seconds) — shown once, ever.
*/
@Composable
fun GestureHintOverlay(onDismiss: () -> Unit) {
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
LaunchedEffect(Unit) {
delay(6500)
onDismiss()
}
val transition = rememberInfiniteTransition(label = "gesture-hint")
// Fingertips press down together…
val press by transition.animateFloat(
initialValue = 1f,
targetValue = 0.86f,
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
label = "press",
)
// …while a ring ripples outward on each press cycle.
val ripple by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
label = "ripple",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Hand: three fingertip dots in a natural arc + ripple ring.
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(150.dp)
.scale(0.4f + ripple * 0.6f)
.border(
2.dp,
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
CircleShape,
),
)
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
FingerDot(x = 44.dp, y = 8.dp, scale = press)
}
Spacer(Modifier.height(28.dp))
Text(
text = stringResource(R.string.gesture_hint_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(R.string.gesture_hint_body),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 48.dp),
)
Spacer(Modifier.height(32.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.clickable(onClick = onDismiss)
.padding(horizontal = 28.dp, vertical = 12.dp),
) {
Text(
text = stringResource(R.string.gesture_hint_got_it),
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
}
}
}
}
@Composable
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
Box(
Modifier
.offset(x = x, y = y)
.size(26.dp)
.scale(scale)
.background(Color.White.copy(alpha = 0.92f), CircleShape),
)
}
@@ -116,7 +116,7 @@ fun NESController(
Box(
modifier = modifier
.fillMaxSize()
.twoFingerHold(onMenu)
.threeFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
@@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
}
}
/** Two-finger hold gesture modifier */
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
if (a.size < 2) t = 0L
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
if (a.size < 3) t = 0L
} while (ev.changes.any { it.pressed })
}
}
@@ -50,7 +50,7 @@ fun NESPortraitController(
Box(
Modifier
.fillMaxSize()
.twoFingerHold(onMenu)
.threeFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
@@ -87,7 +87,7 @@ fun NESPortraitController(
onMove = { dx, dy -> onMouseMove(dx, dy) },
onClick = { onMouseClick(it) },
onScroll = { dy -> onMouseScroll(dy) },
onTwoFingerHold = onMenu,
onThreeFingerHold = onMenu,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
@@ -56,7 +56,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
@@ -82,7 +81,7 @@ import java.util.concurrent.Executors
fun QrScannerOverlay(
visible: Boolean,
onDismiss: () -> Unit,
onServerScanned: (ServerEntry) -> Unit,
onServerScanned: (PairResult.Success) -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -131,7 +130,7 @@ fun QrScannerOverlay(
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
onServerScanned(result.server)
onServerScanned(result)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
@@ -218,13 +217,20 @@ fun QrScannerOverlay(
}
}
/** Shared by the pairing scanner and the wallet scan modal. */
@Composable
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
// TextureView, not the SurfaceView default: SurfaceView punches a
// hole in the window, which black-flashes inside Compose fades and
// ignores rounded-corner clipping (wallet modal).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
DisposableEffect(Unit) {
@@ -283,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
)
}
private var lastAttempt = 0L
override fun analyze(image: ImageProxy) {
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
// retry) pegs a core when run at camera rate, and that CPU contention
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
// frames skipped here are simply dropped, so decodes stay current.
val now = System.currentTimeMillis()
if (now - lastAttempt < 140) {
image.close()
return
}
lastAttempt = now
try {
val plane = image.planes[0]
val buffer = plane.buffer
@@ -32,7 +32,7 @@ fun Trackpad(
onMove: (dx: Int, dy: Int) -> Unit,
onClick: (button: Int) -> Unit,
onScroll: (dy: Int) -> Unit,
onTwoFingerHold: () -> Unit,
onThreeFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
var fingers by remember { mutableIntStateOf(0) }
@@ -53,7 +53,7 @@ fun Trackpad(
val t0 = System.currentTimeMillis()
var maxPtrs = 1
var holdFired = false
var twoStart = 0L
var threeStart = 0L
var scrollAcc = 0f
fingers = 1
@@ -64,19 +64,24 @@ fun Trackpad(
fingers = active.size
when {
active.size >= 2 -> {
if (twoStart == 0L) twoStart = System.currentTimeMillis()
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
// Three fingers = hold for menu; two = scroll. Kept
// on separate counts so a long two-finger scroll can
// never fire the menu mid-gesture.
active.size >= 3 -> {
if (threeStart == 0L) threeStart = System.currentTimeMillis()
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
holdFired = true
onTwoFingerHold()
onThreeFingerHold()
}
if (!holdFired) {
val dy = active.map { it.positionChange().y }.average().toFloat()
scrollAcc += dy
if (kotlin.math.abs(scrollAcc) > 12f) {
onScroll(if (scrollAcc > 0) 1 else -1)
scrollAcc = 0f
}
ev.changes.forEach { it.consume() }
}
active.size == 2 -> {
threeStart = 0L
val dy = active.map { it.positionChange().y }.average().toFloat()
scrollAcc += dy
if (kotlin.math.abs(scrollAcc) > 12f) {
onScroll(if (scrollAcc > 0) 1 else -1)
scrollAcc = 0f
}
ev.changes.forEach { it.consume() }
}
@@ -99,7 +104,11 @@ fun Trackpad(
contentAlignment = Alignment.Center,
) {
Text(
text = if (fingers >= 2) "hold for menu" else "",
text = when {
fingers >= 3 -> "hold for menu"
fingers == 2 -> "scroll"
else -> ""
},
style = MaterialTheme.typography.labelSmall,
color = muted.copy(alpha = 0.4f),
)
@@ -0,0 +1,294 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Native replacement for the web wallet's scan pane — same visual design as
* neode-ui's WalletScanModal (dark glass card, square preview, orange
* viewfinder, status strip) but the camera and decoding run natively, so the
* preview doesn't lag the way getUserMedia does inside a WebView.
*
* Decoded text is handed back to the page ([onDecoded]) which does all the
* detection/spend logic; the page in turn streams status lines (animated-QR
* progress, "not recognised" errors) back in via [status] and closes the
* modal through the JS bridge once it accepts a code.
*/
@Composable
fun WalletQrScannerModal(
visible: Boolean,
status: Pair<String, Boolean>?, // message from the web page + isError
onDecoded: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
// Local error from a failed image upload; a fresh web status replaces it.
var uploadError by remember { mutableStateOf<String?>(null) }
val noQrMessage = stringResource(R.string.no_qr_in_image)
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
val decoded = decodeQrFromUri(context, uri)
if (decoded != null) {
uploadError = null
onDecoded(decoded)
} else {
uploadError = noQrMessage
}
}
}
LaunchedEffect(visible) {
if (visible) {
uploadError = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
LaunchedEffect(status) { if (status != null) uploadError = null }
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(16.dp)
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF212151C))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}, // swallow — only the scrim dismisses
)
.padding(24.dp),
) {
// Header — mirrors the web modal's title row
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_to_send),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
}
Spacer(Modifier.height(8.dp))
// Square camera preview with the orange viewfinder
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.4f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
// Throttle repeat frames: a static QR decodes ~20x/s but
// the page only needs one; animated QRs still stream
// because each frame's text differs.
var lastText by remember { mutableStateOf("") }
var lastSentAt by remember { mutableStateOf(0L) }
CameraQrPreview(onDecoded = { text ->
val now = System.currentTimeMillis()
if (text != lastText || now - lastSentAt > 250) {
lastText = text
lastSentAt = now
onDecoded(text)
}
})
Box(
Modifier
.fillMaxSize(0.62f)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
// Status strip — same slot the web modal uses for hints/errors
val message = uploadError ?: status?.first
val isError = uploadError != null || status?.second == true
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp)
.defaultMinSize(minHeight = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message?.takeIf { it.isNotBlank() }
?: stringResource(R.string.scan_wallet_hint),
style = MaterialTheme.typography.bodySmall,
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
GlassButton(
text = stringResource(R.string.upload_qr_image),
onClick = { imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
}
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
return try {
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
while (maxDim / (sample * 2) >= 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
?: return null
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
)
)
}
try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
} catch (_: NotFoundException) {
// Light-on-dark QRs (dark-themed wallets) decode inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
}
} catch (_: Exception) {
null
}
}
@@ -1,5 +1,8 @@
package com.archipelago.app.ui.navigation
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -16,6 +19,7 @@ import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
@@ -46,6 +50,34 @@ fun AppNavHost(
// connect form so the user lands on the password prompt for that server.
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
// Mesh tunnel: Android's VPN consent dialog is the single unavoidable
// interaction — it can only be launched from an Activity, so pairing
// paths raise FipsManager.consentNeeded and it is handled here, once.
val consentNeeded by FipsManager.consentNeeded.collectAsState()
val vpnConsentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
FipsManager.consentHandled()
if (result.resultCode == android.app.Activity.RESULT_OK) {
FipsManager.startService(context)
}
}
LaunchedEffect(consentNeeded) {
if (!consentNeeded) return@LaunchedEffect
val consentIntent = VpnService.prepare(context)
if (consentIntent == null) {
FipsManager.consentHandled()
FipsManager.startService(context)
} else {
vpnConsentLauncher.launch(consentIntent)
}
}
// Paired + previously consented → the mesh comes back silently on launch.
LaunchedEffect(Unit) {
FipsManager.autoStartIfReady(context)
}
if (introSeen == null) return
// Declared after the introSeen gate so it can't fire before the NavHost
@@ -58,6 +90,7 @@ fun AppNavHost(
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
@@ -125,6 +158,7 @@ fun AppNavHost(
WebViewScreen(
serverUrl = server.toUrl(),
serverPassword = server.password,
meshFallbackUrl = server.toMeshUrl(),
onDisconnect = {
scope.launch {
prefs.clearActiveServer()
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket
import com.archipelago.app.ui.components.NESController
@@ -171,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true },
onThreeFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
@@ -256,10 +257,11 @@ fun RemoteInputScreen(onBack: () -> Unit) {
QrScannerOverlay(
visible = showQrScanner,
onDismiss = { showQrScanner = false },
onServerScanned = { server ->
onServerScanned = { scan ->
showQrScanner = false
scope.launch {
val merged = prefs.upsertServer(server)
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(context, scan.fips, merged.displayName())
if (activeServer == null) prefs.setActiveServer(merged)
}
},
@@ -71,8 +71,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
@@ -83,6 +85,7 @@ import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
@@ -169,10 +172,35 @@ fun ServerConnectScreen(
errorMessage = null
scope.launch {
val result = testConnection(server)
var reachable = testConnection(server)
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
// node. The scanned IP was only ever a dial hint; the node's real
// identity is its npub and its ULA is reachable from anywhere over
// the mesh. Bring the tunnel up and probe the ULA before failing.
if (!reachable && server.meshIp.isNotBlank()) {
FipsManager.autoStartIfReady(context)
val meshServer = server.copy(
address = server.meshIp,
useHttps = false,
port = "",
)
// Mesh discovery + first session can take 15s+ through the
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
// first-ever pairing the VPN consent dialog is on screen at
// the same time — so probe patiently inside a 60s budget with
// per-attempt timeouts wide enough to ride out TCP
// retransmit backoff. The VPN service pre-warms the session
// in parallel (ArchyVpnService.startSessionWarmer).
val deadline = System.currentTimeMillis() + 60_000
while (!reachable && System.currentTimeMillis() < deadline) {
reachable = testConnection(meshServer, timeoutMs = 15_000)
if (!reachable) delay(3000)
}
}
isConnecting = false
if (result) {
if (reachable) {
prefs.setActiveServer(server)
onConnected(server.toUrl())
} else {
@@ -190,12 +218,14 @@ fun ServerConnectScreen(
}
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
// (payload carried a password — the demo flow) or land on the password
// prompt with everything else filled in (real nodes never embed one).
fun onQrScanned(scanned: ServerEntry) {
// (payload carried a credential — demo password or a real node's device
// token) or land on the password prompt with everything else filled in.
// Mesh info (when present) is registered so the FIPS tunnel comes up too.
fun onQrScanned(scan: PairResult.Success) {
showScanner = false
scope.launch {
val merged = prefs.upsertServer(scanned)
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(context, scan.fips, merged.displayName())
prefill(merged)
if (merged.password.isNotBlank()) {
connect(merged)
@@ -647,8 +677,10 @@ private fun sanitizeAddress(input: String): String {
.trimEnd('/')
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
private suspend fun testConnection(server: ServerEntry): Boolean {
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
* patience than LAN ones (first session through the tree can take 15s+). */
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
return withContext(Dispatchers.IO) {
try {
val url = URL("${server.toUrl()}/rpc/v1")
@@ -668,8 +700,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
}
connection.requestMethod = "POST"
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
@@ -1,10 +1,13 @@
package com.archipelago.app.ui.screens
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -12,6 +15,9 @@ import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -47,11 +53,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -62,13 +71,43 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
val u = android.net.Uri.parse(base)
val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
java.net.Socket().use {
it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
true
}
} catch (_: Exception) {
false
}
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
* (patient — a cold session may still be establishing), else LAN anyway so
* the existing error/fallback path handles it. */
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
withContext(Dispatchers.IO) {
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
lanUrl
}
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
@@ -140,17 +179,91 @@ fun WebViewScreen(
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
serverPassword: String = "",
// Node's FIPS mesh URL (http://[fd…]). When the primary address fails on
// a main-frame load — typically the phone left the LAN — retry there
// before surfacing the error page: the mesh tunnel works from anywhere.
meshFallbackUrl: String? = null,
) {
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
var triedMeshFallback by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
// Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an
// unreachable LAN IP burns a minute+ in connect retries before
// onReceivedError fires the mesh fallback — the "stuck connecting"
// stall. A raw TCP probe answers in milliseconds at home and fails in
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
var raceNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
// Starting on the mesh: don't bounce back to it on error (it IS it).
if (picked != serverUrl) triedMeshFallback = true
startUrl = picked
}
// Web-page camera access (wallet QR scanner). The WebView's default
// WebChromeClient silently denies getUserMedia, so grant video capture —
// asking for the app-level CAMERA permission first when needed.
val webViewContext = LocalContext.current
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
pendingWebPermission?.let { req ->
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
}
pendingWebPermission = null
}
// A node app that refused iframing, opened in a local WebView overlay.
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
// while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) }
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
// the ULA while app links may carry the LAN IP (and vice versa) —
// comparing against one host bounced same-node apps (Pine, Home
// Assistant) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) }
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
// One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) }
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
var gestureHintDismissed by remember { mutableStateOf(false) }
// Don't teach the gesture on top of the login/splash — arm the overlay
// ~2 minutes after the kiosk first finishes loading, once the user has
// settled in.
var gestureHintReady by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { isLoading }.first { !it }
delay(120_000)
gestureHintReady = true
}
val scope = rememberCoroutineScope()
// <input type="file"> support — without a chooser implementation the
// WebView silently ignores file inputs (broke the wallet's upload path).
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
val fileChooserLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
pendingFileChooser?.onReceiveValue(
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
)
pendingFileChooser = null
}
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
webView?.goBack()
}
@@ -199,9 +312,13 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.retry),
onClick = {
// Re-race LAN vs mesh — the network we're on may have
// changed since the last pick.
hasError = false
isLoading = true
webView?.loadUrl(serverUrl)
triedMeshFallback = false
startUrl = null
raceNonce++
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
@@ -214,9 +331,16 @@ fun WebViewScreen(
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else if (startUrl == null) {
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
// far cheaper than letting Chromium retry a dead LAN IP.
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = BitcoinOrange)
}
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
val initialUrl = startUrl ?: serverUrl
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
@@ -250,7 +374,7 @@ fun WebViewScreen(
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
if (isSameNode(url)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
@@ -276,6 +400,35 @@ fun WebViewScreen(
"ArchipelagoNative",
)
// Wallet QR bridge. The web scan modal calls:
// window.ArchipelagoQr.open() — show the native scanner
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
// window.ArchipelagoQr.close() — code accepted, tear down
// Decodes flow back through window.__archyQrResult(text);
// a user cancel calls window.__archyQrCancelled().
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun open() {
webViewRef.post {
walletScannerStatus = null
walletScannerVisible = true
}
}
@android.webkit.JavascriptInterface
fun setStatus(message: String, isError: Boolean) {
webViewRef.post { walletScannerStatus = message to isError }
}
@android.webkit.JavascriptInterface
fun close() {
webViewRef.post { walletScannerVisible = false }
}
},
"ArchipelagoQr",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true
@@ -316,8 +469,13 @@ fun WebViewScreen(
)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages.
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
// saved server) — only on our own server's pages
// (LAN origin or the mesh address).
val onOwnServer = url != null && (
url.startsWith(serverUrl) ||
(meshFallbackUrl != null && url.startsWith(meshFallbackUrl))
)
if (serverPassword.isNotBlank() && onOwnServer) {
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
}
}
@@ -328,6 +486,15 @@ fun WebViewScreen(
error: WebResourceError?,
) {
if (request?.isForMainFrame == true) {
val mesh = meshFallbackUrl
if (mesh != null && !triedMeshFallback &&
request.url?.toString()?.startsWith(mesh) != true
) {
triedMeshFallback = true
isLoading = true
view?.loadUrl(mesh)
return
}
hasError = true
isLoading = false
}
@@ -346,7 +513,7 @@ fun WebViewScreen(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@@ -360,6 +527,8 @@ fun WebViewScreen(
val url = request?.url?.toString() ?: return false
// Keep kiosk navigation (same origin incl. port) in place
if (url.startsWith(serverUrl)) return false
// Mesh address is the same server too
if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) return false
// Same node (other port) → in-app; external → browser
routeOutbound(url)
return true
@@ -371,6 +540,46 @@ fun WebViewScreen(
loadProgress = newProgress
}
override fun onShowFileChooser(
view: WebView?,
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
fileChooserParams: FileChooserParams?,
): Boolean {
pendingFileChooser?.onReceiveValue(null)
pendingFileChooser = filePathCallback
val intent = fileChooserParams?.createIntent()
if (intent == null) {
pendingFileChooser = null
return false
}
return try {
fileChooserLauncher.launch(intent)
true
} catch (_: Exception) {
pendingFileChooser = null
false
}
}
// Wallet QR scanner: grant the page camera access.
// Only video capture is granted — anything else the
// page asks for is denied as before.
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
request.deny()
return
}
val hasCamera = ContextCompat.checkSelfPermission(
webViewContext, Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
if (hasCamera) {
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
pendingWebPermission = request
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// window.open() — e.g. the kiosk's "Open in new tab"
// for an app that can't be iframed. Capture the target
// URL via a throwaway WebView and route it ourselves.
@@ -407,22 +616,24 @@ fun WebViewScreen(
}
}
// Two-finger hold (500ms) → navigate to remote input
var twoFingerStart = 0L
var twoFingerFired = false
// Three-finger hold (500ms) → navigate to remote input.
// Three fingers, not two: two-finger scroll/pinch on the
// page collided with the old two-finger hold.
var threeFingerStart = 0L
var threeFingerFired = false
setOnTouchListener { _, event ->
val pointerCount = event.pointerCount
when (event.actionMasked) {
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
if (pointerCount >= 2) {
twoFingerStart = System.currentTimeMillis()
twoFingerFired = false
if (pointerCount >= 3) {
threeFingerStart = System.currentTimeMillis()
threeFingerFired = false
}
}
android.view.MotionEvent.ACTION_MOVE -> {
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
if (System.currentTimeMillis() - twoFingerStart > 500) {
twoFingerFired = true
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
onRemoteInput()
}
}
@@ -430,8 +641,8 @@ fun WebViewScreen(
android.view.MotionEvent.ACTION_UP,
android.view.MotionEvent.ACTION_POINTER_UP,
android.view.MotionEvent.ACTION_CANCEL -> {
if (event.pointerCount <= 2) {
twoFingerStart = 0L
if (event.pointerCount <= 3) {
threeFingerStart = 0L
}
}
}
@@ -439,7 +650,7 @@ fun WebViewScreen(
}
webView = this
loadUrl(serverUrl)
loadUrl(initialUrl)
}
},
)
@@ -464,9 +675,42 @@ fun WebViewScreen(
InAppBrowser(
url = target,
serverUrl = serverUrl,
meshUrl = meshFallbackUrl,
onClose = { inAppUrl = null },
)
}
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
WalletQrScannerModal(
visible = walletScannerVisible,
status = walletScannerStatus,
onDecoded = { text ->
webView?.evaluateJavascript(
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
null,
)
},
onDismiss = {
walletScannerVisible = false
webView?.evaluateJavascript(
"window.__archyQrCancelled && window.__archyQrCancelled()",
null,
)
},
)
// First-launch teaching overlay for the three-finger hold — armed
// ~2 minutes after login so it never fights the splash/first look.
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
!isLoading && inAppUrl == null
) {
GestureHintOverlay(
onDismiss = {
gestureHintDismissed = true
scope.launch { prefs.markGestureHintSeen() }
},
)
}
}
}
}
@@ -505,9 +749,14 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
private fun InAppBrowser(
url: String,
serverUrl: String,
meshUrl: String? = null,
onClose: () -> Unit,
) {
val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
fun isSameNode(u: String): Boolean =
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
var favicon by remember { mutableStateOf<Bitmap?>(null) }
@@ -516,6 +765,18 @@ private fun InAppBrowser(
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// Same camera bridge as the main WebView — node apps opened in the overlay
// (e.g. anything with a QR scanner) get getUserMedia too.
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
pendingWebPermission?.let { req ->
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
}
pendingWebPermission = null
}
// Seed the loading-screen icon immediately from a best-effort favicon
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
// loader shows an icon right away instead of staying blank until the page
@@ -565,6 +826,22 @@ private fun InAppBrowser(
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) favicon = icon
}
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
request.deny()
return
}
val hasCamera = ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
if (hasCamera) {
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
pendingWebPermission = request
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
webViewClient = object : WebViewClient() {
@@ -593,7 +870,7 @@ private fun InAppBrowser(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@@ -607,7 +884,7 @@ private fun InAppBrowser(
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
if (isSameNode(u)) return false
openExternalUrl(ctx, u)
return true
}
@@ -41,4 +41,11 @@
<string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
<string name="gesture_hint_title">Hold with three fingers</string>
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
<string name="gesture_hint_got_it">Got it</string>
<string name="scan_to_send">Scan to send</string>
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
<string name="upload_qr_image">Upload image</string>
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
</resources>
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
# Embedded FIPS mesh node for the Archipelago companion app.
#
# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task
# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so.
# Also builds on the host so `cargo test` covers the non-JNI logic.
#
# `fips` is pinned to the fips-native fork rev that this integration was
# developed against — the fork carries Android support upstream lacks
# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths).
# Override with a local checkout when hacking on fips itself:
# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"'
[package]
name = "archy-fips-core"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
[lib]
name = "archy_fips_core"
# rlib for host tests; cdylib for the Android shared library.
crate-type = ["lib", "cdylib"]
[dependencies]
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
anyhow = "1.0"
serde_json = "1.0"
hex = "0.4"
# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys).
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
tracing = "0.1"
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
libc = "0.2"
# The JNI surface only exists on Android; host builds skip it and drive the
# mesh module directly (tests).
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`.
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
paranoid-android = "0.2"
[workspace]
@@ -0,0 +1,126 @@
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
use std::sync::Once;
use jni::objects::{JClass, JString};
use jni::sys::{jboolean, jint, jstring};
use jni::JNIEnv;
use crate::mesh;
static LOG_INIT: Once = Once::new();
fn init_logging() {
LOG_INIT.call_once(|| {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let _ = tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new("info"))
.with(paranoid_android::layer("archy-fips"))
.try_init();
});
}
fn jstr(env: &mut JNIEnv, s: &JString) -> String {
env.get_string(s).map(|s| s.into()).unwrap_or_default()
}
fn out(env: &JNIEnv, s: String) -> jstring {
env.new_string(s)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
fn err_json(e: impl std::fmt::Display) -> String {
serde_json::json!({ "error": e.to_string() }).to_string()
}
fn identity_json(info: &mesh::IdentityInfo) -> String {
serde_json::json!({
"secret": info.secret_hex,
"npub": info.npub,
"address": info.address,
})
.to_string()
}
/// Kotlin: `external fun generateIdentity(): String`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity(
env: JNIEnv,
_class: JClass,
) -> jstring {
init_logging();
let json = match mesh::generate_identity() {
Ok(info) => identity_json(&info),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun deriveIdentity(secret: String): String`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
mut env: JNIEnv,
_class: JClass,
secret: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let json = match mesh::derive_identity(&secret) {
Ok(info) => identity_json(&info),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
mut env: JNIEnv,
_class: JClass,
secret: JString,
peers_json: JString,
tun_fd: jint,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let peers = jstr(&mut env, &peers_json);
let json = match mesh::start(&secret, &peers, tun_fd) {
Ok((npub, address)) => {
serde_json::json!({ "npub": npub, "address": address }).to_string()
}
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun stop()`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop(
_env: JNIEnv,
_class: JClass,
) {
mesh::stop();
}
/// Kotlin: `external fun isRunning(): Boolean`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning(
_env: JNIEnv,
_class: JClass,
) -> jboolean {
mesh::is_running() as jboolean
}
/// Kotlin: `external fun statusJson(): String`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
env: JNIEnv,
_class: JClass,
) -> jstring {
out(&env, mesh::status_json())
}
+17
View File
@@ -0,0 +1,17 @@
//! Embedded FIPS mesh node for the Archipelago companion app.
//!
//! The phone runs a real, leaf-only FIPS node in-process: Android's
//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic
//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`].
//! Peering is outbound-only — the pairing QR carries the node's npub and
//! transport endpoints, and FIPS nodes accept inbound peers without prior
//! registration, so no server-side enrollment step exists.
//!
//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
pub mod mesh;
#[cfg(target_os = "android")]
mod jni_glue;
+258
View File
@@ -0,0 +1,258 @@
//! Mesh lifecycle: identity, config assembly, and the node task.
//!
//! Host-buildable (no JNI) so the config/identity logic is unit-testable;
//! only [`start`] needs a real TUN fd and therefore only runs on-device.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use fips::config::{PeerConfig, TransportInstances, UdpConfig};
use fips::{Config, Identity, Node};
use tokio::sync::Notify;
/// How long `start` waits for the node to come up (TUN attach + transports).
const START_TIMEOUT: Duration = Duration::from_secs(15);
/// How long `stop` waits for the node task to drain.
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
struct MeshHandle {
runtime: tokio::runtime::Runtime,
task: Option<tokio::task::JoinHandle<()>>,
shutdown: Arc<Notify>,
running: Arc<AtomicBool>,
npub: String,
address: String,
}
static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None);
#[derive(Debug, Clone)]
pub struct IdentityInfo {
pub secret_hex: String,
pub npub: String,
/// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress.
pub address: String,
}
/// Generate a fresh mesh identity from the OS CSPRNG.
pub fn generate_identity() -> Result<IdentityInfo> {
// ~1 in 2^128 chance a candidate is off the curve; loop regardless.
loop {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).context("OS RNG")?;
if let Ok(id) = Identity::from_secret_bytes(&bytes) {
return Ok(IdentityInfo {
secret_hex: hex::encode(bytes),
npub: id.npub(),
address: id.address().to_ipv6().to_string(),
});
}
}
}
/// Re-derive npub + ULA from a stored secret.
pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?;
Ok(IdentityInfo {
secret_hex: secret.to_string(),
npub: id.npub(),
address: id.address().to_ipv6().to_string(),
})
}
/// Build the phone-side node config: leaf-only (never routes third-party
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
/// TUN enabled but attached to the VpnService fd rather than created.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
let mut cfg = Config::default();
cfg.node.identity.nsec = Some(secret.to_string());
cfg.node.identity.persistent = false;
cfg.node.leaf_only = true;
cfg.tun.enabled = true;
cfg.tun.mtu = Some(1280);
cfg.dns.enabled = false;
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
cfg.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some("0.0.0.0:0".to_string()),
..Default::default()
});
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
cfg.transports.tcp = TransportInstances::Single(Default::default());
cfg.peers = peers;
cfg
}
/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`:
/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]`
pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
serde_json::from_str(peers_json).context("peers JSON")
}
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
/// detached — the node owns it from here). Returns (npub, ula) on success.
/// Any previously running node is stopped first.
pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
stop();
// Android hands the VpnService TUN fd over in non-blocking mode on some
// OS builds. The fips TUN reader is a dedicated blocking-read thread that
// treats EAGAIN as fatal — the loop died at startup ("TUN read error …
// Try again (os error 11)" on-device), so sessions came up but no packet
// ever entered the mesh. Force the fd into the blocking mode the reader
// is designed for.
unsafe {
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
let peers = parse_peers(peers_json)?;
let config = build_config(secret, peers);
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
let npub = node.npub();
let address = node.identity().address().to_ipv6().to_string();
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("archy-fips")
.build()
.context("tokio runtime")?;
let shutdown = Arc::new(Notify::new());
let running = Arc::new(AtomicBool::new(false));
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>();
let task = {
let shutdown = shutdown.clone();
let running = running.clone();
runtime.spawn(async move {
match node.start_with_tun_fd(tun_fd).await {
Ok(()) => {
running.store(true, Ordering::SeqCst);
let _ = started_tx.send(Ok(()));
}
Err(e) => {
let _ = started_tx.send(Err(anyhow!("node start: {e}")));
return;
}
}
tokio::select! {
result = node.run_rx_loop() => {
if let Err(e) = result {
tracing::error!("mesh rx loop error: {e}");
}
}
_ = shutdown.notified() => {}
}
if let Err(e) = node.stop().await {
tracing::warn!("mesh stop: {e}");
}
running.store(false, Ordering::SeqCst);
})
};
let started = runtime
.block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await })
.map_err(|_| anyhow!("node start timed out"))?
.map_err(|_| anyhow!("node task died during start"))?;
if let Err(e) = started {
runtime.shutdown_background();
return Err(e);
}
*MESH.lock().unwrap() = Some(MeshHandle {
runtime,
task: Some(task),
shutdown,
running,
npub: npub.clone(),
address: address.clone(),
});
Ok((npub, address))
}
/// Stop the mesh node if running. Idempotent.
pub fn stop() {
let Some(mut handle) = MESH.lock().unwrap().take() else {
return;
};
handle.shutdown.notify_waiters();
if let Some(task) = handle.task.take() {
let _ = handle
.runtime
.block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await });
}
handle.runtime.shutdown_background();
}
pub fn is_running() -> bool {
MESH.lock()
.unwrap()
.as_ref()
.map(|h| h.running.load(Ordering::SeqCst))
.unwrap_or(false)
}
/// `{running, npub, address}` for the Kotlin status surface.
pub fn status_json() -> String {
let guard = MESH.lock().unwrap();
match guard.as_ref() {
Some(h) => serde_json::json!({
"running": h.running.load(Ordering::SeqCst),
"npub": h.npub,
"address": h.address,
})
.to_string(),
None => serde_json::json!({ "running": false }).to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_roundtrip() {
let a = generate_identity().unwrap();
let b = derive_identity(&a.secret_hex).unwrap();
assert_eq!(a.npub, b.npub);
assert_eq!(a.address, b.address);
// ULA in fd::/8
assert!(a.address.starts_with("fd"));
}
#[test]
fn peers_json_parses_into_peer_config() {
let peers = parse_peers(
r#"[{
"npub": "npub1abc",
"alias": "My Archipelago",
"addresses": [
{"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10},
{"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20}
]
}]"#,
)
.unwrap();
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].addresses.len(), 2);
assert!(peers[0].is_auto_connect());
}
#[test]
fn config_is_leaf_only_with_tun() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![]);
assert!(cfg.node.leaf_only);
assert!(cfg.tun.enabled);
assert_eq!(cfg.tun.mtu(), 1280);
assert!(!cfg.dns.enabled);
assert!(!cfg.transports.udp.is_empty());
assert!(!cfg.transports.tcp.is_empty());
}
}
+79
View File
@@ -1,5 +1,84 @@
# Changelog
## v1.7.112-alpha (2026-07-22)
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
## v1.7.111-alpha (2026-07-22)
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.)
- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.
- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.
- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.
- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.
- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.
- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.
- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.
- On the phone home screen, the wallet card moved up to sit right under My Apps.
- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably.
## v1.7.110-alpha (2026-07-21)
- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.
- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.
- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.
- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.
- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).
- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.
- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."
- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.
## v1.7.109-alpha (2026-07-21)
- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.
- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.
- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.
## v1.7.108-alpha (2026-07-20)
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.
- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
## v1.7.106-alpha (2026-07-20)
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.
- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.
- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.
- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.
## v1.7.105-alpha (2026-07-20)
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
- The companion phone app no longer suggests installing the companion app from inside itself.
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
## v1.7.104-alpha (2026-07-19)
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
+13 -2
View File
@@ -351,12 +351,12 @@
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1.0",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -370,6 +370,17 @@
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
+5 -2
View File
@@ -35,6 +35,9 @@ app:
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -43,9 +46,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+5 -2
View File
@@ -35,6 +35,9 @@ app:
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -43,9 +46,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+11 -2
View File
@@ -9,13 +9,22 @@ app:
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
# container is actually running (Knots, Core, or any future distro archy
# ships), so the gateway is never pinned to one node's container name.
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
custom_args:
- >-
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
else
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
fi
derived_env:
- key: FM_BITCOIND_URL
template: "http://{{BITCOIN_HOST}}:8332"
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
+7 -1
View File
@@ -21,6 +21,11 @@ app:
template: fedimint://{{HOST_MDNS}}:8173
- key: FM_API_URL
template: ws://{{HOST_MDNS}}:8174
# Resolves to whichever bitcoin container is running (Knots/Core/future
# distro) instead of a hardcoded name — the guardian works on any node
# regardless of which Bitcoin software it runs.
- key: FM_BITCOIND_URL
template: "http://{{BITCOIN_HOST}}:8332"
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
@@ -62,7 +67,8 @@ app:
environment:
- FM_DATA_DIR=/data
- FM_BITCOIND_URL=http://bitcoin-knots:8332
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
# hardcoded name — do not re-add it here.
- FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173
+1 -1
View File
@@ -1,5 +1,5 @@
# Home Assistant - uses official image
FROM homeassistant/home-assistant:2024.1
FROM homeassistant/home-assistant:2026.7.3
# Default configuration is in the image
# No additional setup needed
+2 -2
View File
@@ -1,11 +1,11 @@
app:
id: homeassistant
name: Home Assistant
version: 2024.1.0
version: 2026.7.3
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present
network: pasta
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) so the orchestrator adopts a matching running container instead of
# recreating it.
container_name: pine-openwakeword
container:
image: docker.io/rhasspy/wyoming-openwakeword:2.1.0
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-openwakeword]
# The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock
# "ok nabu" model; /custom is where a trained custom model (yo_archy)
# drops in later — the engine picks up new .tflite files on restart.
custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"]
dependencies:
- storage: 512Mi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10400 (the Wyoming integration endpoint).
- host: 10400
container: 10400
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-openwakeword
target: /custom
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10400
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-openwakeword
repo: https://github.com/rhasspy/wyoming-openwakeword
license: MIT
tags:
- home
- voice
- wake-word
- wyoming
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.2.2"
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded voices under /data
# preserved).
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
# picks the voice (mirrors the pine ha-stack.yml compose command).
custom_args: ["--voice", "en_GB-alba-medium"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the voice into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10200 (the Wyoming integration endpoint).
- host: 10200
container: 10200
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-piper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10200
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the voice
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-piper
repo: https://github.com/rhasspy/wyoming-piper
license: MIT
tags:
- home
- voice
- text-to-speech
- wyoming
+78
View File
@@ -0,0 +1,78 @@
app:
id: pine-whisper
name: Pine Whisper (STT)
# App revision 3.4.2 = upstream wyoming-whisper 3.4.1 image + tuned args
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
# pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2"
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded models under /data
# preserved).
container_name: pine-whisper
container:
image: docker.io/rhasspy/wyoming-whisper:3.4.1
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-whisper]
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
# pick the model + language (mirrors the pine ha-stack.yml compose command).
# --beam-size 1: the image default is 5 on x86 (1 on ARM). Benchmarked on
# framework-pt (i5-1135G7, base-int8): beam 1 transcribes the same text
# ~45% faster — the standard low-latency setting for short voice commands
# (HA's own whisper add-on defaults to 1).
custom_args: ["--model", "base-int8", "--language", "en", "--beam-size", "1"]
dependencies:
- storage: 2Gi
resources:
memory_limit: 2Gi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the whisper model into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10300 (the Wyoming integration endpoint).
- host: 10300
container: 10300
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-whisper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10300
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the model
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-faster-whisper
repo: https://github.com/rhasspy/wyoming-faster-whisper
license: MIT
tags:
- home
- voice
- speech-to-text
- wyoming
+395
View File
@@ -0,0 +1,395 @@
app:
id: pine
name: Pine
version: "1.3.0"
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home
# The user-facing launcher (app_id + container both "pine", matching the
# runtime references + the live container so the orchestrator adopts it). A
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
# stack members.
container_name: pine
container:
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
# (https or localhost), so the launcher terminates TLS with a self-signed
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
# secure-context fix (#15).
generated_certs:
- crt: /var/lib/archipelago/pine/tls.crt
key: /var/lib/archipelago/pine/tls.key
dependencies:
- app_id: pine-whisper
- app_id: pine-piper
- app_id: pine-openwakeword
- storage: 128Mi
resources:
memory_limit: 64Mi
security:
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
# workers) binds :443 inside the container — needs the worker-drop caps +
# NET_BIND_SERVICE for the privileged port.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
# 10380 (http) is the Open target — the UI launches apps as
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
# listener on 10381, so the new tab lands on a secure context where
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
- host: 10380
container: 80
protocol: tcp
- host: 10381
container: 443
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/tls.crt
target: /etc/nginx/tls.crt
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/tls.key
target: /etc/nginx/tls.key
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/index.html
target: /usr/share/nginx/html/index.html
options: [ro]
environment: []
files:
- path: /var/lib/archipelago/pine/nginx.conf
overwrite: true
content: |
server {
listen 80;
server_name _;
return 301 https://$host:10381$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
root /usr/share/nginx/html;
index index.html;
# Live node facts for the status card — proxied to the node's
# public status tier so the (https) page can fetch same-origin.
location = /node-status {
proxy_pass http://host.containers.internal:80/api/pine/status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
}
location / { try_files $uri $uri/ /index.html; }
}
- path: /var/lib/archipelago/pine/index.html
overwrite: true
content: |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pine — connect your speaker</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
background: #0f1512; color: #e7efe9; }
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
h1 { font-size: 26px; margin: 0; }
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
.card { background: #16201b; border: 1px solid #24332b;
border-radius: 14px; padding: 20px; margin: 20px 0; }
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
.status .row b { min-width: 84px; color: #9fd3b6; }
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
font-size: 13px; color: #cfe9d9; }
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
outline: none; }
input:focus { border-color: #4fae82; }
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
border-radius: 10px; cursor: pointer; transition: filter .15s; }
button:hover:not(:disabled) { filter: brightness(1.08); }
button:disabled { opacity: .45; cursor: default; }
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
font-family: ui-monospace, monospace; white-space: pre-wrap;
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
.ha b { color: #9fbfad; }
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
color: #e8c878; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
<rect x="236" y="396" width="40" height="72" rx="6"/>
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g></svg>
<div><h1>Pine</h1>
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
</header>
<div class="card status">
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
</div>
<div class="card status">
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
</div>
<div class="card insecure" id="insecure">
This page isnt running over HTTPS, so the browser blocks Bluetooth.
Open it via its <b>https://…:10380</b> address (accept the self-signed
certificate) and the button below will work.
</div>
<div class="card">
<label for="ssid">WiFi network (SSID)</label>
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
<input id="pass" type="password" autocomplete="off">
<button id="go">Connect Pine to WiFi</button>
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
</div>
<p class="ha">After WiFi joins, one manual step remains — pair the
speaker in Home Assistant: <b>Settings → Devices &amp; services →
Add Wyoming Protocol</b>, host = the speakers IP, port
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
are wired up automatically when Pine installs. Wake word:
<b>“Hey Jarvis.”</b> Ask node things like <i>“whats the block
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
synced?”</i> or <i>“whats my lightning balance?”</i> — and when a
Claude API key is set on the node, anything else gets answered by
Claude. New mesh messages are announced on the speaker too.</p>
<p class="ha">Troubleshooting: if it hears you (LED reacts) but answers
are silent, unplug and replug the speaker — an interrupted answer can
wedge its audio output until it reboots.</p>
</div>
<script>
"use strict";
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
const SVC = "00467768-6228-2272-4663-277478268000";
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
4:"not authorized — press the button on the device",5:"bad hostname",
255:"unknown device error"};
const logEl = document.getElementById("log");
const log = (msg, cls) => { const l = document.createElement("div");
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
logEl.scrollTop = logEl.scrollHeight; };
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
.map(b => b.toString(16).padStart(2, "0")).join(" ");
const btn = document.getElementById("go");
if (!navigator.bluetooth) {
document.getElementById("insecure").style.display = "block";
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
}
btn.addEventListener("click", async () => {
const ssid = document.getElementById("ssid").value.trim();
const pass = document.getElementById("pass").value;
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
btn.disabled = true; logEl.textContent = "";
let device;
try {
log("Opening Bluetooth device chooser…");
device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
optionalServices: [SVC],
});
log(`Selected: ${device.name || "(unnamed)"}`);
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
log("Connecting…");
const gatt = await device.gatt.connect();
const svc = await gatt.getPrimaryService(SVC);
const stateChar = await svc.getCharacteristic(CHAR_STATE);
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
let done, fail;
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
let authorize; const authorized = new Promise(res => { authorize = res; });
let state = -1;
const onState = (s, via = "") => {
if (s === state) return; state = s;
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
if (s >= 2) authorize();
if (s === 4) done(null);
};
stateChar.addEventListener("characteristicvaluechanged",
e => onState(e.target.value.getUint8(0)));
errorChar.addEventListener("characteristicvaluechanged", e => {
const c = e.target.value.getUint8(0);
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
resultChar.addEventListener("characteristicvaluechanged", e => {
const v = e.target.value; log(`RPC result: ${hex(v)}`);
if (v.byteLength > 2 && v.getUint8(1) > 0) {
const n = v.getUint8(2); const b = new Uint8Array(n);
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
done(new TextDecoder().decode(b)); } });
await stateChar.startNotifications();
await errorChar.startNotifications();
await resultChar.startNotifications();
onState((await stateChar.readValue()).getUint8(0));
const poller = setInterval(async () => {
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
const ec = (await errorChar.readValue()).getUint8(0);
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
let nextUrl;
try {
if (state === 1) {
log("Authorization required — press the centre button on the speaker now.", "warn");
await Promise.race([authorized, outcome,
new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out waiting for the button press")), 60000))]);
log("Authorized.", "ok");
}
const enc = new TextEncoder();
const sb = enc.encode(ssid), pb = enc.encode(pass);
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
const pkt = new Uint8Array([1, data.length, ...data, 0]);
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
log(`Sending WiFi credentials for “${ssid}”…`);
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
else await rpcChar.writeValue(pkt);
log("Credentials delivered — speaker acknowledged.", "ok");
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
const timeout = new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
nextUrl = await Promise.race([outcome, timeout]);
} finally { clearInterval(poller); }
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
try { gatt.disconnect(); } catch {}
} catch (err) {
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
if (err.name === "NotFoundError")
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
+ "Hold the dot button 15s to reset.", "warn");
if (err.name === "NetworkError")
log("BLE link dropped — move closer, and make sure the Mac isn't already "
+ "paired to the speaker (it can hold the link exclusively).", "warn");
try { device?.gatt?.disconnect(); } catch {}
} finally { btn.disabled = false; }
});
// Live node status card — public tier of /api/pine/status via the
// same-origin /node-status proxy. Best-effort: failures just show
// "unavailable" and retry on the next tick.
const nsNode = document.getElementById("ns-node");
const nsBtc = document.getElementById("ns-btc");
const nsPeers = document.getElementById("ns-peers");
async function refreshNodeStatus() {
try {
const r = await fetch("/node-status", { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
const s = await r.json();
const up = Math.floor((s.uptime_seconds || 0) / 3600);
nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`;
if (s.bitcoin && s.bitcoin.height != null) {
const pct = s.bitcoin.sync_percent;
nsBtc.textContent = `block ${s.bitcoin.height}` +
(pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : "");
} else {
nsBtc.textContent = "not running";
}
const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?";
const meshPeers = s.mesh ? s.mesh.peers : 0;
nsPeers.textContent = `${btcPeers} bitcoin` +
(s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : "");
} catch {
nsNode.textContent = "node status unavailable";
nsBtc.textContent = "—";
nsPeers.textContent = "—";
}
}
refreshNodeStatus();
setInterval(refreshNodeStatus, 30000);
</script>
</body>
</html>
health_check:
type: tcp
endpoint: localhost:443
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
interfaces:
main:
name: Pine
description: Connect your speaker to WiFi and check the voice assistant
type: ui
port: 10380
protocol: http
path: /
metadata:
author: Archipelago
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming
repo: https://github.com/rhasspy/wyoming
license: MIT
category: home
launch:
open_in_new_tab: true
tags:
- home
- voice
- assistant
- privacy
+52 -1
View File
@@ -84,6 +84,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.9.1"
@@ -95,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.104-alpha"
version = "1.7.111-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@@ -145,6 +154,7 @@ dependencies = [
"serde_yaml",
"serial2-tokio",
"sha2 0.10.9",
"socket2 0.5.10",
"tar",
"tempfile",
"thiserror 1.0.69",
@@ -160,6 +170,7 @@ dependencies = [
"uuid",
"zbase32",
"zeroize",
"zip",
]
[[package]]
@@ -1174,6 +1185,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@@ -6894,8 +6916,37 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.104-alpha"
version = "1.7.111-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
[dependencies]
# Core dependencies
tokio = { version = "1", features = ["full"] }
# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with
# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt).
socket2 = "0.5"
libc = "0.2" # process-group signalling for the supervised reticulum daemon
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -105,6 +108,10 @@ bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1"
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
# per-platform release zip (see mesh/flash.rs).
zip = { version = "2", default-features = false, features = ["deflate"] }
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
hkdf = "0.12.4"
+20 -2
View File
@@ -188,7 +188,7 @@ impl ApiHandler {
}
};
let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
_ => {
// Not a paid item — no invoice to issue.
return Ok(build_response(
@@ -198,6 +198,13 @@ impl ApiHandler {
));
}
};
if !content_server::method_accepted(&item.access, "lightning") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
));
}
let memo = format!("Archipelago peer file {content_id}");
match self
@@ -315,7 +322,18 @@ impl ApiHandler {
.unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
content_server::AccessControl::Paid { price_sats, .. } => {
if !content_server::method_accepted(&i.access, "onchain") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
),
));
}
*price_sats
}
_ => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
+20
View File
@@ -580,6 +580,26 @@ impl ApiHandler {
self.handle_app_catalog_proxy().await
}
// Pine node status — public tier (version/uptime/height/sync/peer
// counts) is unauthenticated like /bitcoin-status; Lightning
// balances + latest mesh message additionally require the bearer
// token the pine/HA seeder minted (or a valid session).
(Method::GET, "/api/pine/status") => {
let bearer = headers
.get(hyper::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
let authorized = self.rpc_handler.pine_status_token_ok(bearer).await
|| self.is_authenticated(&headers).await;
let body = self.rpc_handler.pine_status_json(authorized).await;
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
// LND connect info — nginx validates session cookie (presence check),
// backend is bound to 127.0.0.1 so only nginx can reach it.
// No backend auth check here because the LND UI iframe fetches this
+68
View File
@@ -9,6 +9,23 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
// Companion device-token login: minted via auth.createDeviceToken and
// carried by the pairing QR. Verified here so it shares the login rate
// limiter with password attempts.
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
return match crate::device_tokens::verify(&self.config.data_dir, token).await {
Some(device) => {
tracing::info!("[onboarding] device-token login ({device})");
Ok(serde_json::Value::Null)
}
None => {
tracing::warn!("[onboarding] device-token login failed");
Err(anyhow::anyhow!("Invalid device token"))
}
};
}
let password = params
.get("password")
.and_then(|v| v.as_str())
@@ -32,6 +49,15 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
// The companion app sends its device token through the password
// field (it reuses the whole password auto-login path, including
// the WebView form). Accept a valid token here so that path works.
if let Some(device) =
crate::device_tokens::verify(&self.config.data_dir, password).await
{
tracing::info!("[onboarding] device-token login via password field ({device})");
return Ok(serde_json::Value::Null);
}
tracing::warn!("[onboarding] login failed — wrong password");
return Err(anyhow::anyhow!("Password Incorrect"));
}
@@ -73,6 +99,48 @@ impl RpcHandler {
Ok(serde_json::Value::Null)
}
/// Mint a device token for the companion pairing QR. Session-gated by the
/// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI
/// can mint one. The plaintext token is returned exactly once.
pub(super) async fn handle_auth_create_device_token(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("companion")
.trim()
.to_string();
if name.is_empty() || name.len() > 64 {
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
}
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
Ok(serde_json::json!({ "name": name, "token": token }))
}
pub(super) async fn handle_auth_list_device_tokens(&self) -> Result<serde_json::Value> {
let tokens = crate::device_tokens::list(&self.config.data_dir).await;
Ok(serde_json::json!(tokens
.iter()
.map(|t| serde_json::json!({ "name": t.name, "created": t.created }))
.collect::<Vec<_>>()))
}
pub(super) async fn handle_auth_revoke_device_token(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?;
Ok(serde_json::json!({ "removed": removed }))
}
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
tracing::info!("[onboarding] logout");
Ok(serde_json::Value::Null)
+115 -1
View File
@@ -179,7 +179,24 @@ impl RpcHandler {
if price == 0 {
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
}
AccessControl::Paid { price_sats: price }
// Optional list of payment methods the sharer accepts.
// Absent/empty = all methods (backward compatible).
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
let accepted: Vec<String> = params
.get("accepted_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.as_str())
.filter(|m| KNOWN_METHODS.contains(m))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
AccessControl::Paid {
price_sats: price,
accepted,
}
}
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
};
@@ -412,6 +429,55 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// NEVER pay twice for content we already own (2026-07-22: a file
// shared twice produced two catalog ids for the same bytes and the
// buyer paid both). Guard BEFORE any ecash is minted, matching both
// by exact (onion, content_id) and by (onion, filename) — the latter
// catches duplicate ids pointing at the same file on the same
// seller. The owned copy is served from the local cache instead.
{
let filename = params.get("filename").and_then(|v| v.as_str());
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
let already = owned.iter().find(|o| {
o.onion == onion
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
}))
});
if let Some(o) = already {
tracing::info!(
onion,
content_id,
owned_as = %o.content_id,
"paid download: already owned — serving cached copy, NOT paying again"
);
if let Some((mime, bytes)) = crate::content_owned::read_owned(
&self.config.data_dir,
&o.onion,
&o.content_id,
)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
"owned": true,
"already_owned": true,
"filename": o.filename,
"mime_type": mime,
"size_bytes": bytes.len(),
"paid_sats": 0,
"data_base64":
base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
}
// Cache record exists but bytes are gone — fall through and
// repurchase rather than stranding the user.
}
}
// `method` pins the backend the user confirmed in the UI ("cashu" |
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
// verify_payment_token accepts either, so a node whose balance lives in
@@ -590,6 +656,54 @@ impl RpcHandler {
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
}
// Auto-file the purchase into the user's Files area (2026-07-22):
// Photos for images/video, Music for audio, Documents otherwise —
// same buckets the Cloud view uses. The in-app viewer still plays
// from the purchase cache; this makes the file ALSO show up where
// files live, on every device, without relying on a browser
// download. Best-effort: never fail a paid download over it.
{
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
"Photos"
} else if mime_type.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let base = std::path::Path::new(&filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let dir = self.config.data_dir.join("filebrowser").join(folder);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (base.clone(), String::new()),
};
let mut n = 2;
while target.exists() {
target = dir.join(format!("{stem} ({n}){ext}"));
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
),
}
}
}
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -26,6 +26,9 @@ impl RpcHandler {
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
"auth.createDeviceToken" => self.handle_auth_create_device_token(params).await,
"auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await,
"auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await,
// Seed management (BIP-39 mnemonic)
"seed.generate" => self.handle_seed_generate().await,
@@ -257,6 +260,7 @@ impl RpcHandler {
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
// Ark protocol (via barkd sidecar)
"wallet.ark-status" => self.handle_wallet_ark_status().await,
@@ -383,6 +387,11 @@ impl RpcHandler {
// Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await,
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
"mesh.flash-status" => self.handle_mesh_flash_status().await,
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
@@ -456,6 +465,8 @@ impl RpcHandler {
"system.factory-reset" => self.handle_system_factory_reset(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
// Opt-in anonymous analytics
"analytics.get-status" => self.handle_analytics_get_status().await,
@@ -484,6 +495,7 @@ impl RpcHandler {
// FIPS mesh transport
"fips.status" => self.handle_fips_status().await,
"fips.pair-info" => self.handle_fips_pair_info().await,
"fips.check-update" => self.handle_fips_check_update().await,
"fips.apply-update" => self.handle_fips_apply_update().await,
"fips.install" => self.handle_fips_install().await,
+25
View File
@@ -118,6 +118,31 @@ impl RpcHandler {
Ok(serde_json::json!({ "removed": removed }))
}
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
/// with sufficient balance. Returns the notes token for the recipient
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
/// split from Cashu 2026-07-22). The heavy lifting already existed in
/// `fedimint_client::spend_from_any`; it was simply never exposed.
pub(super) async fn handle_wallet_fedimint_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let amount_sats = params
.as_ref()
.and_then(|p| p.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
let (token, federation_id) =
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
.await?;
Ok(serde_json::json!({
"token": token,
"federation_id": federation_id,
"amount_sats": amount_sats,
}))
}
/// `wallet.fedimint-balance` — total sats across all joined federations.
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified
+47
View File
@@ -16,6 +16,53 @@ impl RpcHandler {
Ok(serde_json::to_value(status)?)
}
/// Everything the companion app needs to join this node's mesh, embedded
/// in the pairing QR by the web UI: the daemon's npub (identity to dial),
/// the fips0 ULA (where the UI is reachable once the phone is meshed),
/// and the transport ports on this host. The QR builder supplies the
/// host/IP itself — it knows which origin the browser reached the node on.
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
// isn't directly dialable (phone away from home, node behind NAT).
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
.await
.unwrap_or_default();
// Pairing must always carry a public rendezvous point: without one the
// phone is IP-bound to the LAN host it scanned and goes dark the
// moment it leaves that network. This is a pairing hint only — the
// node's own anchor file is not modified, so an operator's removal of
// the default anchors still sticks for the node itself.
if !anchor_list
.iter()
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
{
anchor_list.push(fips::anchors::archy_anchor());
}
let anchors = anchor_list
.into_iter()
.map(|a| {
serde_json::json!({
"npub": a.npub,
"addr": a.address,
"transport": a.transport,
})
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"npub": npub,
"ula": ula,
"udp_port": fips::PUBLISHED_UDP_PORT,
"tcp_port": fips::DEFAULT_TCP_PORT,
"anchors": anchors,
}))
}
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
let check = fips::update::check().await?;
Ok(serde_json::to_value(check)?)
+51 -4
View File
@@ -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<String> {
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<serde_json::Value> {
@@ -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<Self>) {
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<serde_json::Value> {
// 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,
)
+49 -11
View File
@@ -5,6 +5,21 @@ use tracing::info;
use super::LND_REST_BASE_URL;
/// LND rejects RPCs with "server is still in the process of starting" for a
/// short window after wallet unlock (p2p/graph subsystems still loading).
/// Transient by design — match it so callers retry and, if it persists,
/// surface a calm notice instead of a scary failure. The exact phrase below
/// is what the frontend keys its softer (non-red) styling on.
fn lnd_still_starting(msg: &str) -> bool {
let m = msg.to_ascii_lowercase();
m.contains("in the process of starting") || m.contains("server is still starting")
}
/// User-facing text for the still-starting state. Deliberately calm: this is
/// a "wait a moment", not an error.
const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \
usually takes a minute or two after the node comes online. Please try again shortly.";
#[derive(Debug, Serialize)]
struct ChannelInfo {
chan_id: String,
@@ -251,25 +266,45 @@ impl RpcHandler {
"perm": false,
"timeout": "30"
});
let connect_resp = client
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&connect_body)
.timeout(std::time::Duration::from_secs(35))
.send()
.await
.context("Failed to connect to peer")?;
// Right after wallet unlock, LND's RPC answers while its p2p
// server is still spinning up, and every connect attempt gets
// "server is still in the process of starting". That's a
// transient state, not a failure — it clears in seconds — so
// retry quietly for ~30s before surfacing a calm, non-scary
// notice (the frontend styles LND_STARTING_MSG as info, not red).
let mut attempt = 0u32;
loop {
let connect_resp = client
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&connect_body)
.timeout(std::time::Duration::from_secs(35))
.send()
.await
.context("Failed to connect to peer")?;
if !connect_resp.status().is_success() {
if connect_resp.status().is_success() {
break;
}
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
// LND returns an error if we already have this peer — that is fine
if !msg.contains("already connected") {
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
if msg.contains("already connected") {
break;
}
if lnd_still_starting(msg) && attempt < 5 {
attempt += 1;
info!(attempt, "LND still starting — retrying peer connect in 6s");
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
continue;
}
if lnd_still_starting(msg) {
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
}
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
}
}
@@ -305,6 +340,9 @@ impl RpcHandler {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
if lnd_still_starting(msg) {
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
}
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
}
+166
View File
@@ -56,6 +56,172 @@ pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
}
}
/// Real-time wallet push (user req 2026-07-22): the UI must reflect an
/// incoming on-chain transaction the moment the node sees it, not on the
/// next poll. Streams LND's `/v1/transactions/subscribe` — it fires on
/// 0-conf mempool arrival AND again on each confirmation — and nudges the
/// shared data-model revision on every event; /ws/db pushes that to every
/// connected client and the frontend refetches wallet balance/transactions.
/// Reconnects forever with capped backoff: LND restarting, wallet locked, or
/// LND not installed yet all just mean "try again shortly".
pub(crate) fn spawn_lnd_tx_watcher(state_manager: std::sync::Arc<crate::state::StateManager>) {
tokio::spawn(async move {
let mut delay = std::time::Duration::from_secs(5);
loop {
match stream_lnd_transactions(&state_manager).await {
// Stream ended cleanly (LND shutdown) — resume fast.
Ok(()) => delay = std::time::Duration::from_secs(5),
Err(e) => {
tracing::debug!("lnd tx watcher: {e:#} — retrying in {delay:?}");
}
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(std::time::Duration::from_secs(120));
}
});
}
async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> {
let macaroon_hex = hex::encode(read_lnd_admin_macaroon().await?);
// Dedicated client: the shared lnd_client() carries a 15s total timeout,
// which would kill this deliberately long-lived stream.
let client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
.context("Failed to create streaming HTTP client")?;
let mut resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions/subscribe"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("subscribe request failed")?;
anyhow::ensure!(
resp.status().is_success(),
"transactions/subscribe returned {}",
resp.status()
);
tracing::info!("lnd tx watcher: streaming wallet transaction events");
while let Some(chunk) = resp.chunk().await? {
if chunk.is_empty() {
continue;
}
// Any streamed event = wallet activity. Revision bump is the push
// contract (same pattern as the mesh-peer bridge in server.rs) — the
// clients refetch, so we don't need to parse the event body.
let (data, _) = sm.get_snapshot().await;
sm.update_data(data).await;
tracing::debug!(
bytes = chunk.len(),
"lnd tx watcher: wallet tx event — nudged ws clients"
);
}
Ok(())
}
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
/// for 14 HOURS with its RPC answering but the server never finishing
/// startup — synced_to_chain=false, zero peers, every channel inactive —
/// and nothing noticed until a human tried to open a channel. The wedge
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
/// with channels that need a peer) persists. A restart reliably clears it
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
/// after 15 consecutive bad minutes we bounce the container ourselves, with
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the
/// operator.
pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move {
let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None;
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet)
continue;
};
let macaroon_hex = hex::encode(bytes);
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
else {
continue;
};
let Ok(resp) = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
else {
bad_minutes = 0; // down/locked — not the wedge signature
continue;
};
let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0;
continue;
};
let synced = info
.get("synced_to_chain")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
let channels = info
.get("num_active_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_inactive_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_pending_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let wedged = !synced || (channels > 0 && peers == 0);
if !wedged {
bad_minutes = 0;
continue;
}
bad_minutes += 1;
if bad_minutes < 15 {
continue;
}
if last_restart
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
.unwrap_or(false)
{
continue;
}
tracing::warn!(
synced_to_chain = synced,
num_peers = peers,
channels,
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
);
let out = tokio::process::Command::new("podman")
.args(["restart", "lnd"])
.output()
.await;
match out {
Ok(o) if o.status.success() => {
tracing::info!("LND watchdog restart complete");
}
Ok(o) => tracing::warn!(
"LND watchdog restart failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
}
last_restart = Some(tokio::time::Instant::now());
bad_minutes = 0;
}
});
}
impl RpcHandler {
/// Helper: create an authenticated LND REST client.
/// Returns an HTTP client configured for LND's self-signed TLS and the
+16 -1
View File
@@ -28,13 +28,20 @@ impl RpcHandler {
));
}
// Zero-amount invoices need the amount supplied by the payer; LND's
// REST API takes it as an `amt` string alongside the payment request.
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
info!("Paying Lightning invoice");
let (client, macaroon_hex) = self.lnd_client().await?;
let pay_body = serde_json::json!({
let mut pay_body = serde_json::json!({
"payment_request": payment_request,
});
if let Some(amt) = amount_sats {
pay_body["amt"] = serde_json::json!(amt.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
@@ -55,6 +62,14 @@ impl RpcHandler {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
// Invoices are short-lived; retrying the same one can never
// succeed, so tell the user the way out instead of just the fact.
if msg.contains("invoice expired") {
return Err(anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
msg.trim_start_matches("invoice expired. ")
));
}
return Err(anyhow::anyhow!("Payment failed: {}", msg));
}
+132
View File
@@ -0,0 +1,132 @@
use super::super::RpcHandler;
use crate::mesh;
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
use crate::mesh::types::DeviceType;
use anyhow::Result;
fn parse_family(s: &str) -> Result<DeviceType> {
match s.trim().to_lowercase().as_str() {
"meshcore" => Ok(DeviceType::Meshcore),
"meshtastic" => Ok(DeviceType::Meshtastic),
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"),
}
}
fn parse_board(s: &str) -> Result<FlashBoard> {
match s.trim().to_lowercase().as_str() {
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
}
}
impl RpcHandler {
/// mesh.flash-list-firmware — resolve the available firmware version(s)
/// for a given family. v1 only ever surfaces "latest".
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let versions = flash::list_firmware(family).await?;
Ok(serde_json::json!({ "versions": versions }))
}
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
/// latest firmware for the given family, defaulting to a full chip
/// erase before write. `board` is optional: if the port's USB vid:pid
/// unambiguously resolves to a known board, that's used; otherwise the
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
/// doc comment on why we refuse to guess).
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let path = params
.as_ref()
.and_then(|p| p.get("path"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
.to_string();
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let detected = mesh::detect_devices().await;
anyhow::ensure!(
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
let board = match params
.as_ref()
.and_then(|p| p.get("board"))
.and_then(|v| v.as_str())
{
Some(explicit) => parse_board(explicit)?,
None => {
let info = mesh::detect_devices_info()
.await
.into_iter()
.find(|d| d.path == path);
info.as_ref()
.and_then(flash::resolve_flash_board)
.ok_or_else(|| {
anyhow::anyhow!(
"Could not auto-detect the board on {path} — specify board explicitly"
)
})?
}
};
flash::start_flash_job(
&self.flash_job,
&self.mesh_service_arc(),
self.config.data_dir.clone(),
path,
board,
family,
)
.await?;
Ok(serde_json::json!({ "started": true }))
}
/// mesh.flash-status — poll the current (or most recent) flash job.
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
let status: FlashJobStatus = j.snapshot().await;
let mut value = serde_json::to_value(&status)?;
if let Some(obj) = value.as_object_mut() {
obj.insert("active".into(), (!status.done).into());
}
Ok(value)
}
None => Ok(serde_json::json!({ "active": false })),
}
}
/// mesh.flash-cancel — best-effort; only honored before erase/write has
/// started (see `FlashJob::cancel`'s doc comment).
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
j.cancel().await?;
Ok(serde_json::json!({ "cancelled": true }))
}
None => anyhow::bail!("No flash job in progress"),
}
}
}
@@ -158,6 +158,34 @@ impl RpcHandler {
anyhow::bail!("Unknown LoRa region: {trimmed}");
}
}
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
// mesh::LoraRadioParams). Validated against the firmware's accepted
// ranges here so a bad value errors at the API instead of being sent
// to the radio and rejected on-device. `null` clears the setting.
if let Some(rp) = params.get("lora_radio_params") {
if rp.is_null() {
config.lora_radio_params = None;
} else {
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
anyhow::ensure!(
(150_000..=2_500_000).contains(&parsed.freq_khz),
"freq_khz out of range (150000..=2500000)"
);
anyhow::ensure!(
(7_000..=500_000).contains(&parsed.bw_hz),
"bw_hz out of range (7000..=500000)"
);
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
config.lora_radio_params = Some(parsed);
}
}
// Hot-swap "keep as is": false = never write config to the radio
// (region/channel/PHY/advert name) — use it exactly as flashed.
if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) {
config.manage_radio = manage;
}
// Firmware pin: probe only the named firmware on the port ("auto"/""
// clears the pin and restores strict-probe auto-detect).
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
+1
View File
@@ -1,5 +1,6 @@
mod assistant;
mod bitcoin_ops;
mod flash;
mod messaging;
mod safety;
mod status;
@@ -41,6 +41,10 @@ impl RpcHandler {
// live radio-reported `region`): the configured LoRa region and
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
obj.insert("lora_region".into(), config.lora_region.clone().into());
obj.insert(
"lora_radio_params".into(),
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
);
obj.insert(
"device_kind".into(),
config
@@ -48,6 +52,9 @@ impl RpcHandler {
.map(|k| k.to_string().to_lowercase())
.into(),
);
// Hot-swap "keep as is" state: false = archipelago never writes
// config to the radio (see MeshConfig::manage_radio).
obj.insert("manage_radio".into(), config.manage_radio.into());
// USB identity per detected port so the setup modal can show the
// actual board (product string on native-USB boards, vid:pid as
// the fallback for bridge chips).
@@ -74,6 +81,59 @@ impl RpcHandler {
Ok(value)
}
/// mesh.probe-device — Identify the firmware on a detected serial port and
/// read its current configuration WITHOUT provisioning it. Powers the
/// hot-swap "device detected" modal's current-details view. The path must
/// be one of the currently detected candidate ports (no arbitrary device
/// paths), and probing the live session's own port is refused.
pub(in crate::api::rpc) async fn handle_mesh_probe_device(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let path = params
.as_ref()
.and_then(|p| p.get("path"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
.to_string();
let detected = mesh::detect_devices().await;
anyhow::ensure!(
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
// Refuse to probe while a firmware flash is in flight. Confirmed
// live 2026-07-23: esptool ("multiple access on port?") and
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
// failed with symptoms consistent with a second process holding the
// same serial fd — the flash subprocess runs for minutes outside
// our own async runtime, so nothing previously stopped a concurrent
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
// from opening the identical port at the same time and corrupting
// both operations' handshakes.
if let Some(job) = self.flash_job.read().await.as_ref() {
anyhow::ensure!(
job.snapshot().await.done,
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
);
}
// Only hold the mesh_service lock long enough for the quick
// active-path guard check — NEVER across the actual probe, which
// can take 15-60s across its internal collision retries. Confirmed
// live 2026-07-23: holding this read lock for the full probe starved
// a concurrent firmware-flash job's MeshService::stop() (which needs
// the write lock) well past its own bounded timeout, surfacing as
// "Mesh listener did not release the serial port" even though
// stop() itself was fast.
{
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
svc.ensure_probe_allowed(&path).await?;
}
}
let probe = mesh::listener::probe_device(&path).await?;
Ok(serde_json::to_value(probe)?)
}
/// mesh.peers — List discovered mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
@@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Mempool requires",
"Container",
"Image",
// Wallet-actionable errors: masking "Insufficient balance: need 80
// sats, have 0 sats" behind "Operation failed. Check server logs."
// sent the operator to journalctl for a message that was written for
// them in the first place (ecash send, 2026-07-22).
"Insufficient balance",
"Insufficient funds",
// Lightning payment failures carry LND's reason ("invoice expired.
// Valid until …", "no route", …) — the user can act on every one of
// them, and masking sent the operator to journalctl (invoice-expired
// send, 2026-07-23).
"Payment failed",
"Invalid payment request",
"Missing 'payment_request'",
"Your Lightning node is still finishing",
"Bitcoin address",
"No router",
"No OpenWrt",
@@ -151,6 +165,25 @@ mod sanitize_tests {
}
}
#[test]
fn lightning_payment_errors_pass_through() {
// LND's payment-failure reasons are written for the payer — masking
// "invoice expired" as "Check server logs" left a user retrying a
// dead invoice (framework-pt, 2026-07-23).
for msg in [
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
"Payment failed: unable to find a path to destination",
"Invalid payment request: must be a Lightning invoice (lnbc...)",
"Missing 'payment_request' parameter",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
+38 -4
View File
@@ -15,7 +15,7 @@ mod fips;
mod handshake;
mod identity;
mod interfaces;
pub(in crate::api) mod lnd;
pub(crate) mod lnd;
mod marketplace;
mod mesh;
mod middleware;
@@ -26,7 +26,9 @@ mod node;
mod nostr;
mod openwrt;
mod package;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
mod pine_status;
mod response;
mod router;
mod security;
@@ -87,6 +89,9 @@ pub struct RpcHandler {
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
/// job at a time, since flashing needs exclusive access to the port.
flash_job: crate::mesh::flash::FlashJobHandle,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
/// Shared content-addressed blob store. Set by ApiHandler after construction
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
@@ -158,6 +163,7 @@ impl RpcHandler {
endpoint_rate_limiter,
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
flash_job: crate::mesh::flash::new_job_handle(),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
@@ -438,7 +444,13 @@ impl RpcHandler {
}
}
Err(e) => {
error!("RPC error on {}: {}", rpc_req.method, e);
// `{:#}` renders the whole anyhow context chain. Logging only the
// outermost context threw away the actual cause: a peer-files
// failure logged just "Failed to connect to peer", with the real
// error (Tor SOCKS failure, FIPS resolve, timeout) discarded — so
// the logs couldn't distinguish a dead peer from a slow circuit.
// The client-facing message below stays `{}` so internals aren't leaked.
error!("RPC error on {}: {:#}", rpc_req.method, e);
let user_message = sanitize_error_message(&e.to_string());
RpcResponse {
result: None,
@@ -525,9 +537,31 @@ impl RpcHandler {
self.login_rate_limiter.record_failure(client_ip).await;
}
// On successful login, check if 2FA is required
// On successful login, check if 2FA is required. Device-token logins
// (companion pairing QR) skip the TOTP challenge like remember-me does:
// the token was minted from an already-authenticated session, and there
// is no password with which to decrypt the TOTP secret anyway.
if method == "auth.login" && rpc_resp.error.is_none() {
let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false);
let password = login_params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str());
let is_token_login = login_params
.as_ref()
.and_then(|p| p.get("token"))
.and_then(|v| v.as_str())
.is_some()
|| match password {
// Companion device tokens also arrive through the password
// field (see handle_auth_login) — those logins get a full
// session too; there's no password to decrypt TOTP with.
Some(pw) => crate::device_tokens::verify(&self.config.data_dir, pw)
.await
.is_some(),
None => false,
};
let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled {
let password = login_params
.as_ref()
+144 -82
View File
@@ -38,7 +38,19 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await;
// `scan_subnet` is `async fn` but loops over up to a full /24 of
// blocking TCP probes + SSH handshakes with no real await points —
// same blocking-on-a-worker-thread hazard as the other openwrt
// handlers (see handle_openwrt_get_status), just larger in scope.
let routers = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(detect::scan_subnet(
subnet,
prefix,
&ssh_user,
&ssh_password,
))
})
.await?;
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
Ok(serde_json::json!({ "routers": ips }))
@@ -87,8 +99,84 @@ impl RpcHandler {
.or_else(|| saved.password.clone())
.unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
// Router/Session (ssh2) is a fully synchronous, blocking API with no
// timeout on the initial TCP connect — run it on the blocking pool,
// not directly on a tokio worker thread. Inlined here, a single
// unreachable router (e.g. after physically relocating the node, so
// the configured router is on a different/unreachable network) hangs
// for the OS's default TCP connect timeout (routinely 2+ minutes),
// and every concurrent poll of this endpoint eats another worker
// thread — with only a handful of worker threads total, that starves
// every other in-flight request in the whole process. This was a
// real full-node outage (2026-07-24), diagnosed via a live gdb
// backtrace showing 3 of 4 worker threads blocked in this exact
// `TcpStream::connect` → `Router::connect_password` call chain.
let host_for_task = host.clone();
let ssh_user_for_task = ssh_user.clone();
let ssh_password_for_task = ssh_password.clone();
let status = tokio::task::spawn_blocking(move || -> Result<serde_json::Value> {
let router =
Router::connect_password(&host_for_task, 22, &ssh_user_for_task, &ssh_password_for_task)?;
router.verify_openwrt()?;
// System info
let release = router
.run_ok("cat /etc/openwrt_release")
.unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
let uptime_secs: u64 = router
.run_ok("cat /proc/uptime")
.unwrap_or_default()
.split_whitespace()
.next()
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename.
let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
let tollgate = if tollgate_installed {
serde_json::json!({
"installed": true,
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
};
// WiFi interfaces
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
let wan_status = wan::get_wan_status(&router);
Ok(serde_json::json!({
"host": host_for_task,
"hostname": hostname,
"uptime_secs": uptime_secs,
"release": parse_release(&release),
"tollgate": tollgate,
"wifi_interfaces": wifi_interfaces,
"wan": wan_status,
}))
})
.await??;
// Persist the connection so other views (e.g. the Home dashboard's
// Network tile) can poll `openwrt.get-status` with no params instead
@@ -107,62 +195,7 @@ impl RpcHandler {
.await;
}
// System info
let release = router
.run_ok("cat /etc/openwrt_release")
.unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
let uptime_secs: u64 = router
.run_ok("cat /proc/uptime")
.unwrap_or_default()
.split_whitespace()
.next()
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename.
let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
let tollgate = if tollgate_installed {
serde_json::json!({
"installed": true,
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
};
// WiFi interfaces
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
let wan_status = wan::get_wan_status(&router);
Ok(serde_json::json!({
"host": host,
"hostname": hostname,
"uptime_secs": uptime_secs,
"release": parse_release(&release),
"tollgate": tollgate,
"wifi_interfaces": wifi_interfaces,
"wan": wan_status,
}))
Ok(status)
}
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
@@ -228,15 +261,32 @@ impl RpcHandler {
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
};
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
tollgate::provision(&router, &config).await?;
let response_ssid = config.ssid.clone();
let response_mint_url = config.mint_url.clone();
// Blocking ssh2 I/O — see handle_openwrt_get_status for why this
// must run on the blocking pool rather than a tokio worker thread.
// `tollgate::provision` is `async fn` but has no real await points
// (every op inside it is a synchronous SSH round trip) — block_on
// here just runs it to completion on this blocking-pool thread
// instead of pretending it yields on a tokio worker.
let host_for_task = host.clone();
let ssh_user_for_task = ssh_user.clone();
let ssh_password_for_task = ssh_password.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let router =
Router::connect_password(&host_for_task, 22, &ssh_user_for_task, &ssh_password_for_task)?;
router.verify_openwrt()?;
tokio::runtime::Handle::current().block_on(tollgate::provision(&router, &config))?;
Ok(())
})
.await??;
Ok(serde_json::json!({
"ok": true,
"host": host,
"ssid": config.ssid,
"mint_url": config.mint_url,
"ssid": response_ssid,
"mint_url": response_mint_url,
}))
}
@@ -279,22 +329,27 @@ impl RpcHandler {
.or_else(|| saved.password.clone())
.unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
// Blocking ssh2 I/O — see handle_openwrt_get_status for why this
// must run on the blocking pool rather than a tokio worker thread.
let result = tokio::task::spawn_blocking(move || -> Result<Vec<serde_json::Value>> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let networks = wifi_scan::scan_networks(&router)?;
let result: Vec<serde_json::Value> = networks
.iter()
.map(|n| {
serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
let networks = wifi_scan::scan_networks(&router)?;
Ok(networks
.iter()
.map(|n| {
serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
})
})
})
.collect();
.collect())
})
.await??;
Ok(serde_json::json!({ "networks": result }))
}
@@ -357,9 +412,6 @@ impl RpcHandler {
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let config = wan::WispConfig {
ssid: ssid.clone(),
password,
@@ -368,7 +420,17 @@ impl RpcHandler {
dhcp_limit,
masq,
};
wan::configure_wisp(&router, &config)?;
// Blocking ssh2 I/O — see handle_openwrt_get_status for why this
// must run on the blocking pool rather than a tokio worker thread.
let host_for_task = host.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let router = Router::connect_password(&host_for_task, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
wan::configure_wisp(&router, &config)?;
Ok(())
})
.await??;
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
}
@@ -496,6 +496,13 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"netbird-dashboard".into(),
"netbird-server".into(),
],
// Pine voice-assistant stack: launcher + the two Wyoming engines.
"pine" => vec![
"pine".into(),
"pine-whisper".into(),
"pine-piper".into(),
"pine-openwakeword".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -206,19 +206,24 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
)),
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
let mut missing = vec![];
if !deps.has_bitcoin {
missing.push("Bitcoin Knots");
}
if !deps.has_electrumx {
missing.push("ElectrumX");
}
Err(anyhow::anyhow!(
"Mempool requires {} to be running. Please install and start {} first.",
missing.join(" and "),
missing.join(" and ")
))
"mempool" | "mempool-web" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"Mempool requires Bitcoin Knots to be running. \
Please install and start Bitcoin Knots first."
)),
// ElectrumX is deliberately NOT a hard gate for mempool: mempool-api
// reconnects to electrum on its own (verified live — it retries
// ECONNREFUSED in a loop and comes good when electrum starts
// listening), and an ElectrumX mid-resync can be legitimately down
// for DAYS. The hard gate here blocked repeated mempool installs on
// .116 (2026-07-22) while electrumx was compacting. Block/fee views
// work from bitcoind immediately; address lookups light up when
// ElectrumX finishes.
"mempool" | "mempool-web" if !deps.has_electrumx => {
info!(
"Mempool installing while ElectrumX is not running — \
mempool-api reconnects automatically once ElectrumX is up"
);
Ok(())
}
"fedimint" if !deps.has_bitcoin => {
info!("Fedimint installing without local Bitcoin node — configure remote Bitcoin RPC in Fedimint guardian setup");
@@ -298,9 +303,10 @@ fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep>
if !deps.has_bitcoin {
missing.push(BITCOIN);
}
if !deps.has_electrumx {
missing.push(ELECTRUM);
}
// ElectrumX intentionally not required — see check_install_deps:
// mempool-api reconnects when electrum comes up, and a resyncing
// ElectrumX can be down for days (the 3-minute bounded wait here
// would still fail the install for no benefit).
}
// fedimint deliberately absent: check_install_deps allows it without
// a local Bitcoin node (remote RPC configured in guardian setup).
@@ -595,6 +601,10 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
| "nbxplorer"
| "fedimint"
| "fedimint-gateway"
| "pine"
| "pine-whisper"
| "pine-piper"
| "pine-openwakeword"
)
}
@@ -626,6 +636,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -1106,7 +1117,11 @@ mod tests {
}
#[tokio::test]
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
async fn mempool_waits_on_bitcoin_only() {
// ElectrumX is deliberately NOT gated (2026-07-22): a resyncing
// ElectrumX can be down for days and mempool-api reconnects on
// its own, so the wait only covers Bitcoin — even when electrumx
// is installed and stopped.
let calls = Arc::new(AtomicU32::new(0));
let (labels, sink) = label_sink();
let probe_calls = Arc::clone(&calls);
@@ -1114,8 +1129,8 @@ mod tests {
"mempool",
move || {
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
// Bitcoin comes up on probe 2, electrumx on probe 3.
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
// Bitcoin comes up on probe 2; electrumx never does.
async move { Ok(probe(n >= 1, false, &["bitcoin-knots", "electrumx"])) }
},
sink,
36,
@@ -1126,22 +1141,19 @@ mod tests {
let labels = labels.lock().unwrap();
assert_eq!(
labels.as_slice(),
&[
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
"Waiting for ElectrumX to start…".to_string(),
]
&["Waiting for Bitcoin to start…".to_string()]
);
}
#[tokio::test]
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
// Bitcoin is installed (waiting could help) but ElectrumX is not
// installed at all — waiting can never satisfy the gate, so fail
// fast with the canonical message.
async fn mempool_fails_fast_when_bitcoin_is_not_installed() {
// Bitcoin isn't installed at all — waiting can never satisfy the
// gate, so fail fast with the canonical message. (A missing
// ElectrumX no longer blocks anything.)
let (labels, sink) = label_sink();
let err = wait_for_install_deps(
"mempool",
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|| async { Ok(probe(false, false, &["electrumx"])) },
sink,
36,
Duration::ZERO,
@@ -294,6 +294,9 @@ impl RpcHandler {
if package_id == "netbird" {
return self.install_netbird_stack().await;
}
if package_id == "pine" {
return self.install_pine_stack().await;
}
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
@@ -1560,6 +1563,15 @@ autopilot.active=false\n",
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
async fn run_post_install_hooks(&self, package_id: &str) {
if matches!(package_id, "homeassistant" | "home-assistant")
&& super::pine_ha::pine_engines_installed().await
{
// Pine was installed first: wire HA to its Wyoming engines now,
// then restart so the seeded storage is what HA loads.
if super::pine_ha::seed_home_assistant_pine_defaults().await {
super::pine_ha::restart_home_assistant_if_running().await;
}
}
if package_id == "filebrowser" {
// Generate a random password (32 bytes, hex-encoded)
let mut buf = [0u8; 32];
@@ -3,6 +3,8 @@ mod config;
mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
mod set_config;
File diff suppressed because it is too large Load Diff
+62 -1
View File
@@ -12,7 +12,11 @@ use tracing::info;
use super::install::{install_log, patch_indeedhub_nostr_provider};
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
// 45s, not 10s: under heavy disk load (e.g. an ElectrumX resync/compaction
// hammering the same SSD) a plain `podman ps` was observed taking >10s on
// .116 (2026-07-22), failing a mempool install at the probe step for no real
// reason. Podman being slow is not podman being dead.
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(45);
const PODMAN_STACK_LOG_TIMEOUT: Duration = Duration::from_secs(15);
const PODMAN_STACK_PULL_TIMEOUT: Duration = Duration::from_secs(600);
const PODMAN_STACK_REMOVE_TIMEOUT: Duration = Duration::from_secs(90);
@@ -744,6 +748,15 @@ fn netbird_stack_app_ids() -> &'static [&'static str] {
&["netbird-server", "netbird-dashboard", "netbird"]
}
fn pine_stack_app_ids() -> &'static [&'static str] {
// Dependency/startup order: the two Wyoming engines (STT + TTS) first — they
// own their downloaded model/voice under /data and publish 10300/10200 —
// then the user-facing launcher ("pine", the nginx that serves the setup /
// status page + is the Open target). Mirrors the pine startup_order in
// dependencies.rs + the stack member table in app_ops.rs.
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"]
}
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
// Dependency order: backends + their generated secrets first, then the api
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
@@ -1907,6 +1920,54 @@ impl RpcHandler {
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
)
}
/// Install the Pine voice-assistant stack (Whisper STT + Piper TTS + the
/// setup/status launcher). Manifest-driven only, like netbird: render the
/// 3-member stack from apps/pine-*/manifest.yml via the orchestrator
/// (archy-net + network_aliases, published Wyoming ports 10300/10200 so
/// Home Assistant reaches the engines via host.containers.internal, the
/// launcher's setup page written via `files:`). The manifests use the exact
/// live container names, so on an existing node this ADOPTS the running
/// stack rather than recreating it (downloaded models/voices preserved).
///
/// There is no in-Rust hardcoded fallback: the signed catalog always ships
/// apps/pine-*/manifest.yml. If the orchestrator doesn't know these app_ids
/// and no running stack exists to adopt, install errors rather than
/// silently diverging from the manifest contract.
pub(super) async fn install_pine_stack(&self) -> Result<serde_json::Value> {
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
{
Self::seed_pine_ha_defaults().await;
return Ok(orchestrated);
}
if let Some(adopted) = adopt_stack_if_exists(
"pine",
"pine",
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
)
.await?
{
Self::seed_pine_ha_defaults().await;
return Ok(adopted);
}
anyhow::bail!(
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
)
}
/// After a Pine install, wire Home Assistant to the new engines when HA is
/// on this node (the HA post-install hook covers the reverse order).
async fn seed_pine_ha_defaults() {
if !super::pine_ha::home_assistant_installed().await {
return;
}
if super::pine_ha::seed_home_assistant_pine_defaults().await {
super::pine_ha::restart_home_assistant_if_running().await;
}
}
}
#[cfg(test)]
+159
View File
@@ -0,0 +1,159 @@
//! Node-status JSON for the Pine voice stack (`GET /api/pine/status`).
//!
//! Two tiers in one endpoint:
//! - **Public** (no credentials): version, uptime, bitcoin height / sync /
//! peer count, mesh peer count. Feeds the Pine launcher page's live status
//! card — nothing here a LAN visitor couldn't already infer from the
//! existing unauthenticated `/bitcoin-status`.
//! - **Token** (`Authorization: Bearer <pine-status-token>`): adds Lightning
//! balances and the most recent received mesh text message. Feeds the
//! Home Assistant REST sensors seeded by `package::pine_ha` — the seeder
//! mints the token into `data_dir/secrets/pine-status-token` (0600) and
//! embeds it in HA's configuration, so only HA (and the node owner) can
//! read balances or message text.
//!
//! Replaces the interim per-node socat forwarder + bitcoind-RPC-credentials-
//! in-configuration.yaml stopgap used before this endpoint existed.
use super::RpcHandler;
use crate::mesh::types::MessageDirection;
use serde_json::{json, Value};
/// File under `data_dir/secrets/` holding the bearer token that unlocks the
/// sensitive tier. Written by the pine/HA seeder, read per-request here.
pub(crate) const PINE_STATUS_TOKEN_FILE: &str = "pine-status-token";
/// Constant-time-ish equality — avoids early-exit timing on the token compare.
fn token_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
impl RpcHandler {
/// True when `presented` matches the on-disk pine status token. Missing or
/// empty token file means the sensitive tier is locked (seeder not run).
pub(crate) async fn pine_status_token_ok(&self, presented: &str) -> bool {
let path = self
.config
.data_dir
.join("secrets")
.join(PINE_STATUS_TOKEN_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(tok) => {
let tok = tok.trim();
!tok.is_empty() && token_eq(tok, presented.trim())
}
Err(_) => false,
}
}
/// Assemble the status document. `authorized` selects the token tier.
/// Every sub-source is best-effort: a dead bitcoind/LND/mesh never turns
/// the endpoint into an error — the field just reports what it can.
pub(crate) async fn pine_status_json(&self, authorized: bool) -> Value {
let bs = crate::bitcoin_status::get_bitcoin_status().await;
let bitcoin = {
let info = bs.blockchain_info.as_ref();
let height = info.and_then(|i| i.get("blocks")).and_then(Value::as_u64);
let progress = info
.and_then(|i| i.get("verificationprogress"))
.and_then(Value::as_f64);
let ibd = info
.and_then(|i| i.get("initialblockdownload"))
.and_then(Value::as_bool);
let peers = bs
.network_info
.as_ref()
.and_then(|n| n.get("connections"))
.and_then(Value::as_u64);
json!({
"ok": bs.ok,
"height": height,
"sync_percent": progress.map(|p| (p * 10000.0).round() / 100.0),
"ibd": ibd,
"peers": peers,
})
};
let (mesh, mesh_message) = {
let guard = self.mesh_service.read().await;
match guard.as_ref() {
Some(svc) => {
let status = svc.status().await;
let latest = if authorized {
svc.messages(None)
.await
.iter()
.rev()
.find(|m| {
m.direction == MessageDirection::Received
&& m.message_type == "text"
})
.map(|m| {
json!({
"id": m.id,
"from": m.peer_name.clone()
.unwrap_or_else(|| format!("contact {}", m.peer_contact_id)),
"text": m.plaintext,
"timestamp": m.timestamp,
})
})
} else {
None
};
(
json!({ "enabled": status.enabled, "peers": status.peer_count }),
latest,
)
}
None => (json!({ "enabled": false, "peers": 0 }), None),
}
};
let lightning = if authorized {
match self.handle_lnd_getinfo().await {
Ok(info) => json!({
"balance_sats": info.get("balance_sats"),
"channel_balance_sats": info.get("channel_balance_sats"),
"active_channels": info.get("num_active_channels"),
"synced_to_chain": info.get("synced_to_chain"),
}),
Err(_) => Value::Null,
}
} else {
Value::Null
};
json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": crate::crash_recovery::uptime_seconds(),
"bitcoin": bitcoin,
"mesh": mesh,
"lightning": lightning,
// Always an object: HA's REST sensor reads attributes via
// json_attributes_path "$.mesh_message", and a null there makes
// HA log a "JSON result was not a dictionary" warning every scan.
"mesh_message": mesh_message.unwrap_or_else(|| json!({})),
})
}
}
#[cfg(test)]
mod tests {
use super::token_eq;
#[test]
fn token_eq_matches_only_exact() {
assert!(token_eq("abc123", "abc123"));
assert!(!token_eq("abc123", "abc124"));
assert!(!token_eq("abc123", "abc12"));
assert!(!token_eq("", "x"));
assert!(token_eq("", ""));
}
}
@@ -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,
}))
}
@@ -719,4 +727,94 @@ impl RpcHandler {
_ => anyhow::bail!("Unknown setting: {}", key),
}
}
/// system.kiosk-display.get — Current kiosk display preset + whether this
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
&self,
) -> Result<serde_json::Value> {
let has_kiosk = tokio::fs::metadata("/etc/systemd/system/archipelago-kiosk.service")
.await
.is_ok();
let conf = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
.await
.unwrap_or_default();
let preset = if conf.contains("ARCHIPELAGO_KIOSK_SCALE=1") {
"native"
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920") {
"balanced"
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280") {
"large"
} else {
"auto"
};
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
}
/// system.kiosk-display.set — Write the kiosk display preset and restart
/// the kiosk (only if it is running) so it takes effect immediately. The
/// launcher sources /etc/archipelago/kiosk-display.conf at startup.
pub(in crate::api::rpc) async fn handle_system_kiosk_display_set(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let preset = params
.get("preset")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
let conf = match preset {
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
// 1080p TV -> 1.5, laptop panels -> 1.0.
"auto" => String::new(),
// Biggest UI: every panel targets a 1280-wide layout.
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
// Full-HD layout on any panel that can carry it.
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
// No scaling: native CSS viewport, most content, smallest UI.
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
other => anyhow::bail!("Unknown display preset: {other}"),
};
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
if conf.is_empty() {
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
} else {
// tee via sudo — the backend runs unprivileged and /etc is root's.
let mut child = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/tee", KIOSK_DISPLAY_CONF])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.context("spawn sudo tee for kiosk display conf")?;
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(conf.as_bytes()).await?;
}
let out = child.wait_with_output().await?;
if !out.status.success() {
anyhow::bail!(
"writing {} failed: {}",
KIOSK_DISPLAY_CONF,
String::from_utf8_lossy(&out.stderr).trim()
);
}
}
// try-restart: only bounces a kiosk that is actually running, so this
// never starts a kiosk an operator disabled.
let _ = host_sudo(&[
"/usr/bin/systemctl",
"try-restart",
"archipelago-kiosk.service",
])
.await;
info!(preset, "Kiosk display preset applied");
Ok(serde_json::json!({ "preset": preset, "applied": true }))
}
}
const KIOSK_DISPLAY_CONF: &str = "/etc/archipelago/kiosk-display.conf";
+13 -1
View File
@@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
/// Services for known-but-uninstalled apps are hidden (issue #79).
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let (data, _) = self.state_manager.get_snapshot().await;
let mut apps = AppInstallState {
known: Default::default(),
installed: Default::default(),
};
for (id, pkg) in &data.package_data {
apps.known.insert(id.clone());
if pkg.installed.is_some() {
apps.installed.insert(id.clone());
}
}
let services = list_services(&config_dir, Some(&apps)).await?;
let tor_running = check_tor_running().await;
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
}
+58 -3
View File
@@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
// ─── Service Listing ─────────────────────────────────────────────
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
/// Which packages the node knows about and which are installed — used to
/// hide hidden services for apps that aren't installed. ISO first-boot used
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
/// never installed (issue #79).
pub(super) struct AppInstallState {
pub known: std::collections::HashSet<String>,
pub installed: std::collections::HashSet<String>,
}
/// Package ids a Tor service name may correspond to. Service names predate
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
/// can map to several package ids.
fn service_alias_candidates(name: &str) -> Vec<&str> {
match name {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
}
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx", "electrs", "mempool-electrs"]
}
"btcpay" | "btcpay-server" | "btcpayserver" => {
vec!["btcpay", "btcpay-server", "btcpayserver"]
}
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
other => vec![other],
}
}
impl AppInstallState {
/// A service is listed unless it names a known-but-uninstalled app.
/// The node's own service, the content relay, and custom user-created
/// services (names matching no catalog package) always show.
fn service_visible(&self, name: &str) -> bool {
if name == "archipelago" || name == "relay" {
return true;
}
let candidates = service_alias_candidates(name);
if !candidates.iter().any(|c| self.known.contains(*c)) {
return true; // not an app — custom hidden service
}
candidates.iter().any(|c| self.installed.contains(*c))
}
}
pub(super) async fn list_services(
config_dir: &std::path::Path,
apps: Option<&AppInstallState>,
) -> Result<Vec<TorService>> {
let base = detect_hidden_service_base();
let config = load_services_config(config_dir).await;
let mut services = Vec::new();
let mut seen = std::collections::HashSet::new();
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
for entry in &config.services {
let onion = read_onion_address(&entry.name).await;
seen.insert(entry.name.clone());
if !visible(&entry.name) {
continue;
}
let onion = read_onion_address(&entry.name).await;
services.push(TorService {
name: entry.name.clone(),
local_port: entry.local_port,
@@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
if seen.contains(&service_name) {
continue;
}
seen.insert(service_name.clone());
if !visible(&service_name) {
continue;
}
let onion = read_onion_address(&service_name).await;
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
let is_proto = is_protocol_service(&service_name);
services.push(TorService {
name: service_name,
+46 -18
View File
@@ -437,6 +437,23 @@ impl RpcHandler {
Ok(serde_json::json!({ "added": true, "npub": npub }))
}
/// The host address a WireGuard peer should dial — prefer the configured
/// host IP, then public-IP lookup, then first local address.
async fn current_wg_endpoint_host(&self) -> String {
if self.config.host_ip != "127.0.0.1" {
return self.config.host_ip.clone();
}
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
}
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
pub(super) async fn handle_vpn_create_peer(
&self,
@@ -501,22 +518,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
};
// Detect host IP — prefer config, then nvpn, then system detection
let host_ip = if self.config.host_ip != "127.0.0.1" {
self.config.host_ip.clone()
} else {
// Fallback: get public IP via external service
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
};
let endpoint = format!("{}:51820", host_ip);
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
// Allocate a peer IP (simple: hash the peer name)
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
@@ -667,15 +669,41 @@ impl RpcHandler {
let content = tokio::fs::read_to_string(&peer_file)
.await
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
let peer: serde_json::Value = serde_json::from_str(&content)?;
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"No config stored for peer '{}' — recreate the device to get a new QR code",
name
)
})?;
// The stored Endpoint is the node's address at creation time; after
// the node moves networks it points at a dead IP and the QR produces
// a tunnel that can never connect. Refresh it to the current address.
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
let config: String = stored
.lines()
.map(|l| {
if l.trim_start().starts_with("Endpoint") {
format!("Endpoint = {}", endpoint)
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
if config != stored {
if let Some(obj) = peer.as_object_mut() {
obj.insert("config".to_string(), config.clone().into());
}
if let Ok(json) = serde_json::to_string_pretty(&peer) {
if tokio::fs::write(&peer_file, json).await.is_ok() {
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
}
}
}
let qr = qrcode::QrCode::new(config.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr
+9 -1
View File
@@ -50,6 +50,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
// The legacy umbrella id maps to the split stack (the orchestrator's
// umbrella alias handles this too; listing it here keeps the RPC
// layer's fan-out explicit).
@@ -75,7 +76,14 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
/// they drive archy-mempool-web), otherwise the app itself.
fn owning_package(app_id: &str) -> &str {
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
const STACKS: &[&str] = &[
"immich",
"indeedhub",
"btcpay-server",
"netbird",
"mempool",
"pine",
];
for stack in STACKS {
if stack_member_app_ids(stack).contains(&app_id) {
return stack;
+241
View File
@@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
// The router daemon handles routing + the ELD re-modeset nudge; this heal
// installs the packages, group membership, script and unit on deployed nodes.
const AUDIO_ROUTER: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
const AUDIO_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
// splice-from-configs + self-heal pattern as the audio router.
const GAMEPAD_KEYS: &str =
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
const GAMEPAD_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
// write the identical file at build time (image-recipe/_archived/
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
@@ -80,6 +102,13 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block lacking the Pine node-status proxy.
/// `/api/pine/status` serves the Pine launcher page's live status card and
/// the seeded Home Assistant REST sensors (the sensitive tier is gated by a
/// bearer token at the backend, so nginx just forwards). Kept in sync with
/// the canonical block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PINE_STATUS_BLOCK: &str = "\n # Pine node status — live node facts for the Pine launcher page and the\n # seeded Home Assistant sensors. Sensitive fields are token-gated at the\n # backend; nginx only forwards.\n location /api/pine/status {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Authorization $http_authorization;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 10s;\n proxy_read_timeout 15s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
@@ -137,6 +166,13 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
match run_polkit_networkmanager_repair().await {
Ok(true) => info!(
"Installed NetworkManager polkit rule for the archipelago user — Wi-Fi setup enabled"
),
Ok(false) => debug!("NetworkManager polkit rule already present"),
Err(e) => warn!("polkit NetworkManager repair failed (non-fatal): {:#}", e),
}
match run_journald_dropin().await {
Ok(true) => info!("Installed journald log-volume policy drop-in"),
Ok(false) => debug!("journald log-volume policy already in place"),
@@ -442,6 +478,68 @@ exit 2
}
}
/// Self-heal Wi-Fi setup on nodes that predate the polkit fix (issue #99).
///
/// Archipelago drives NetworkManager from a system-level systemd service
/// (`User=archipelago`, no logind seat), so the stock NM polkit rule — which
/// only authorizes `subject.local && subject.active` sessions — denies it, and
/// "connect to Wi-Fi" fails with "Insufficient privileges". Fresh ISO installs
/// since 2026-05 ship the rule below, but nodes that reached this build over
/// OTA never got it (OTA replaces the binary + web UI, not host system config).
///
/// Install the scoped rule if it is missing, and best-effort ensure `polkitd`
/// itself is present (without the daemon the rule is inert). Both are wrapped
/// so an offline/locked apt or a missing package can never fail startup — the
/// rule is still written so it takes effect once polkitd arrives (e.g. after an
/// ISO reflash). Idempotent: keyed on the rule's unique `subject.user` marker.
async fn run_polkit_networkmanager_repair() -> Result<bool> {
let script = r#"
set -u
RULE=/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
MARKER='subject.user == "archipelago"'
# Rule already installed nothing to do.
if grep -qF "$MARKER" "$RULE" 2>/dev/null; then
exit 0
fi
# The rule is inert without the polkit daemon. Older nodes (the ones that hit
# issue #99) shipped without it. Try to install it, but never let apt failure
# (offline node, locked dpkg, package unavailable) abort the heal the rule is
# written regardless so it activates whenever polkitd lands.
if [ ! -d /usr/share/polkit-1 ] && ! command -v pkaction >/dev/null 2>&1; then
timeout 240 apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1 \
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1' \
|| true
fi
mkdir -p /etc/polkit-1/rules.d
cat > "$RULE" <<'RULEEOF'
polkit.addRule(function(action, subject) {
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
return polkit.Result.YES;
}
});
RULEEOF
chmod 644 "$RULE"
# Pick up the new rule. polkitd re-reads rules.d on reload; restart as a
# fallback. Non-fatal if the unit name differs or the daemon is absent.
systemctl reload polkit 2>/dev/null \
|| systemctl restart polkit 2>/dev/null \
|| systemctl restart polkit.service 2>/dev/null \
|| true
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("install NetworkManager polkit rule")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => Ok(true),
_ => {
warn!("polkit NetworkManager repair helper exited with {}", status);
Ok(false)
}
}
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
@@ -718,6 +816,109 @@ pub async fn ensure_kiosk_hardened() {
}
}
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
/// under the lingering user manager — no logind seat, so no udev ACLs on
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
/// nudge) installed and current. No-op on nodes without the kiosk — audio
/// only matters where media plays on an attached display.
pub async fn ensure_audio_stack() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return; // no kiosk → no display audio to route
}
// Package install runs via systemd-run (host_sudo), outside the service
// sandbox — /usr and the dpkg database are read-only in our namespace.
if fs::metadata("/usr/bin/pactl").await.is_err() {
info!("audio: PipeWire stack missing — installing packages");
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
match host_sudo(&[
"apt-get",
"install",
"-y",
"-qq",
"--no-install-recommends",
"pipewire",
"pipewire-pulse",
"pipewire-alsa",
"wireplumber",
"alsa-utils",
])
.await
{
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
Ok(s) => {
warn!("audio: package install exited with {} — will retry next start", s);
return;
}
Err(e) => {
warn!("audio: package install failed: {:#} — will retry next start", e);
return;
}
}
}
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
}
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("audio: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
// First install on this node — bring it up now and on every boot.
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
} else if script_changed || unit_changed {
// Content update: restart only if it's running — never re-enable a
// unit an operator deliberately disabled.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
info!("audio: router updated");
}
}
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
/// daemon (controller works inside every app iframe on the TV) installed and
/// current. Same gating as audio: no kiosk → no display input to bridge.
pub async fn ensure_gamepad_keys() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return;
}
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
}
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
info!("gamepad: bridge installed and enabled (TV controller input)");
} else if script_changed || unit_changed {
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
info!("gamepad: bridge updated");
}
}
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend
@@ -785,22 +986,46 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !content.contains("location /bitcoin-status");
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
let missing_pine_status = has_lnd_anchor && !content.contains("location /api/pine/status");
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
let needs_fedimint_css = content.contains("location /app/fedimint/")
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
// Companion mesh access: phones reach this node over FIPS at its fips0
// ULA (http://[fdxx:…]). Configs shipped before 2026-07-23 listened on
// IPv4 only, so the ULA could never connect — nothing answered [::]:80.
let missing_v6_http = content.contains("listen 80 default_server;")
&& !content.contains("listen [::]:80");
let missing_v6_https = content.contains("listen 443 ssl default_server;")
&& !content.contains("listen [::]:443");
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
&& !missing_peer_content
&& !missing_pine_status
&& !has_lnd_dup_cors
&& !needs_fedimint_css
&& !missing_v6_http
&& !missing_v6_https
{
return Ok(false);
}
let mut patched = content.clone();
if missing_v6_http {
patched = patched.replace(
"listen 80 default_server;",
"listen 80 default_server;\n listen [::]:80 default_server;",
);
}
if missing_v6_https {
patched = patched.replace(
"listen 443 ssl default_server;",
"listen 443 ssl default_server;\n listen [::]:443 ssl default_server;",
);
}
if has_lnd_dup_cors {
// Drop the redundant nginx-side CORS headers so the backend's single
// validated Access-Control-Allow-Origin is the only one returned.
@@ -838,6 +1063,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
}
}
if missing_pine_status {
// Same anchoring as the LND proxy: prepend to every server block that
// proxies to the backend.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if patched.contains(anchor) {
let replacement = format!("{}{}", NGINX_PINE_STATUS_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
} else {
warn!("nginx conf missing anchor — skipping /api/pine/status patch");
}
}
if missing_peer_content {
// Same anchoring as the LND proxy: prepend the block to every server
// block so /api/peer-content/* reaches the backend instead of the SPA.
@@ -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 {
@@ -57,6 +57,9 @@ impl DockerPackageScanner {
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"pine-whisper",
"pine-piper",
"pine-openwakeword",
"buildx_buildkit_default",
];
@@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
/// write-probes are `podman exec`s into EVERY running container; running them
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
/// tick forever — a permanent conmon "Failed to create container" storm on
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
/// steady-state one: sweep each container on the first pass after it appears,
/// then at most once per hour.
fn ownership_sweep_due(name: &str) -> bool {
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
static LAST: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
> = std::sync::OnceLock::new();
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let Ok(mut map) = map.lock() else {
return true;
};
let now = std::time::Instant::now();
match map.get(name) {
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
_ => {
map.insert(name.to_string(), now);
true
}
}
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container.
///
@@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator {
if crate::app_ops::lifecycle_op_in_flight(&c.name) {
continue;
}
// Throttled: first pass after the container appears, then
// hourly — not on every 30s tick (see ownership_sweep_due).
if !ownership_sweep_due(&c.name) {
continue;
}
if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman")
@@ -3062,8 +3094,9 @@ impl ProdContainerOrchestrator {
.unwrap_or_else(|| "bitcoin-knots".to_string());
}
#[allow(unreachable_code)]
// Mirrors api::rpc::package::dependencies (the legacy install path);
// both Bitcoin node variants are reachable on archy-net by name.
// The known Bitcoin node containers, preferred in order. Any archy
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
// (or bare `bitcoin`), all reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
@@ -3073,12 +3106,23 @@ impl ProdContainerOrchestrator {
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
names
.lines()
.map(|l| l.trim())
.find(|name| BITCOIN_NAMES.contains(name))
.map(|name| name.to_string())
.unwrap_or_else(|| "bitcoin-knots".to_string())
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
// Prefer a known name in priority order…
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
return hit.to_string();
}
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
}
#[cfg(test)]
@@ -3215,10 +3259,10 @@ impl ProdContainerOrchestrator {
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
}
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
// derived_env (works on Knots/Core/any distro). The old hardcoded
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
// derived-env and broke Core nodes.
let provider = FileSecretsProvider {
root: self.secrets_dir.clone(),
+24 -4
View File
@@ -284,7 +284,7 @@ impl QuadletUnit {
let _ = writeln!(s, "PodmanArgs=--cpus={cpus}");
}
if let Some(h) = &self.health {
let _ = writeln!(s, "HealthCmd={}", h.cmd);
let _ = writeln!(s, "HealthCmd={}", h.cmd.replace('%', "%%"));
let _ = writeln!(s, "HealthInterval={}", h.interval);
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
let _ = writeln!(s, "HealthRetries={}", h.retries);
@@ -298,7 +298,7 @@ impl QuadletUnit {
// images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper
// into `bitcoind sh -lc ...` and crash-looped). Emitting
// Entrypoint= makes the unit independent of the image's entrypoint.
let _ = writeln!(s, "Entrypoint={first}");
let _ = writeln!(s, "Entrypoint={}", first.replace('%', "%%"));
let mut parts: Vec<String> = rest.to_vec();
parts.extend(self.command.iter().cloned());
if !parts.is_empty() {
@@ -335,11 +335,16 @@ impl QuadletUnit {
/// the minimum quoting needed so quadlet's parser sees one element per
/// item: anything containing whitespace, quotes, or shell metacharacters
/// gets wrapped in double quotes with embedded `"` and `\` escaped.
///
/// `%` is escaped to `%%`: quadlet copies Exec= content into the generated
/// service's ExecStart, where systemd expands `%` specifiers at load time —
/// a bare `%s` in a manifest script silently became `/bin/bash` (the user's
/// shell) and corrupted bitcoind's generated rpc.conf.
fn shell_join(parts: &[String]) -> String {
parts
.iter()
.map(|p| {
let p = p.replace(['\r', '\n'], " ");
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
let escaped = p
.replace('\\', "\\\\")
@@ -355,7 +360,8 @@ fn shell_join(parts: &[String]) -> String {
}
fn quote_environment(env: &str) -> String {
let env = env.replace(['\r', '\n'], " ");
// `%` → `%%` for the same systemd-specifier reason as shell_join.
let env = env.replace(['\r', '\n'], " ").replace('%', "%%");
if env.is_empty()
|| env
.chars()
@@ -1122,6 +1128,20 @@ mod tests {
);
}
#[test]
fn percent_is_escaped_for_systemd_specifiers() {
// Regression: a bare `%s` in an Exec= line is a systemd specifier
// (user's shell = /bin/bash) once quadlet copies it into the
// generated ExecStart — bitcoind's rpc.conf came out as
// `rpcuser=/bin/bash` and every RPC consumer got 401s.
assert_eq!(
shell_join(&["printf 'rpcuser=%s' \"$U\"".to_string()]),
"\"printf 'rpcuser=%%s' \\\"$$U\\\"\""
);
assert_eq!(shell_join(&["--fmt=%h:%p".to_string()]), "--fmt=%%h:%%p");
assert_eq!(quote_environment("FMT=%m"), "FMT=%%m");
}
#[test]
fn restart_policy_emits_correct_systemd_string() {
assert_eq!(RestartPolicy::Always.as_systemd(), "always");
+43 -4
View File
@@ -51,9 +51,25 @@ pub enum AccessControl {
PeersOnly,
Paid {
price_sats: u64,
/// Payment methods the sharer accepts: "lightning", "onchain",
/// "ecash", "fedimint". Empty = everything — which is also what
/// catalogs written before this field deserialize to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
accepted: Vec<String>,
},
}
/// Does the sharer accept this payment method for the item? Empty list =
/// all methods (pre-field catalogs and "no preference").
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
match access {
AccessControl::Paid { accepted, .. } => {
accepted.is_empty() || accepted.iter().any(|m| m == method)
}
_ => true,
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ContentCatalog {
pub items: Vec<ContentItem>,
@@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
}
/// Add a content item to the catalog.
///
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
/// every call, so id-only dedup let the same file be shared twice as two
/// separately-priced entries — and a buyer paid twice for one file
/// (2026-07-22). Same filename → update the existing entry in place and
/// keep its id, so existing buyers' owned records stay valid.
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
let mut catalog = load_catalog(data_dir).await?;
if catalog.items.iter().any(|i| i.id == item.id) {
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
}
catalog.items.push(item);
let norm = |f: &str| f.trim_start_matches('/').to_string();
if let Some(existing) = catalog
.items
.iter_mut()
.find(|i| norm(&i.filename) == norm(&item.filename))
{
let keep_id = existing.id.clone();
*existing = item;
existing.id = keep_id;
} else {
catalog.items.push(item);
}
save_catalog(data_dir, &catalog).await?;
Ok(catalog)
}
@@ -252,20 +285,26 @@ pub async fn serve_content(
// Check access control
match &item.access {
AccessControl::Paid { price_sats } => {
AccessControl::Paid { price_sats, .. } => {
// Two ways to satisfy payment:
// (a) a valid ecash token (the local-wallet fast path), or
// (b) a Lightning-invoice payment hash this node issued and has
// since confirmed settled (the "pay from any wallet" path, #46).
// Each path only counts when the sharer accepts that method.
let mut authorized = false;
if let Some(token) = payment_token {
if verify_payment_token(data_dir, token, *price_sats).await {
if (method_accepted(&item.access, "ecash")
|| method_accepted(&item.access, "fedimint"))
&& verify_payment_token(data_dir, token, *price_sats).await
{
authorized = true;
}
}
if !authorized {
if let Some(hash) = invoice_hash {
if crate::content_invoice::is_paid_for(hash, id).await {
if method_accepted(&item.access, "lightning")
&& crate::content_invoice::is_paid_for(hash, id).await
{
authorized = true;
}
}
+93 -3
View File
@@ -372,6 +372,28 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
/// Recover containers that were running before a crash.
/// Attempts to start each container, logging success/failure.
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
// Snapshot entries can outlive their containers (removed while we were
// down, or podman storage partially reset by an unclean poweroff).
// `podman start` on those fails permanently, and recovery runs BEFORE the
// server binds its port and notifies systemd ready — burning retries on
// them pushed recovery past TimeoutStartSec and brick-looped the node
// (killed mid-recovery → next boot sees a crash again, forever).
let containers: Vec<&RunningContainerRecord> = match existing_container_names().await {
Some(existing) => {
let (present, missing): (Vec<_>, Vec<_>) =
containers.iter().partition(|r| existing.contains(&r.name));
if !missing.is_empty() {
warn!(
"Skipping {} snapshot container(s) that no longer exist: {:?}",
missing.len(),
missing.iter().map(|r| r.name.as_str()).collect::<Vec<_>>()
);
}
present
}
None => containers.iter().collect(),
};
let mut report = RecoveryReport {
total: containers.len(),
recovered: 0,
@@ -381,11 +403,28 @@ 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
);
// Recovery counts against systemd's start timeout; a heavy node
// legitimately needs several minutes for dozens of containers. Push
// the deadline out ahead of each container so systemd only kills us
// if we stop making progress (360s covers one full attempt chain).
let _ = sd_notify::notify(
false,
&[sd_notify::NotifyState::ExtendTimeoutUsec(360_000_000)],
);
// Rate-limit container starts to avoid overwhelming podman on low-resource systems
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
@@ -427,6 +466,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
attempt + 1,
stderr.trim()
);
// The container is gone (raced past the pre-filter, or the
// filter query failed) — retrying can never succeed.
if stderr.contains("no such container") {
break;
}
}
Err(e) => {
warn!(
@@ -448,6 +492,26 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
report
}
/// All container names podman knows about (running or not). `None` if the
/// query fails — callers fail open and attempt every snapshot entry.
async fn existing_container_names() -> Option<std::collections::HashSet<String>> {
let output = podman_output(
&["ps", "-a", "--format", "{{.Names}}"],
Duration::from_secs(30),
)
.await
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.map(|s| s.to_string())
.collect(),
)
}
#[derive(Debug)]
pub struct RecoveryReport {
pub total: usize,
@@ -710,6 +774,10 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
| "netbird-server"
| "netbird-dashboard"
| "netbird"
| "pine-whisper"
| "pine-piper"
| "pine-openwakeword"
| "pine"
)
}
@@ -771,6 +839,21 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
containers: &["netbird-server", "netbird-dashboard", "netbird"],
anchor: "netbird-server",
},
StackRecoverySpec {
name: "pine",
network: "archy-net",
aliases: &[
("pine-whisper", "pine-whisper"),
("pine-piper", "pine-piper"),
("pine-openwakeword", "pine-openwakeword"),
("pine", "pine"),
],
containers: &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
// The launcher only depends on the two engines; whisper is the
// heaviest/first member, so treat it as the stack anchor (its
// presence means the stack was really installed, not orphan debris).
anchor: "pine-whisper",
},
]
}
@@ -861,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
}
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;
+131
View File
@@ -0,0 +1,131 @@
//! Companion device tokens — long-lived bearer credentials minted from an
//! authenticated session, so the pairing QR can log a phone in without
//! carrying the admin password (which the browser never has anyway).
//!
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
//! data dir); the plaintext is returned exactly once at mint time and rides
//! the QR as the `tok` param. Verification goes through `auth.login`'s
//! `token` param and is covered by the same login rate limiter as passwords.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tokio::fs;
const TOKENS_FILE: &str = "device-tokens.json";
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
/// so this only limits the number of *distinct* device names.
const MAX_TOKENS: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceToken {
pub name: String,
/// Hex SHA-256 of the plaintext token.
pub hash: String,
/// Unix seconds at mint time.
pub created: u64,
}
fn tokens_path(data_dir: &Path) -> PathBuf {
data_dir.join(TOKENS_FILE)
}
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
match fs::read(tokens_path(data_dir)).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
let bytes = serde_json::to_vec_pretty(tokens)?;
fs::write(tokens_path(data_dir), bytes)
.await
.context("write device-tokens.json")
}
fn hash_hex(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// Mint a new token for `name`. An existing token with the same name is
/// replaced, so re-showing the pairing QR never piles up stale entries.
/// Returns the plaintext token — the only time it ever exists outside the QR.
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
let token_bytes: [u8; 32] = rand::random();
let token = hex::encode(token_bytes);
let mut tokens = load(data_dir).await;
tokens.retain(|t| t.name != name);
if tokens.len() >= MAX_TOKENS {
tokens.remove(0);
}
tokens.push(DeviceToken {
name: name.to_string(),
hash: hash_hex(&token),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
});
save(data_dir, &tokens).await?;
Ok(token)
}
/// Verify a candidate token. Returns the device name it was minted for.
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
let candidate_hash = hash_hex(candidate);
load(data_dir)
.await
.iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
.map(|t| t.name.clone())
}
/// List stored tokens (hashes only — plaintexts are unrecoverable).
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
load(data_dir).await
}
/// Remove the token minted for `name`. Returns whether one existed.
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
let mut tokens = load(data_dir).await;
let before = tokens.len();
tokens.retain(|t| t.name != name);
let removed = tokens.len() != before;
if removed {
save(data_dir, &tokens).await?;
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn mint_verify_replace_remove() {
let dir = tempfile::tempdir().unwrap();
let token = create(dir.path(), "phone").await.unwrap();
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
assert!(verify(dir.path(), "not-a-token").await.is_none());
// Re-minting the same name invalidates the old token.
let token2 = create(dir.path(), "phone").await.unwrap();
assert!(verify(dir.path(), &token).await.is_none());
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
assert_eq!(list(dir.path()).await.len(), 1);
assert!(remove(dir.path(), "phone").await.unwrap());
assert!(verify(dir.path(), &token2).await.is_none());
}
}
+93 -18
View File
@@ -49,9 +49,7 @@ pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
/// The upstream public anchor as a ready-to-apply `SeedAnchor`.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
@@ -61,6 +59,65 @@ pub fn default_public_anchor() -> SeedAnchor {
}
}
// Archipelago-operated anchor on vps2 (the OTA/registry host, 146.59.87.168).
// Every node already reaches this host for updates, so it is reachable from
// networks that the upstream anchor is not — which is most of them (the
// upstream anchor answers on one IPv4 that many home/office networks can't
// reach, and its DNS resolves IPv6-first while the daemon is IPv4-only).
// TCP because that traverses NAT/firewalls best; 8444 because 8443 on that
// host is already taken by a container.
pub const ARCHY_ANCHOR_NPUB: &str =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak";
pub const ARCHY_ANCHOR_ADDR: &str = "146.59.87.168:8444";
pub const ARCHY_ANCHOR_TRANSPORT: &str = "tcp";
/// The Archipelago-operated anchor as a ready-to-apply `SeedAnchor`.
pub fn archy_anchor() -> SeedAnchor {
SeedAnchor {
npub: ARCHY_ANCHOR_NPUB.to_string(),
address: ARCHY_ANCHOR_ADDR.to_string(),
transport: ARCHY_ANCHOR_TRANSPORT.to_string(),
label: "Archipelago anchor (vps2)".to_string(),
}
}
/// Public FIPS-network anchors from join.fips.network (adverts published as
/// Nostr kind-37195 `fips-overlay-v1` events, refreshed hourly). Of the eight
/// live test anchors (2026-07-22), these two are the dual-transport ones —
/// they answer on TCP as well as UDP, and TCP is what traverses restrictive
/// networks. The remaining six are UDP-only; add them per-node via
/// `fips.add-seed-anchor` if more rendezvous diversity is wanted.
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
vec![
SeedAnchor {
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u"
.to_string(),
address: "23.182.128.74:443".to_string(),
transport: "tcp".to_string(),
label: "FIPS network anchor (join.fips.network)".to_string(),
},
SeedAnchor {
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
.to_string(),
address: "217.77.8.91:443".to_string(),
transport: "tcp".to_string(),
label: "FIPS network anchor (join.fips.network)".to_string(),
},
]
}
/// The default anchor set carried implicitly by `load()` on nodes that have
/// never edited their anchor list, so every node dials them without operator
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
/// node: `fipsctl connect` is attempted for each, and whichever the node's
/// network can reach wins. The Archipelago-operated anchor is listed first
/// because it is reachable from the widest set of networks.
pub fn default_public_anchors() -> Vec<SeedAnchor> {
let mut anchors = vec![archy_anchor(), default_public_anchor()];
anchors.extend(fips_network_anchors());
anchors
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
@@ -94,7 +151,7 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(vec![default_public_anchor()]);
return Ok(default_public_anchors());
}
let bytes = tokio::fs::read(&path)
.await
@@ -268,28 +325,46 @@ mod tests {
}
#[tokio::test]
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
async fn load_missing_seeds_default_public_anchors() {
// A node that has never edited its anchor list should still get the
// full default anchor set so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
assert_eq!(got, default_public_anchors());
// The Archipelago-operated anchor must come first (widest reachability)
// and the upstream anchor must remain present as a fallback.
assert_eq!(got[0], archy_anchor());
assert!(got.contains(&default_public_anchor()));
// Every default must be a TCP form (traverses NAT/firewalls), never the
// dead udp:8668 the upstream anchor never answers on.
assert!(got.iter().all(|a| a.transport == "tcp"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
async fn removing_one_default_persists_and_keeps_the_other() {
// Editing the anchor list (here removing one default) makes the file
// authoritative: the removed anchor must not be silently re-seeded on
// next load, and the remaining default must stay.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
let list = remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
assert!(!list.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
assert!(list.contains(&default_public_anchor()));
let got = load(dir.path()).await.unwrap();
assert_eq!(got, list, "edited list is authoritative; no re-seed");
}
#[tokio::test]
async fn removing_all_defaults_persists_as_empty() {
// Removing every default leaves an empty authoritative list that must
// not be re-seeded on next load.
let dir = tempfile::tempdir().unwrap();
let mut list = Vec::new();
for anchor in default_public_anchors() {
list = remove(dir.path(), &anchor.npub).await.unwrap();
}
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
assert!(got.is_empty(), "defaults must stay removed once edited");
}
#[tokio::test]
+193 -40
View File
@@ -10,54 +10,153 @@
//! whitelists `install` into `/etc/fips/`.
use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use tokio::process::Command;
use super::{
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT,
};
/// Write the FIPS daemon config based on the local npub and default
/// transports. Overwrites any existing file — callers are expected to
/// Header prepended to the generated YAML. serde doesn't emit comments, so
/// this is concatenated onto the serialised body.
const CONFIG_HEADER: &str = "# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n";
/// Typed mirror of the subset of upstream `fips.yaml` that archipelago owns.
///
/// This was previously built by `format!`-ing a string literal. Upstream's
/// config structs are `#[serde(deny_unknown_fields)]`, so a key we get wrong
/// doesn't degrade gracefully — the daemon refuses to start and the node drops
/// off the mesh. Serialising from typed structs lets the compiler and the
/// tests below catch drift, instead of a node discovering it at boot after an
/// upgrade.
///
/// Schema verified field-by-field against jmcorgan/fips **v0.4.1** (2026-07-20).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FipsConfig {
pub node: NodeSection,
pub tun: TunSection,
pub dns: DnsSection,
pub transports: TransportsSection,
/// Static peers. Always empty: archipelago feeds peers dynamically via the
/// seed-anchors apply loop and federation-invite hooks.
pub peers: Vec<PeerEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NodeSection {
pub identity: IdentitySection,
pub discovery: DiscoverySection,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct IdentitySection {
/// With `persistent: true` the daemon reuses the key file at
/// config-dir/fips.key (= `DAEMON_KEY_PATH`) instead of generating an
/// ephemeral identity on every start.
pub persistent: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DiscoverySection {
pub lan: LanDiscoverySection,
}
/// mDNS / DNS-SD discovery on the local link (`node.discovery.lan.*`), added
/// upstream in v0.4.0 and opt-in there (upstream default is `false`).
///
/// We enable it so co-located nodes peer directly instead of depending on the
/// public anchor being reachable — an anchor blackhole on one network segment
/// otherwise islands a node completely.
///
/// Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
/// has no `lan` field *and* no `deny_unknown_fields`, so a v0.3.0 daemon ignores
/// this key harmlessly (verified against the v0.3.0 source). It therefore starts
/// working on its own when a node upgrades, with no second config migration.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LanDiscoverySection {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TunSection {
pub enabled: bool,
pub name: String,
pub mtu: u16,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DnsSection {
pub enabled: bool,
pub bind_addr: String,
}
/// Both UDP and TCP are enabled: the public anchor answers on TCP/8443 only,
/// and networks that block outbound UDP can still bootstrap over TCP.
/// Upstream dropped the `tor:` transport variant — archipelago's own Tor
/// fallback handles that layer.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TransportsSection {
pub udp: TransportBind,
pub tcp: TransportBind,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TransportBind {
/// Upstream takes `bind_addr` ("host:port"), not `enabled` + `port`.
pub bind_addr: String,
}
/// A static peer entry. Never constructed today (see `FipsConfig::peers`), but
/// typed so the shape is checked if static peering is ever needed.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PeerEntry {
pub npub: String,
pub address: String,
pub transport: String,
}
impl Default for FipsConfig {
fn default() -> Self {
Self {
node: NodeSection {
identity: IdentitySection { persistent: true },
discovery: DiscoverySection {
lan: LanDiscoverySection { enabled: true },
},
},
tun: TunSection {
enabled: true,
name: "fips0".to_string(),
mtu: 1280,
},
dns: DnsSection {
enabled: true,
bind_addr: "127.0.0.1".to_string(),
},
transports: TransportsSection {
udp: TransportBind {
bind_addr: format!("0.0.0.0:{PUBLISHED_UDP_PORT}"),
},
tcp: TransportBind {
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
},
},
peers: Vec::new(),
}
}
}
/// Render the FIPS daemon config. Overwrites any existing file — callers
/// re-run this whenever the key or daemon version changes.
///
/// Schema is intentionally minimal: node identity comes from the key
/// file on disk (the daemon handles it), transports enable UDP + TCP
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
/// Static peer list is empty — archipelago feeds peers dynamically via
/// the seed-anchors apply loop and federation-invite hooks.
/// Node identity comes from the key file on disk; the static peer list stays
/// empty because peers are fed dynamically at runtime.
pub fn render_config_yaml() -> String {
// Schema matches upstream jmcorgan/fips as of 2026-04. With
// `node.identity.persistent: true` the daemon reuses the key file at
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
// rather than `enabled: true / port: N`. Both UDP and TCP are
// enabled by default because the public anchor (fips.v0l.io)
// currently answers on TCP/8443 only, and networks that block UDP
// outbound can still bootstrap via TCP. Upstream fips no longer
// has a `tor:` transport variant — archipelago's own Tor fallback
// handles that layer.
format!(
"# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n\
node:\n \
identity:\n \
persistent: true\n\
tun:\n \
enabled: true\n \
name: fips0\n \
mtu: 1280\n\
dns:\n \
enabled: true\n \
bind_addr: \"127.0.0.1\"\n\
transports:\n \
udp:\n \
bind_addr: \"0.0.0.0:{udp}\"\n \
tcp:\n \
bind_addr: \"0.0.0.0:{tcp}\"\n\
peers: []\n",
udp = DEFAULT_UDP_PORT,
tcp = DEFAULT_TCP_PORT,
)
let body = serde_yaml::to_string(&FipsConfig::default())
.expect("FipsConfig is a plain struct tree and cannot fail to serialise");
format!("{CONFIG_HEADER}{body}")
}
/// Install the local FIPS key + rendered config into `/etc/fips/`.
@@ -194,7 +293,7 @@ mod tests {
fn test_rendered_yaml_matches_upstream_schema() {
let yaml = render_config_yaml();
assert!(yaml.contains("persistent: true"));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", PUBLISHED_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
assert!(yaml.contains("udp:"));
assert!(yaml.contains("tcp:"));
@@ -205,6 +304,60 @@ mod tests {
assert!(!yaml.contains("tor:"));
}
/// Exact-output snapshot. Upstream's config structs are
/// `deny_unknown_fields`, so an accidental key rename/addition means the
/// daemon won't start. Pinning the full rendering makes any such change
/// fail here — where it's cheap — instead of on a node after an upgrade.
/// If this fails, re-verify against the upstream schema before updating it.
#[test]
fn test_rendered_yaml_exact_snapshot() {
let expected = "\
# Generated by archipelago do not edit by hand.
# Regenerated on every key change and daemon upgrade.
node:
identity:
persistent: true
discovery:
lan:
enabled: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: 127.0.0.1
transports:
udp:
bind_addr: 0.0.0.0:2121
tcp:
bind_addr: 0.0.0.0:8443
peers: []
";
assert_eq!(render_config_yaml(), expected);
}
/// The rendered config must parse as YAML and carry the mDNS opt-in at the
/// exact path upstream reads (`node.discovery.lan.enabled`) — a typo there
/// would silently leave LAN discovery off rather than erroring.
#[test]
fn test_lan_discovery_enabled_at_upstream_path() {
let yaml = render_config_yaml();
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("renders valid YAML");
assert_eq!(
parsed["node"]["discovery"]["lan"]["enabled"],
serde_yaml::Value::Bool(true),
);
}
/// Rendering is deterministic: the startup drift check in server.rs compares
/// the freshly rendered config against what's on disk, so any instability
/// here would cause an endless reinstall+restart loop of the daemon.
#[test]
fn test_render_is_deterministic() {
assert_eq!(render_config_yaml(), render_config_yaml());
}
#[tokio::test]
async fn test_install_refuses_when_key_missing() {
let dir = tempfile::tempdir().unwrap();
+9
View File
@@ -119,6 +119,15 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
/// Default UDP port the daemon listens on.
pub const DEFAULT_UDP_PORT: u16 = 8668;
/// UDP port archipelago actually publishes/binds for the daemon. The
/// container publishes `2121:2121/udp` (see `package/config.rs`) and every
/// peer roster in the fleet dials `<host>:2121`, but the rendered daemon
/// config used to bind upstream's 8668 — leaving inbound UDP dead on
/// bridged-network installs and everything silently riding TCP 8443. The
/// bind now uses this port so the published mapping, the fleet rosters,
/// and the pairing QR all agree.
pub const PUBLISHED_UDP_PORT: u16 = 2121;
/// Default TCP port the daemon listens on. Used as a fallback when a
/// peer can't be reached over UDP — common on networks that block UDP
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
+53
View File
@@ -34,6 +34,7 @@ mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod mesh_ports;
mod ceremony;
mod config;
mod constants;
@@ -45,6 +46,7 @@ mod content_server;
mod crash_recovery;
mod credentials;
mod data_model;
mod device_tokens;
mod disk_monitor;
mod electrs_status;
mod federation;
@@ -98,6 +100,40 @@ async fn main() -> Result<()> {
return ceremony::run();
}
// Plain CLI flags must never boot the daemon (a stray `--version` used to
// start a second instance next to the systemd one). Handled before any
// tracing/state init so stdout stays clean.
match std::env::args().nth(1).as_deref() {
Some("--version") | Some("-V") => {
println!(
"archipelago {}-{}",
env!("CARGO_PKG_VERSION"),
option_env!("GIT_HASH").unwrap_or("dev")
);
return Ok(());
}
Some("--help") | Some("-h") => {
println!("Archipelago Bitcoin Node OS");
println!();
println!("Usage: archipelago [COMMAND]");
println!();
println!("Running with no arguments starts the node daemon.");
println!();
println!("Commands:");
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
println!();
println!("Options:");
println!(" -V, --version Print version and exit");
println!(" -h, --help Print this help and exit");
return Ok(());
}
Some(other) if other.starts_with('-') => {
eprintln!("archipelago: unknown option '{other}' (see --help)");
std::process::exit(2);
}
_ => {}
}
let startup_start = std::time::Instant::now();
crash_recovery::init_start_time();
@@ -351,6 +387,23 @@ async fn main() -> Result<()> {
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened());
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
// nodes (older ISOs shipped no audio stack; the router also heals the
// boot-time ELD race that leaves HDMI silently unavailable).
tokio::spawn(bootstrap::ensure_audio_stack());
// TV input: gamepad→keyboard bridge so controllers work inside every app
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
tokio::spawn(bootstrap::ensure_gamepad_keys());
// Mesh access: mirror IPv4-published app ports onto [::] so direct-port
// app URLs (http://[<fips0 ULA>]:<port>) work from the companion.
tokio::spawn(mesh_ports::run_mesh_port_mirror());
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
// DHCP renumbering strands them — HA never re-resolves on its own.
tokio::spawn(api::rpc::wyoming_satellite_keeper());
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
+975
View File
@@ -0,0 +1,975 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
//! three firmware families the mesh module already knows how to detect (see
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
//! flash time (never bundled/pinned in the repo), and every flash defaults
//! to a full chip erase before write.
//!
//! MeshCore and Meshtastic are flashed the same way: download a released
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
//! this family, so we deliberately don't resolve a firmware URL ourselves
//! for Reticulum; rnodeconf already knows how).
use super::serial::DetectedDeviceInfo;
use super::types::DeviceType;
use super::MeshService;
use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, OnceLock};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::{info, warn};
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
/// esptool target covers both), but ship different USB identities and
/// different per-board firmware assets upstream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashBoard {
HeltecV3,
HeltecV4,
}
impl FlashBoard {
/// Meshtastic's board id (matches the release manifest's `board` field
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
fn meshtastic_id(self) -> &'static str {
match self {
Self::HeltecV3 => "heltec-v3",
Self::HeltecV4 => "heltec-v4",
}
}
}
/// Map a detected USB vid:pid to a known flashable board, using the same
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
/// auto-match since that vid:pid is bridge-chip-specific.
///
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
/// with product string "USB JTAG/serial debug unit" — that descriptor is
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
/// board's image. Callers (the RPC layer / frontend) must let the user pick
/// the board manually whenever this returns `None`.
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
match (info.vid.as_deref(), info.pid.as_deref()) {
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashStage {
Downloading,
Erasing,
Writing,
Autoinstalling,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct FlashJobStatus {
pub board: FlashBoard,
pub family: DeviceType,
pub path: String,
pub stage: FlashStage,
pub percent: Option<u8>,
pub log_tail: Vec<String>,
pub done: bool,
pub error: Option<String>,
}
const LOG_TAIL_MAX: usize = 200;
/// How long to wait after a successful flash before resuming the mesh
/// listener, so the board finishes its own post-flash boot/reset before we
/// start opening the port (which itself toggles DTR/RTS) again.
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
/// Absolute ceiling on a whole flash job (download + erase + write, or
/// autoinstall), regardless of what it's doing internally. Last-resort
/// safety net so a hang anywhere can't wedge the single-flash-job guard
/// forever — generous enough to never trigger on a legitimately slow
/// multi-hundred-MB transfer.
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
/// How long to wait for MeshService::stop() to release the serial port
/// before giving up. Confirmed live 2026-07-23: the listener's own
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
/// between candidates, so stop() can take a while (or, if the loop is
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
/// cycle without leaving a flash request hanging indefinitely if the
/// listener genuinely won't let go.
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// How long to keep retrying the port-free check before giving up.
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Confirm nothing else has `path` open by actually opening (and immediately
/// closing) it ourselves. Retries across the timeout since a just-stopped
/// listener's fd can take a moment to actually release even after `stop()`
/// returns (task abort is a request, not an instant guarantee the OS-level
/// resource is gone yet).
async fn wait_for_port_free(path: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
let mut last_err = None;
loop {
match serial2_tokio::SerialPort::open(path, 115200) {
Ok(_) => return Ok(()),
Err(e) => last_err = Some(e),
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
last_err.map(|e| e.to_string()).unwrap_or_default()
))
}
/// Live state for the one flash job that can run at a time. A single global
/// slot is sufficient because flashing needs exclusive serial access to the
/// one port being flashed — there is no meaningful concept of two concurrent
/// flash jobs on this node.
pub struct FlashJob {
status: RwLock<FlashJobStatus>,
/// Set once the background task is spawned. Only used while `stage` is
/// still `Downloading` — an interrupted erase/write can leave the chip
/// in a worse state than either finished or unstarted, so cancellation
/// is refused once erase begins (see `cancel()`).
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
}
impl FlashJob {
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
Arc::new(Self {
abort_handle: RwLock::new(None),
status: RwLock::new(FlashJobStatus {
board,
family,
path,
stage: FlashStage::Downloading,
percent: None,
log_tail: Vec::new(),
done: false,
error: None,
}),
})
}
pub async fn snapshot(&self) -> FlashJobStatus {
self.status.read().await.clone()
}
async fn set_stage(&self, stage: FlashStage) {
let mut s = self.status.write().await;
s.stage = stage;
s.percent = None;
}
async fn set_percent(&self, percent: u8) {
self.status.write().await.percent = Some(percent.min(100));
}
async fn push_log(&self, line: impl Into<String>) {
let mut s = self.status.write().await;
s.log_tail.push(line.into());
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
if overflow > 0 {
s.log_tail.drain(0..overflow);
}
}
async fn fail(&self, err: &anyhow::Error) {
let mut s = self.status.write().await;
s.stage = FlashStage::Failed;
s.error = Some(format!("{err:#}"));
s.done = true;
}
async fn finish(&self) {
let mut s = self.status.write().await;
s.stage = FlashStage::Done;
s.done = true;
}
/// Best-effort cancel: only honored before erase/write/autoinstall has
/// started (i.e. still in `Downloading`). Once a stage that touches the
/// chip begins, this refuses — interrupting an erase or write can leave
/// the flash in a state worse than either finished or unstarted.
pub async fn cancel(&self) -> Result<()> {
let mut s = self.status.write().await;
if s.done {
anyhow::bail!("Flash job already finished");
}
if s.stage != FlashStage::Downloading {
anyhow::bail!(
"Cannot cancel once {:?} has started — let it finish or fail on its own",
s.stage
);
}
if let Some(handle) = self.abort_handle.write().await.take() {
handle.abort();
}
s.stage = FlashStage::Failed;
s.error = Some("Cancelled by user".to_string());
s.done = true;
Ok(())
}
}
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
pub fn new_job_handle() -> FlashJobHandle {
Arc::new(RwLock::new(None))
}
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
data_dir.join("mesh").join("firmware-cache")
}
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
/// the *entire* request including streaming the response body, which would
/// kill a legitimate large download partway through (Meshtastic's esp32s3
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
/// applies a per-chunk stall timeout, and metadata calls (small JSON
/// responses) get their own short timeout at the call site.
fn github_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("archipelago-mesh-flash")
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to build HTTP client")
}
/// Applied per-chunk while streaming a firmware download — if the transfer
/// stalls (no bytes for this long) it's treated as a failure, but a slow
/// download that's still making progress is never killed just for taking a
/// while.
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Applied to metadata calls (GitHub release JSON) — these are small
/// responses with no reason to ever take this long.
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// Resolve what firmware is available for a board+family. v1 only ever
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
/// resolves on its own" (it does its own version checking upstream).
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
match family {
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
DeviceType::Meshtastic => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Meshcore => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
}
}
#[derive(serde::Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
/// Start a flash job in the background. Returns as soon as the job has been
/// registered and the listener released — callers poll `FlashJobHandle` via
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
pub async fn start_flash_job(
handle: &FlashJobHandle,
mesh_service: &Arc<RwLock<Option<MeshService>>>,
data_dir: PathBuf,
path: String,
board: FlashBoard,
family: DeviceType,
) -> Result<()> {
{
let existing = handle.read().await;
if let Some(job) = existing.as_ref() {
if !job.snapshot().await.done {
anyhow::bail!("A firmware flash is already in progress on this node");
}
}
}
let job = FlashJob::new(board, family, path.clone());
*handle.write().await = Some(Arc::clone(&job));
let bg_job = Arc::clone(&job);
let bg_service = Arc::clone(mesh_service);
let task = tokio::spawn(async move {
// esptool/archy-rnodeconf need exclusive serial access — release
// the listener's hold on the port before touching it. This USED
// TO run synchronously in start_flash_job before the job was even
// spawned, blocking the RPC call itself on s.stop().await — a real
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
// reconnect/probe sequence that doesn't check its shutdown signal
// between candidates, so stop() never returned. The HTTP request
// timed out client-side ("Operation failed"), while the job
// (already inserted into `handle`) was permanently wedged — nothing
// had been spawned yet to ever mark it done, so every later flash
// attempt failed with "already in progress" until a full restart.
// Now this runs inside the spawned task with its own bounded
// timeout, so the RPC call always returns immediately regardless,
// and a slow-to-stop listener fails the job cleanly instead of
// hanging everything downstream of it forever.
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
s.stop().await;
}
})
.await;
if stop_result.is_err() {
let err = anyhow::anyhow!(
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
STOP_LISTENER_TIMEOUT.as_secs()
);
bg_job.push_log(format!("ERROR: {err:#}")).await;
bg_job.fail(&err).await;
return;
}
// Belt-and-suspenders port-free check. `stop()` above should have
// fully released the port, but esptool/rnodeconf run as external
// subprocesses for minutes outside our own async runtime — if
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
// fd from an aborted task, anything we haven't anticipated), handing
// the port to the flasher anyway risks exactly the corruption
// confirmed live 2026-07-23: esptool's "device disconnected or
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
// and definitive — before ever starting the flasher.
if let Err(e) = wait_for_port_free(&path).await {
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(&e).await;
return;
}
// Outer ceiling on top of run_flash's own internal timeouts —
// belt-and-suspenders so that no future hang (network, subprocess,
// anything) can ever wedge the single-flash-job guard permanently
// again the way a stuck download did on 2026-07-23 (every
// subsequent mesh.flash-device call failed with "already in
// progress" until the service was restarted). Generous enough that
// a legitimately slow multi-hundred-MB transfer still completes.
let result = match tokio::time::timeout(
MAX_JOB_DURATION,
run_flash(board, family, &data_dir, &path, &bg_job),
)
.await
{
Ok(inner) => inner,
Err(_) => Err(anyhow::anyhow!(
"Flash job exceeded the {}-minute ceiling — aborted",
MAX_JOB_DURATION.as_secs() / 60
)),
};
let succeeded = result.is_ok();
match &result {
Ok(()) => {
bg_job.push_log("Flash completed successfully".to_string()).await;
bg_job.finish().await;
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
}
Err(e) => {
// {:#} (alternate Display) walks the full anyhow context
// chain — plain {} / %e only prints the outermost .context()
// message, which made a real 2026-07-23 esptool failure
// undiagnosable from journalctl alone (just "esptool
// erase_flash failed", no actual esptool stderr).
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(e).await;
}
}
// The board's firmware may now differ from whatever was pinned
// before — clear the pin either way so a later reconnect's strict
// auto-detect order picks up reality instead of getting wedged
// trying the old protocol first.
if let Ok(mut config) = super::load_config(&data_dir).await {
config.device_kind = None;
if let Err(e) = super::save_config(&data_dir, &config).await {
warn!(error = %e, "Failed to clear device_kind pin after flash");
}
}
if !succeeded {
// Deliberately do NOT auto-restart the listener here. A failed
// flash means we can't vouch for the board's state — reopening
// the port immediately (esptool/rnodeconf's own reset sequence
// plus our open() toggling DTR/RTS again right after) risks
// hammering a marginal device with reconnect attempts. Confirmed
// live 2026-07-23: exactly this sequence left a real Heltec V3
// boot-looping for 5+ minutes after a failed flash. Leave mesh
// stopped; the user reconnects explicitly via the hot-swap
// modal/Mesh page once they've confirmed the board is alive.
warn!(
path = %path,
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
);
return;
}
// On success, give the board a moment to finish booting after the
// flash tool's own reset sequence before we start hammering it with
// connection attempts — same reasoning as above, just the
// lower-risk (successful-flash) side of it.
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
match super::load_config(&data_dir).await {
Ok(config) => {
// Only resume if mesh is actually still enabled per the
// CURRENT persisted config — confirmed live 2026-07-23:
// unconditionally forcing a restart here, regardless of
// `enabled`, overrode a user's own concurrent "disable
// mesh" toggle and left the listener running while
// config said disabled. That inconsistent state is what
// made a later legitimate "Keep As Is" click (which
// correctly tries to start on a false→true transition)
// fail with "already running" — the listener had already
// been force-started behind the config's back.
let should_run = config.enabled;
if let Err(e) = s.configure(config).await {
warn!(error = %e, "Failed to resume mesh listener after flash");
}
if should_run {
if let Err(e) = s.start() {
warn!(error = %e, "Failed to restart mesh listener after flash");
}
}
}
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
}
}
});
*job.abort_handle.write().await = Some(task.abort_handle());
Ok(())
}
async fn run_flash(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
path: &str,
job: &Arc<FlashJob>,
) -> Result<()> {
match family {
DeviceType::Meshtastic | DeviceType::Meshcore => {
let image = fetch_esptool_image(board, family, data_dir, job).await?;
esptool_erase_and_write(path, &image, job).await
}
DeviceType::Reticulum => {
let lora_region = super::load_config(data_dir)
.await
.ok()
.and_then(|c| c.lora_region);
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
}
}
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
async fn fetch_esptool_image(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let cache = firmware_cache_dir(data_dir);
tokio::fs::create_dir_all(&cache)
.await
.context("Creating firmware cache dir")?;
let client = github_client()?;
match family {
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
_ => anyhow::bail!("{family} is not flashed via esptool"),
}
}
async fn fetch_meshtastic_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.timeout(METADATA_TIMEOUT)
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
// zip rather than shipping per-board top-level assets — both Heltec V3
// and V4 are esp32s3, so this is the right zip for both (confirmed live
// against v2.7.26.54e0d8d).
let zip_asset = release
.assets
.iter()
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
let version = zip_asset
.name
.strip_prefix("firmware-esp32s3-")
.and_then(|s| s.strip_suffix(".zip"))
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
.to_string();
let zip_path = cache.join(&zip_asset.name);
if tokio::fs::metadata(&zip_path).await.is_err() {
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
} else {
job.push_log(format!("Using cached {}", zip_asset.name)).await;
}
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
// partition table + app) meant to be written at offset 0x0 on a freshly
// erased chip — confirmed by inspecting the real zip's contents, as
// opposed to the plain "*.bin" OTA-update image which assumes an
// existing bootloader/partition table already on the chip.
let entry_name = format!(
"firmware-{}-{}.factory.bin",
board.meshtastic_id(),
version
);
let out_path = cache.join(&entry_name);
if tokio::fs::metadata(&out_path).await.is_ok() {
return Ok(out_path);
}
job.push_log(format!(
"Extracting {entry_name} from {}",
zip_asset.name
))
.await;
let zip_path_owned = zip_path.clone();
let entry_name_owned = entry_name.clone();
let out_path_owned = out_path.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
let mut entry = archive
.by_name(&entry_name_owned)
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
let mut out =
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
Ok(())
})
.await
.context("Firmware extraction task panicked")??;
Ok(out_path)
}
async fn fetch_meshcore_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.timeout(METADATA_TIMEOUT)
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
// Upstream's casing differs between boards (Heltec_v3_... vs
// heltec_v4_...) — match case-insensitively on the exact per-board
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
// variants (there's a "_tft_" in between, so a straight substring match
// on "heltec_v4_companion_radio_usb" is already safe).
let needle = match board {
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
};
let asset = release
.assets
.iter()
.find(|a| {
let lower = a.name.to_lowercase();
lower.contains(needle) && lower.ends_with("-merged.bin")
})
.ok_or_else(|| {
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
})?;
let out_path = cache.join(&asset.name);
if tokio::fs::metadata(&out_path).await.is_ok() {
job.push_log(format!("Using cached {}", asset.name)).await;
return Ok(out_path);
}
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
Ok(out_path)
}
async fn download_to_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Downloading).await;
// Bound only the wait for the response to *start* (headers) — NOT a
// request-level `.timeout()`, which would cap the whole body transfer
// again (the bug this replaced: a blanket 30s client timeout killed
// large downloads mid-stream). If the server never responds at all,
// this is what stops the job from hanging forever; the per-chunk stall
// timeout below is what guards the body once streaming starts. Without
// this, a server that accepts the TCP connection but never sends
// headers back hangs this call indefinitely — confirmed live
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
// for good, permanently blocking every subsequent flash attempt with
// "already in progress" until the service was restarted.
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
.await
.context("Firmware download server did not respond")?
.context("Starting firmware download")?
.error_for_status()
.context("Firmware download returned an error status")?;
let total = resp.content_length();
let tmp = dest.with_extension("part");
let mut file = tokio::fs::File::create(&tmp)
.await
.context("Creating firmware download file")?;
let mut stream = resp.bytes_stream();
let mut downloaded: u64 = 0;
use futures_util::StreamExt;
loop {
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
.await
.context("Firmware download stalled")?;
let Some(chunk) = next else { break };
let chunk = chunk.context("Reading firmware download stream")?;
file.write_all(&chunk)
.await
.context("Writing firmware download")?;
downloaded += chunk.len() as u64;
if let Some(total) = total {
if total > 0 {
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
.await;
}
}
}
file.flush().await.ok();
tokio::fs::rename(&tmp, dest)
.await
.context("Finalizing firmware download")?;
job.push_log(format!(
"Downloaded {} ({downloaded} bytes)",
dest.display()
))
.await;
Ok(())
}
/// Both Heltec V3 and V4 are ESP32-S3 boards.
const ESPTOOL_CHIP: &str = "esp32s3";
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
/// board+adapter combinations — esptool's own docs recommend retrying at a
/// lower baud rate when this happens. Rather than fail the whole job on the
/// first hiccup, retry once at a conservative baud before giving up.
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
/// `write_flash --erase-all` erases the whole chip before writing, in one
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
/// esptool_global_args' doc comment) — without it, --erase-all hits the
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
/// does not support function erase_flash", confirmed live 2026-07-23), since
/// esptool's --erase-all is implemented as the same full-chip-erase command,
/// not a per-sector loop.
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
job.set_stage(FlashStage::Writing).await;
let image_str = image.to_string_lossy().to_string();
esptool_with_retry(
path,
&["write_flash", "--erase-all", "0x0", &image_str],
job,
)
.await
.context("esptool write_flash failed")?;
Ok(())
}
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
/// appending `--baud 115200` after the subcommand on the retry path
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
/// time, so the fallback-baud retry never actually got a chance to run.
/// Building global args separately from subcommand args keeps this correct
/// by construction instead of relying on call-site ordering.
///
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
/// fetches the exact same file from the matching upstream esptool release
/// tag and installs it alongside the apt package (see the esptool install
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
/// stub) was tried first and works for connecting, but the ROM bootloader
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
/// so --no-stub broke our "always erase before write" default outright
/// rather than just being slower. Restoring the real stub file is the
/// correct fix, not routing around its absence.
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
if let Some(b) = baud {
args.push("--baud");
args.push(b);
}
args
}
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
let mut cmd = Command::new("esptool");
cmd.args(esptool_global_args(path, None));
cmd.args(subcommand);
match run_streamed(cmd, None, job).await {
Ok(()) => Ok(()),
Err(first_err) => {
job.push_log(format!(
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
))
.await;
let mut retry = Command::new("esptool");
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
retry.args(subcommand);
run_streamed(retry, None, job)
.await
.context(format!("retry also failed (first attempt: {first_err:#})"))
}
}
}
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
fn rnodeconf_bin() -> String {
std::env::var("ARCHY_RNODECONF_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
}
/// `--autoinstall`'s "which board is this" step is interactive by design —
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
/// (their bootstrap-time USB identity is often generic, same root cause as
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
/// sequence observed for a Heltec board that already has *some* RNode
/// firmware installed (the common case — a truly blank chip likely skips
/// straight to the same "Device Selection" menu):
/// 1. numbered device-type menu → answer with the menu number
/// 2. "Hit enter to continue" → answer with a blank line
/// 3. numbered band menu → answer with the menu number
/// 4. "Is the above correct? [y/N]" → answer "y"
/// Feeding all four answers up front (rather than watching stdout for each
/// prompt text) works because the menu is always asked in this fixed order
/// for every board that needs (re)provisioning — verified by driving it
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
/// bootstrap → "Device signature validated" on the next probe).
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
match board {
FlashBoard::HeltecV3 => "8",
FlashBoard::HeltecV4 => "9",
}
}
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
/// (868/915/923 MHz), not the final operating frequency — that's still
/// configured later via the daemon's interface config, same as today. This
/// is a best-effort mapping from the node's persisted Meshtastic-style
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
/// 915 MHz as the broadest-compatibility default.
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
match lora_region.map(|s| s.trim().to_uppercase()) {
Some(r) if r.contains("868") => "1",
Some(r) if r.contains("923") => "3",
_ => "2",
}
}
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
/// --help` AND a real end-to-end flash on real hardware) — this is the
/// RNode-side equivalent of our "always erase before write" default, since
/// autoinstall doesn't try to preserve any existing on-device state.
async fn rnodeconf_autoinstall(
path: &str,
board: FlashBoard,
lora_region: Option<&str>,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Autoinstalling).await;
let bin = rnodeconf_bin();
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command::new("rnodeconf")
};
cmd.args(["--autoinstall", path]);
let stdin = format!(
"{}\n\n{}\ny\n",
rnodeconf_device_menu_number(board),
rnodeconf_band_menu_number(lora_region)
);
run_streamed(cmd, Some(stdin.into_bytes()), job)
.await
.context("archy-rnodeconf --autoinstall failed")
}
// ─── Subprocess streaming ────────────────────────────────────────────────
fn percent_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
}
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if stdin.is_some() {
cmd.stdin(Stdio::piped());
}
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
// the chip in a worse state than either finished or unstarted (see the
// cancellation-safety note in mesh flashing docs). The job is expected
// to run to completion or fail on its own.
let mut child = cmd.spawn().context("Failed to start subprocess")?;
if let Some(bytes) = stdin {
if let Some(mut child_stdin) = child.stdin.take() {
child_stdin
.write_all(&bytes)
.await
.context("Writing to subprocess stdin")?;
}
}
let mut tasks = Vec::new();
if let Some(stdout) = child.stdout.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(cap) = percent_regex().captures(&line) {
if let Ok(pct) = cap[1].parse::<u8>() {
job.set_percent(pct).await;
}
}
job.push_log(line).await;
}
}));
}
if let Some(stderr) = child.stderr.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
job.push_log(line).await;
}
}));
}
let status = child.wait().await.context("Waiting for subprocess")?;
for t in tasks {
let _ = t.await;
}
if !status.success() {
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
// stderr (already captured into job.log_tail by the reader tasks
// above) is what actually explains a failure. Confirmed live
// 2026-07-23: a bare "Command exited with exit status: 1" told us
// nothing when esptool's real error was sitting in the log tail the
// whole time, only visible via the UI's live poll, not journald.
let tail: Vec<String> = job
.snapshot()
.await
.log_tail
.iter()
.rev()
.take(10)
.rev()
.cloned()
.collect();
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
}
Ok(())
}
+176 -7
View File
@@ -15,13 +15,15 @@ mod frames;
mod node_cmd;
mod session;
pub(crate) use session::{probe_device, DeviceProbe};
use super::types::*;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, RwLock};
use tracing::{error, info};
use tracing::{error, info, warn};
/// How often to broadcast our identity advertisement (seconds).
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
@@ -52,6 +54,22 @@ const RX_STALL_TIMEOUT: Duration = Duration::from_secs(1800);
/// Maximum stored messages (circular buffer).
const MAX_MESSAGES: usize = 100;
/// On-disk message-history file under `data_dir/` (written by
/// `spawn_message_persister`, restored by `load_persisted_messages`).
const MESSAGES_FILE: &str = "mesh-messages.json";
/// Serialized form of the persisted history. `send_seqs` rides along because
/// the per-target outbound sequence counters must survive restarts too:
/// receivers dedup on (sender_pubkey, sender_seq), so a node that reboots and
/// starts counting from 1 again has its first messages silently dropped by
/// every peer that already saw those sequence numbers.
#[derive(serde::Serialize, serde::Deserialize)]
struct PersistedMessages {
messages: Vec<MeshMessage>,
#[serde(default)]
send_seqs: HashMap<u32, u64>,
}
/// Check if two ISO8601 timestamps are within N seconds of each other.
fn within_seconds_iso(ts1: &str, ts2: &str, secs: i64) -> bool {
use chrono::DateTime;
@@ -69,6 +87,18 @@ const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
/// Maximum reconnect delay (cap for exponential backoff).
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
/// Minimum time a session must run before we trust it enough to reset
/// backoff to the minimum. Without this gate, a device that connects then
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
/// auto-reset-circuit boards alike), turning a device that's merely
/// unstable into a self-sustaining boot loop that outlasts whatever
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
/// flash left it in a marginal state.
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
/// Number of consecutive write failures before we consider the device dead
/// and trigger a reconnection cycle.
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
@@ -402,6 +432,90 @@ impl MeshState {
let count = self.peers.read().await.len();
self.status.write().await.peer_count = count;
}
/// Restore the persisted message history + outbound sequence counters.
/// Called once at service startup, before the listener spawns. Missing
/// file (fresh node) is normal; a corrupt file is logged and skipped
/// rather than blocking mesh startup.
pub async fn load_persisted_messages(&self) {
let path = self.data_dir.join(MESSAGES_FILE);
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
warn!("mesh: reading {} failed: {e}", path.display());
return;
}
};
let persisted: PersistedMessages = match serde_json::from_slice(&bytes) {
Ok(p) => p,
Err(e) => {
warn!("mesh: parsing {} failed (skipping restore): {e}", path.display());
return;
}
};
let count = persisted.messages.len();
let max_id = persisted.messages.iter().map(|m| m.id).max().unwrap_or(0);
*self.messages.write().await = persisted.messages.into();
if !persisted.send_seqs.is_empty() {
*self.next_send_seq.write().await = persisted.send_seqs;
}
{
let mut id = self.next_message_id.write().await;
if *id <= max_id {
*id = max_id + 1;
}
}
info!("mesh: restored {count} persisted messages (next id {})", max_id + 1);
}
}
/// Persist the message history on a low-rate timer instead of hooking every
/// mutation path (store, delivered/transport/encrypted stamps, edits, deletes,
/// read-receipt prunes — and whatever gets added next). Serializing ≤100
/// messages every few seconds is trivial; the write is skipped when nothing
/// changed, and at most the last few seconds of history are lost on a hard
/// kill — versus the entire history, which is what a restart cost before this
/// existed. Atomic write-then-rename, 0600 (DM plaintext lives in this file).
pub fn spawn_message_persister(state: Arc<MeshState>) {
tokio::spawn(async move {
use std::os::unix::fs::PermissionsExt;
let path = state.data_dir.join(MESSAGES_FILE);
let tmp = path.with_extension("json.tmp");
let mut last_written: Option<Vec<u8>> = None;
let mut tick = tokio::time::interval(Duration::from_secs(5));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
let snapshot = PersistedMessages {
messages: state.messages.read().await.iter().cloned().collect(),
send_seqs: state.next_send_seq.read().await.clone(),
};
let json = match serde_json::to_vec_pretty(&snapshot) {
Ok(j) => j,
Err(e) => {
warn!("mesh: serializing message history failed: {e}");
continue;
}
};
if last_written.as_deref() == Some(json.as_slice()) {
continue;
}
if let Err(e) = tokio::fs::write(&tmp, &json).await {
warn!("mesh: writing {} failed: {e}", tmp.display());
continue;
}
let perms = std::fs::Permissions::from_mode(0o600);
if let Err(e) = tokio::fs::set_permissions(&tmp, perms).await {
warn!("mesh: chmod {} failed: {e}", tmp.display());
}
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
warn!("mesh: renaming {} -> {} failed: {e}", tmp.display(), path.display());
continue;
}
last_written = Some(json);
}
});
}
/// Spawn the background mesh listener task.
@@ -421,9 +535,11 @@ pub fn spawn_mesh_listener(
our_x25519_pubkey_hex: String,
server_name: Option<String>,
lora_region: Option<String>,
lora_radio_params: Option<super::LoraRadioParams>,
channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>,
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
manage_radio: bool,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
@@ -431,6 +547,10 @@ pub fn spawn_mesh_listener(
let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx;
let mut reconnect_delay = RECONNECT_DELAY_INIT;
// Mutable so a successful auto-detect can pin the firmware kind for
// the rest of this listener's lifetime — see the pin-on-first-success
// block below for why.
let mut device_kind = device_kind;
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
// builds a fresh device struct (contacts/current_region/etc. all
// start empty), so per-device session state is naturally isolated
@@ -446,6 +566,7 @@ pub fn spawn_mesh_listener(
return;
}
let session_start = std::time::Instant::now();
match session::run_mesh_session(
&state,
&data_dir,
@@ -456,9 +577,11 @@ pub fn spawn_mesh_listener(
&our_x25519_pubkey_hex,
server_name.as_deref(),
lora_region.as_deref(),
lora_radio_params,
channel_name.as_deref(),
device_kind,
reticulum_tcp.clone(),
manage_radio,
&mut shutdown,
&mut cmd_rx,
)
@@ -466,13 +589,14 @@ pub fn spawn_mesh_listener(
{
Ok(()) => {
info!("Mesh session ended cleanly");
// Session was established before ending — reset backoff
reconnect_delay = RECONNECT_DELAY_INIT;
// Only trust a session that actually ran for a while —
// see STABLE_SESSION_THRESHOLD's doc comment.
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
reconnect_delay = RECONNECT_DELAY_INIT;
}
}
Err(e) => {
// Check if session was ever connected (vs failed to open)
let was_connected = state.status.read().await.device_connected;
if was_connected {
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
reconnect_delay = RECONNECT_DELAY_INIT;
}
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
@@ -498,11 +622,56 @@ pub fn spawn_mesh_listener(
}
}
// Update status to disconnected
// Pin the firmware kind after the first successful auto-detect.
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
// after clearing a stale pin), EVERY reconnect re-runs the full
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
// candidate past the first does its own open() with the DTR/RTS
// reset both boards need, so a device correctly identified as
// Meshtastic still gets reset once for the failed Meshcore
// attempt before Meshtastic's own open() resets it again. That
// doubled the reset count on every single reconnect indefinitely,
// not just during initial detection. Once auto-detect has
// identified the device this listener is actually talking to,
// there's no reason to keep guessing on subsequent reconnects —
// pin it, both in this task's own loop (takes effect
// immediately) and on disk (survives a service restart). A
// genuine hot-swap to different firmware is still handled: the
// setup modal's `mesh.probe-device` always re-probes unpinned,
// and the flash flow already clears this pin on its own.
if device_kind.is_none() {
let detected = state.status.read().await.device_type;
if detected != super::types::DeviceType::Unknown {
device_kind = Some(detected);
match super::load_config(&data_dir).await {
Ok(mut cfg) if cfg.device_kind.is_none() => {
cfg.device_kind = Some(detected);
if let Err(e) = super::save_config(&data_dir, &cfg).await {
warn!("Failed to persist auto-detected device_kind: {}", e);
} else {
info!(
kind = %detected,
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
);
}
}
Ok(_) => {}
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
}
}
}
// Update status to disconnected. device_type/firmware_version are
// reset too — they were previously left holding the LAST radio's
// identity, so after a hot-swap the UI showed the old firmware
// ("meshcore, disconnected") while a different stick sat in the
// port. Unknown-until-next-successful-connect is the honest state.
{
let mut status = state.status.write().await;
status.device_connected = false;
status.device_path = None;
status.device_type = super::types::DeviceType::Unknown;
status.firmware_version = None;
}
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
+206 -55
View File
@@ -274,6 +274,7 @@ async fn auto_detect_and_open(
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
}
info!(candidates = ?paths, "Auto-detect candidate ports for this attempt");
for path in &paths {
debug!(path = %path, "Probing for mesh radio device");
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
@@ -340,6 +341,102 @@ async fn auto_detect_and_open(
)
}
/// What the hot-swap probe learned about a just-plugged radio, without
/// provisioning anything. Serialized straight into the `mesh.probe-device`
/// RPC response for the device setup modal.
#[derive(Debug, Clone, serde::Serialize)]
pub struct DeviceProbe {
pub path: String,
/// "reticulum" | "meshcore" | "meshtastic"
pub kind: String,
pub firmware_version: Option<String>,
pub node_id: Option<u32>,
pub advert_name: Option<String>,
/// Meshtastic-only: current radio config, read via `want_config`.
pub region: Option<String>,
pub modem_preset: Option<String>,
pub primary_channel: Option<String>,
pub secondary_channel: Option<String>,
pub max_contacts: Option<u16>,
}
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
/// for the same RNode-wedging reason) and read what's currently configured on
/// it. The port is released when this returns, so the listener's reconnect
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
/// DETECT probe — no daemon spawn just to identify a stick.
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
// The listener's reconnect loop may hold this port for ~10s of every
// backoff cycle, and Linux happily double-opens a tty — two concurrent
// handshakes corrupt each other into silence (observed on framework-pt:
// every firmware "failed" while the listener was mid-cycle). Retry across
// the listener's idle gaps instead of failing on first collision.
let mut last_err = None;
for attempt in 0..3u32 {
if attempt > 0 {
tokio::time::sleep(Duration::from_secs(7)).await;
}
match probe_device_once(path).await {
Ok(p) => return Ok(p),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("probe failed")))
}
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
if super::super::reticulum::probe_rnode(path).await.is_ok() {
return Ok(DeviceProbe {
path: path.to_string(),
kind: "reticulum".to_string(),
firmware_version: Some("RNode (KISS)".to_string()),
node_id: None,
advert_name: None,
region: None,
modem_preset: None,
primary_channel: None,
secondary_channel: None,
max_contacts: None,
});
}
if let Ok(mut dev) = MeshcoreDevice::open(path).await {
if let Ok(info) = dev.initialize().await {
let queried = dev.query_device_info().await;
return Ok(DeviceProbe {
path: path.to_string(),
kind: "meshcore".to_string(),
firmware_version: queried.as_ref().map(|(v, _)| v.clone()),
node_id: Some(info.node_id),
advert_name: dev.advert_name(),
region: None,
modem_preset: None,
primary_channel: None,
secondary_channel: None,
max_contacts: queried.map(|(_, m)| m).or(Some(info.max_contacts)),
});
}
}
if let Ok(mut dev) = MeshtasticDevice::open(path).await {
if let Ok(info) = dev.initialize().await {
let cfg = dev.probed_config();
return Ok(DeviceProbe {
path: path.to_string(),
kind: "meshtastic".to_string(),
firmware_version: Some(info.firmware_version.clone()),
node_id: Some(info.node_id),
advert_name: dev.advert_name(),
region: cfg.region,
modem_preset: cfg.modem_preset,
primary_channel: cfg.primary_channel,
secondary_channel: cfg.secondary_channel,
max_contacts: Some(info.max_contacts),
});
}
}
anyhow::bail!("no supported mesh radio firmware answered on {path}")
}
async fn open_preferred_path(
path: &str,
data_dir: &Path,
@@ -391,40 +488,19 @@ async fn open_preferred_path(
};
}
// Reticulum first — see the matching comment on auto_detect_and_open:
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while
// trying Meshcore/Meshtastic first was observed leaving a real RNode
// board unresponsive by the time Reticulum's turn came.
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => {
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
}
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
}
// Unpinned: don't probe this path ourselves at all. Confirmed live
// 2026-07-23 — this function used to run its own Reticulum→Meshcore→
// Meshtastic sequence here, and the caller (run_mesh_session) falls
// back to `auto_detect_and_open` on any error, which scans every
// candidate path (this one included) with the exact same three-protocol
// sequence. With a single physical radio — the overwhelmingly common
// case — `path` here IS the one candidate `auto_detect_and_open` is
// about to try, so every unpinned reconnect was resetting the board via
// Reticulum/Meshcore/Meshtastic's DTR/RTS toggle TWICE: once here, once
// again moments later in auto-detect. Bailing immediately (no port
// access at all) means auto-detect's single pass is the only one that
// ever touches the port when nothing is pinned yet.
anyhow::bail!("No device_kind pin — deferring to auto-detect for {path}")
}
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
@@ -836,6 +912,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// Same retry-cap idea as the region, for the Meshcore radio-params write.
static RADIO_PARAMS_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// Same retry-cap idea as the region, for the shared-channel write.
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
@@ -851,9 +931,11 @@ pub(super) async fn run_mesh_session(
our_x25519_pubkey_hex: &str,
server_name: Option<&str>,
lora_region: Option<&str>,
lora_radio_params: Option<crate::mesh::LoraRadioParams>,
channel_name: Option<&str>,
device_kind: Option<DeviceType>,
reticulum_tcp: Option<ReticulumTcpConfig>,
manage_radio: bool,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> {
@@ -918,8 +1000,15 @@ pub(super) async fn run_mesh_session(
// re-handshake the freshly-rebooted radio (and then set its name on the
// reconnect, where the region already matches and no reboot occurs).
use std::sync::atomic::Ordering;
if !manage_radio {
// Keep-as-is mode (hot-swap "keep as is" decision): the operator told
// us to use whatever is flashed/configured on this radio. Skip every
// config write below — region, channel, PHY params, advert name — and
// run with the device's own settings.
info!("manage_radio=false — leaving the radio's own configuration untouched");
}
let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
if region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
if manage_radio && region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
match device.ensure_lora_region(lora_region).await {
Ok(true) => {
REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
@@ -938,7 +1027,7 @@ pub(super) async fn run_mesh_session(
Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
Err(e) => warn!("Failed to provision LoRa region: {}", e),
}
} else if lora_region.is_some() {
} else if manage_radio && lora_region.is_some() {
warn!(
region = lora_region.unwrap_or(""),
attempts = MAX_REGION_PROVISION_ATTEMPTS,
@@ -953,7 +1042,7 @@ pub(super) async fn run_mesh_session(
// the radio). Without a matching channel two same-region radios still can't
// decode each other's traffic. Same retry-cap + restart-on-change pattern.
let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
if manage_radio && channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
match device.ensure_channel(channel_name).await {
Ok(true) => {
CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
@@ -969,7 +1058,7 @@ pub(super) async fn run_mesh_session(
Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
Err(e) => warn!("Failed to provision mesh channel: {}", e),
}
} else if channel_name.is_some() {
} else if manage_radio && channel_name.is_some() {
warn!(
channel = channel_name.unwrap_or(""),
attempts = MAX_REGION_PROVISION_ATTEMPTS,
@@ -978,24 +1067,86 @@ pub(super) async fn run_mesh_session(
);
}
// Provision Meshcore LoRa PHY params (freq/bw/sf/cr) when the operator has
// configured them. Meshcore-only: Meshtastic radios get region+preset via
// ensure_lora_region above, and Reticulum carries its own RNode profile.
// Gated on a persisted marker of the last-applied params rather than the
// device's SELF_INFO readback (its field offsets shift across firmware
// versions), so we send the set-command once per configured value and never
// reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so
// a successful write restarts the session like the region path.
if let (true, Some(params), MeshRadioDevice::Meshcore(dev)) =
(manage_radio, lora_radio_params, &mut device)
{
let marker_path = data_dir.join("meshcore-radio-params.json");
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
.await
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
if applied != Some(params) {
let attempts = RADIO_PARAMS_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
if attempts < MAX_REGION_PROVISION_ATTEMPTS {
match dev
.set_radio_params(params.freq_khz, params.bw_hz, params.sf, params.cr)
.await
{
Ok(()) => {
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
if let Ok(json) = serde_json::to_vec(&params) {
if let Err(e) = tokio::fs::write(&marker_path, json).await {
warn!("Failed to persist radio-params marker: {}", e);
}
}
info!(
freq_khz = params.freq_khz,
bw_hz = params.bw_hz,
sf = params.sf,
cr = params.cr,
"Provisioned Meshcore radio params — radio rebooting, \
restarting mesh session"
);
tokio::time::sleep(Duration::from_secs(10)).await;
return Ok(());
}
Err(e) => {
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
warn!("Failed to provision Meshcore radio params: {}", e);
}
}
} else {
warn!(
attempts = MAX_REGION_PROVISION_ATTEMPTS,
"Meshcore radio rejected the configured radio params after \
repeated attempts continuing with the device's own settings."
);
}
} else {
RADIO_PARAMS_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed);
}
}
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
// falling back to the DID fragment if no name is configured.
let advert_name = if let Some(name) = server_name {
// Meshcore firmware limits advert names — truncate to 20 chars
name.chars().take(20).collect::<String>()
} else {
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
format!("Archy-{}", short_did)
};
if let Err(e) = device.set_advert_name(&advert_name).await {
warn!("Failed to set advert name: {}", e);
} else {
// Reflect the post-set name in MeshStatus too so the UI can filter
// its own radio echo from the peer list. Without this, the status
// still carries whatever pre-set name the firmware reported and the
// self-filter never matches.
let mut status = state.status.write().await;
status.self_advert_name = Some(advert_name.clone());
// falling back to the DID fragment if no name is configured. Skipped in
// keep-as-is mode — the radio keeps the name it came with (already
// reflected in status from the connect handshake).
if manage_radio {
let advert_name = if let Some(name) = server_name {
// Meshcore firmware limits advert names — truncate to 20 chars
name.chars().take(20).collect::<String>()
} else {
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
format!("Archy-{}", short_did)
};
if let Err(e) = device.set_advert_name(&advert_name).await {
warn!("Failed to set advert name: {}", e);
} else {
// Reflect the post-set name in MeshStatus too so the UI can filter
// its own radio echo from the peer list. Without this, the status
// still carries whatever pre-set name the firmware reported and the
// self-filter never matches.
let mut status = state.status.write().await;
status.self_advert_name = Some(advert_name.clone());
}
}
// Broadcast our advertisement so other nodes can discover us
+92 -3
View File
@@ -167,7 +167,40 @@ pub struct MeshtasticDevice {
pending_reinit: bool,
}
/// Read-only snapshot of the radio config learned during `initialize`'s
/// `want_config` stream — surfaced by the hot-swap probe so the setup modal
/// can show what's currently on a just-plugged radio.
#[derive(Debug, Clone)]
pub struct MeshtasticProbedConfig {
pub region: Option<String>,
pub modem_preset: Option<String>,
pub primary_channel: Option<String>,
pub secondary_channel: Option<String>,
}
impl MeshtasticDevice {
/// See [`MeshtasticProbedConfig`]. Everything here was learned via reads
/// (`want_config`) — safe for a probe that must not modify the device.
pub fn probed_config(&self) -> MeshtasticProbedConfig {
MeshtasticProbedConfig {
region: self
.current_region
.and_then(region_code_to_name)
.map(str::to_string),
modem_preset: self
.current_modem_preset
.and_then(modem_preset_name)
.map(str::to_string),
primary_channel: self
.current_primary_channel
.as_ref()
.map(|(name, _)| if name.is_empty() { "(default public)".to_string() } else { name.clone() }),
secondary_channel: self
.current_secondary_channel
.as_ref()
.map(|(name, _)| name.clone()),
}
}
pub async fn open(path: &str) -> Result<Self> {
match tokio::fs::metadata(path).await {
Ok(meta) => {
@@ -181,11 +214,20 @@ impl MeshtasticDevice {
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
// auto-reset) reset on a DTR/RTS transition, so deassert both and
// settle before the handshake below. 300ms is nowhere near a real
// firmware boot time (LoRa radio init alone can take longer) —
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
// Meshtastic's open() doing this same reset, a single auto-detect
// cycle trying multiple protocols in sequence kept re-resetting the
// board before it ever finished booting from the PREVIOUS attempt's
// reset, on both a Heltec V3 and V4, regardless of firmware family —
// a self-sustaining "never finishes booting" loop with a boot-time
// root cause hiding behind what looked like a per-protocol failure.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(Duration::from_millis(2000)).await;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self {
@@ -1321,6 +1363,53 @@ fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
/// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g.
/// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive.
/// Returns `None` for an unrecognised name so we never write a bogus region.
/// Inverse of `region_name_to_code` — for showing a probed radio's current
/// region to the user (hot-swap setup modal).
pub(crate) fn region_code_to_name(code: u32) -> Option<&'static str> {
Some(match code {
0 => "UNSET",
1 => "US",
2 => "EU_433",
3 => "EU_868",
4 => "CN",
5 => "JP",
6 => "ANZ",
7 => "KR",
8 => "TW",
9 => "RU",
10 => "IN",
11 => "NZ_865",
12 => "TH",
13 => "LORA_24",
14 => "UA_433",
15 => "UA_868",
16 => "MY_433",
17 => "MY_919",
18 => "SG_923",
19 => "PH_433",
20 => "PH_868",
21 => "PH_915",
22 => "ANZ_433",
_ => return None,
})
}
/// Meshtastic `Config.LoRaConfig.ModemPreset` enum names, for display.
pub(crate) fn modem_preset_name(code: u32) -> Option<&'static str> {
Some(match code {
0 => "LONG_FAST",
1 => "LONG_SLOW",
2 => "VERY_LONG_SLOW",
3 => "MEDIUM_SLOW",
4 => "MEDIUM_FAST",
5 => "SHORT_SLOW",
6 => "SHORT_FAST",
7 => "LONG_MODERATE",
8 => "SHORT_TURBO",
_ => return None,
})
}
pub(crate) fn region_name_to_code(name: &str) -> Option<u32> {
Some(match name.trim().to_uppercase().as_str() {
"UNSET" => 0,
+100 -4
View File
@@ -8,6 +8,7 @@
pub mod alerts;
pub mod bitcoin_relay;
pub mod crypto;
pub mod flash;
pub mod listener;
pub mod meshtastic;
pub mod message_types;
@@ -38,6 +39,14 @@ use tokio::sync::watch;
use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// How long `MeshService::stop()` waits for the listener task to notice its
/// shutdown signal and exit gracefully before force-aborting it. See
/// `stop()`'s doc comment for the real incident this guards against: without
/// a hard abort fallback, a slow-to-notice listener could be left running
/// forever, orphaned, racing a later independently-started listener on the
/// same serial port.
const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
@@ -322,6 +331,22 @@ pub(crate) async fn seed_federation_peers_into_mesh(
}
}
/// Operator-configured LoRa PHY parameters for a Meshcore radio, in the
/// firmware's own field units: `freq_khz` = MHz×1000 (869618 → 869.618 MHz),
/// `bw_hz` = kHz×1000 (62500 → 62.5 kHz), `sf` 5..=12, `cr` 5..=8. These are
/// region/deployment-specific (e.g. the Portugal preset 869618/62500/8/8) and
/// MUST match every radio on the local mesh — a mismatched radio hears RF
/// energy but demodulates nothing. None (the default) leaves the device's own
/// settings untouched, so nodes outside the configured deployment are never
/// affected.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoraRadioParams {
pub freq_khz: u32,
pub bw_hz: u32,
pub sf: u8,
pub cr: u8,
}
/// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
@@ -340,6 +365,11 @@ pub struct MeshConfig {
/// unset/None.
#[serde(default)]
pub lora_region: Option<String>,
/// Meshcore LoRa PHY parameters (freq/bw/sf/cr). Provisioned onto the
/// radio on connect when set; None leaves the device untouched. Ignored
/// for Meshtastic (region/preset covers it) and Reticulum.
#[serde(default)]
pub lora_radio_params: Option<LoraRadioParams>,
/// Whether to periodically broadcast our identity.
#[serde(default)]
pub broadcast_identity: bool,
@@ -399,6 +429,12 @@ pub struct MeshConfig {
/// serial-only/auto-detect behavior unchanged.
#[serde(default)]
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
/// Whether archipelago manages the radio's configuration (region, shared
/// channel, PHY params, advert name) on connect. `true` (default) is
/// today's behavior. `false` = the hot-swap modal's "keep as is": use the
/// radio exactly as flashed — no config writes at all.
#[serde(default = "default_true")]
pub manage_radio: bool,
}
/// True when `name` is a LoRa region the Meshtastic driver can program
@@ -422,6 +458,7 @@ impl Default for MeshConfig {
device_path: None,
channel_name: Some("archipelago".to_string()),
lora_region: None,
lora_radio_params: None,
broadcast_identity: true,
advert_name: None,
mesh_only_mode: None,
@@ -436,6 +473,7 @@ impl Default for MeshConfig {
assistant_allowed_contacts: Vec::new(),
device_kind: None,
reticulum_tcp: None,
manage_radio: true,
}
}
}
@@ -669,6 +707,13 @@ impl MeshService {
}
}
// Restore the message history + outbound send-sequence counters, then
// keep them persisted. Until 2026-07-22 both were RAM-only: every
// restart wiped the chat history, and the reset sequence counters made
// peers drop the first post-reboot messages as (pubkey, seq) replays.
state.load_persisted_messages().await;
listener::spawn_message_persister(Arc::clone(&state));
Ok(Self {
state,
config,
@@ -698,10 +743,18 @@ impl MeshService {
self.server_name = name;
}
/// Start the background mesh listener.
/// Start the background mesh listener. Idempotent: if the listener is
/// already running, this is a harmless no-op rather than an error —
/// confirmed live 2026-07-23, a real race between the flash job's own
/// post-flash restart and a concurrent user "Keep As Is" click (both
/// legitimately trying to ensure the listener is running) surfaced this
/// as a user-facing "Mesh listener already running" RPC error. Ensuring
/// the listener is running is the intent every caller actually has;
/// whichever caller's start() happens to win the race, the other
/// finding it already satisfied is success, not failure.
pub fn start(&mut self) -> Result<()> {
if self.listener_handle.is_some() {
anyhow::bail!("Mesh listener already running");
return Ok(());
}
let (shutdown_tx, shutdown_rx) = watch::channel(false);
@@ -722,9 +775,11 @@ impl MeshService {
self.our_x25519_pubkey_hex.clone(),
self.server_name.clone(),
self.config.lora_region.clone(),
self.config.lora_radio_params,
self.config.channel_name.clone(),
self.config.device_kind,
self.config.reticulum_tcp.clone(),
self.config.manage_radio,
shutdown_rx,
cmd_rx,
);
@@ -929,8 +984,36 @@ impl MeshService {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.listener_handle.take() {
let _ = handle.await;
if let Some(mut handle) = self.listener_handle.take() {
// Bounded wait for graceful shutdown, with a hard abort as
// fallback — confirmed live 2026-07-23: a caller-side timeout
// wrapping stop() (mesh::flash's STOP_LISTENER_TIMEOUT) cancelled
// this await when the listener was slow to notice its shutdown
// signal (mid multi-candidate probe), but `.take()` above had
// already cleared `listener_handle` to None — so MeshService
// believed it was stopped while the task kept running, orphaned
// (dropping a JoinHandle does not abort the task it points to).
// A later start() then spawned a second, fully independent
// listener session racing the orphaned one on the same serial
// port — neither could ever get a clean response, so every
// mesh.configure/probe against that device failed indefinitely
// even though the device itself was fine.
//
// Awaiting `&mut handle` (not `handle` by value) is what makes
// the fallback possible: the Future is polled through the
// reference, so if the timeout fires, this task's own `handle`
// binding is still ours to call `.abort()` on afterward —
// unlike moving `handle` into the timeout future outright, which
// would drop (and thus orphan) it on timeout with nothing left
// to abort.
if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut handle)
.await
.is_err()
{
warn!("Mesh listener did not shut down gracefully in time — aborting it");
handle.abort();
let _ = handle.await;
}
}
if let Some(handle) = self.deadman_handle.take() {
handle.abort();
@@ -981,6 +1064,19 @@ impl MeshService {
self.state.peers.read().await.values().cloned().collect()
}
/// Refuse to probe the port the live session currently occupies (the
/// probe would steal the serial port from under the session); a
/// detected-but-not-connected port is fair game, accepting a benign race
/// with the reconnect loop (whichever loses just retries). Split out from
/// the actual probe on purpose — see `probe_device`'s doc comment.
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
let status = self.state.status.read().await;
if status.device_connected && status.device_path.as_deref() == Some(path) {
anyhow::bail!("{path} is the active mesh radio — already connected");
}
Ok(())
}
/// Get message history.
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
let messages = self.state.messages.read().await;
+70 -11
View File
@@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
encode_frame(&data)
}
/// CMD_SET_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to
/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq =
/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz);
/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore
/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`,
/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The
/// same fields (same units) come back in the SELF_INFO reply, so a caller can
/// read them to detect drift. Values outside the firmware's accepted ranges are
/// rejected by the device (it replies with an error frame), not clamped here.
pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec<u8> {
let mut data = vec![CMD_SET_RADIO_PARAMS];
data.extend_from_slice(&freq_field.to_le_bytes());
data.extend_from_slice(&bw_field.to_le_bytes());
data.push(sf);
data.push(cr);
encode_frame(&data)
}
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME];
@@ -335,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
// ─── Response parsers ───────────────────────────────────────────────────
/// Decode a device/contact name from raw frame bytes, defensively.
///
/// Name fields sit at firmware-version-dependent offsets; when an offset
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
/// hand the UI replacement-character soup ("\u{618}…"). Strict rules: valid
/// UTF-8 up to the first NUL, no control characters, at least one
/// non-whitespace char — anything else gets the caller's readable fallback.
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
match std::str::from_utf8(&bytes[..end]) {
Ok(s) => {
let s = s.trim();
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
s.to_string()
} else {
fallback.to_string()
}
}
Err(_) => fallback.to_string(),
}
}
/// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
@@ -366,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII
// Name follows after fixed fields. A firmware whose fixed-field layout
// differs would put binary here — decode defensively so the settings
// panel never shows byte soup.
let name_start = 4;
let name = if data.len() > name_start {
let name_end = data[name_start..]
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
} else {
String::new()
};
@@ -435,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
// name at data[99..131] (32 bytes)
let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len());
let short_id = format!("{}...", &public_key_hex[..8]);
let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end])
.trim_end_matches('\0')
.to_string()
decode_mesh_name(&data[name_start..name_end], &short_id)
} else {
format!("{}...", &public_key_hex[..8])
short_id
};
// last_advert at data[131..135]
@@ -730,6 +766,29 @@ mod tests {
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_build_set_radio_params_wire_layout() {
// Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8.
// freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500.
let frame = build_set_radio_params(869_618, 62_500, 8, 8);
assert_eq!(frame[0], OUTBOUND_MARKER);
// payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11);
let data = &frame[3..];
assert_eq!(data[0], CMD_SET_RADIO_PARAMS);
assert_eq!(
u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
869_618
);
assert_eq!(
u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
62_500
);
assert_eq!(data[9], 8); // sf
assert_eq!(data[10], 8); // cr
assert_eq!(data.len(), 11);
}
#[test]
fn test_decode_frame_complete() -> Result<()> {
// Simulate an inbound frame: < + len(2) + [RESP_OK]
+1 -1
View File
@@ -1083,7 +1083,7 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
/// Send the verified RNode KISS detect sequence and look for `DETECT_RESP`.
/// Bytes confirmed against the canonical Reticulum source — see the module
/// doc comment and `docs/RETICULUM-TRANSPORT-PROGRESS.md`.
async fn probe_rnode(path: &str) -> Result<()> {
pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
+101 -3
View File
@@ -58,11 +58,20 @@ impl MeshcoreDevice {
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
// auto-reset) reset on a DTR/RTS transition, so deassert both and
// settle before the handshake below. 300ms is nowhere near a real
// firmware boot time (LoRa radio init alone can take longer) —
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
// Meshtastic's open() doing this same reset, a single auto-detect
// cycle trying multiple protocols in sequence kept re-resetting the
// board before it ever finished booting from the PREVIOUS attempt's
// reset, on both a Heltec V3 and V4, regardless of firmware family —
// a self-sustaining "never finishes booting" loop with a boot-time
// root cause hiding behind what looked like a per-protocol failure.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(Duration::from_millis(2000)).await;
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
@@ -149,6 +158,35 @@ impl MeshcoreDevice {
Ok(info)
}
/// The advert name learned from SELF_INFO during `initialize`.
pub fn advert_name(&self) -> Option<String> {
self.advert_name.clone()
}
/// Read firmware version + contact capacity via CMD_DEVICE_QUERY (0x16).
/// Read-only — used by the hot-swap probe to show what's on a
/// just-plugged radio. Tolerates interleaved push frames and firmware
/// that doesn't answer the query (returns None rather than erroring).
pub async fn query_device_info(&mut self) -> Option<(String, u16)> {
if self
.send_raw(&protocol::build_device_query())
.await
.is_err()
{
return None;
}
for _ in 0..5 {
match self.recv_frame_timeout(Duration::from_secs(2)).await {
Ok(f) if f.code == protocol::RESP_DEVICE_INFO => {
return protocol::parse_device_info(&f.data).ok();
}
Ok(_) => continue, // push notification — keep waiting
Err(_) => return None,
}
}
None
}
/// Set the advertised name on the mesh network.
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
self.send_raw(&protocol::build_set_advert_name(name))
@@ -164,6 +202,29 @@ impl MeshcoreDevice {
Ok(())
}
/// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field
/// units — see `protocol::build_set_radio_params`). On RESP_OK the
/// firmware persists the params and reboots to apply them, so the caller
/// must treat the session as gone and reconnect.
pub async fn set_radio_params(
&mut self,
freq_khz: u32,
bw_hz: u32,
sf: u8,
cr: u8,
) -> Result<()> {
self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr))
.await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Set radio params failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Broadcast our advertisement to the mesh.
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_raw(&protocol::build_send_self_advert()).await?;
@@ -495,14 +556,38 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
///
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
/// primary radio currently enumerates as, so both names always pointed at
/// the same candidate list entry and both passed this scan — confirmed live
/// 2026-07-23, this made an already-connected, working radio (connected via
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
/// hot-swap UI's active-session guard compares path strings, so it didn't
/// recognize the two aliases as the same port, showed the "device detected"
/// modal for a radio that was already set up, and probing it there opened
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
/// with — a continuous, UI-driven reset loop that only ran while that view
/// was open (matches the reported "stops when I leave, resumes when I come
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
/// dedup and is what's reported when both alias and target are present.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
let mut seen_real_paths = std::collections::HashSet::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device");
continue;
}
let real_path = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| std::path::PathBuf::from(path));
if !seen_real_paths.insert(real_path.clone()) {
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
continue;
}
devices.push(path.to_string());
}
}
@@ -520,6 +605,12 @@ pub struct DetectedDeviceInfo {
pub pid: Option<String>,
pub product: Option<String>,
pub manufacturer: Option<String>,
/// 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<u64>,
}
/// Like `detect_serial_devices`, but with USB metadata per port.
@@ -527,12 +618,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
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
+145
View File
@@ -0,0 +1,145 @@
//! Mirror IPv4-published app ports onto IPv6 for mesh access.
//!
//! The companion (and anything else on the FIPS mesh) reaches this node at
//! its fips0 ULA. The web UI builds app links from the current host + each
//! app's DIRECT port (Direct Port Rule) — but rootless-podman published
//! ports bind 0.0.0.0 only, so `http://[<ULA>]:8334` was refused for every
//! catalog app even after nginx :80 learned IPv6 (2026-07-23, third
//! "the node wasn't listening on v6" bug of the night).
//!
//! Instead of touching the container layer (port-publish changes would
//! recreate every container), a reconcile loop mirrors the host's public
//! IPv4 listeners: any port ≥1024 listening on 0.0.0.0 without an IPv6
//! any-address listener gets a v6-only `[::]:<port>` forwarder to
//! `127.0.0.1:<port>`. Ports appear/disappear with app install/remove, so
//! the mirror follows `/proc/net/tcp*` rather than the catalog — companion
//! containers with hardcoded ports (bitcoin-ui :8334) are covered the same
//! as manifest-declared ones. Nothing is exposed that IPv4 didn't already
//! expose to the LAN; the ULA is the established remote-access surface.
use std::collections::{HashMap, HashSet};
use std::net::{Ipv6Addr, SocketAddrV6};
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const RECONCILE_INTERVAL: Duration = Duration::from_secs(15);
/// Never mirror system ports; nginx (80/443) handles its own v6 listeners.
const MIN_PORT: u16 = 1024;
/// Entry point — spawned from main; runs for the process lifetime.
pub async fn run_mesh_port_mirror() {
let mut active: HashMap<u16, JoinHandle<()>> = HashMap::new();
loop {
match reconcile(&mut active).await {
Ok(()) => {}
Err(e) => debug!("mesh-ports: reconcile skipped: {e:#}"),
}
tokio::time::sleep(RECONCILE_INTERVAL).await;
}
}
async fn reconcile(active: &mut HashMap<u16, JoinHandle<()>>) -> Result<()> {
let v4 = listening_ports("/proc/net/tcp", 8).await?;
let v6 = listening_ports("/proc/net/tcp6", 32).await?;
// Drop mirrors whose backing IPv4 listener vanished (app removed/stopped).
active.retain(|port, handle| {
if v4.contains(port) && !handle.is_finished() {
true
} else {
handle.abort();
info!("mesh-ports: released [::]:{port} (backing 0.0.0.0 listener gone)");
false
}
});
for port in v4 {
if port < MIN_PORT || active.contains_key(&port) {
continue;
}
// A foreign IPv6 any-listener already serves this port (our own
// mirrors are excluded above via `active`).
if v6.contains(&port) {
continue;
}
match spawn_forwarder(port) {
Ok(handle) => {
info!("mesh-ports: mirroring [::]:{port} -> 127.0.0.1:{port}");
active.insert(port, handle);
}
Err(e) => debug!("mesh-ports: cannot mirror port {port}: {e:#}"),
}
}
Ok(())
}
/// Ports in LISTEN state bound to the any-address, parsed from /proc/net/tcp
/// (`addr_hex_len` 8) or /proc/net/tcp6 (32).
async fn listening_ports(path: &str, addr_hex_len: usize) -> Result<HashSet<u16>> {
let raw = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("read {path}"))?;
let any_addr = "0".repeat(addr_hex_len);
let mut ports = HashSet::new();
for line in raw.lines().skip(1) {
let mut cols = line.split_whitespace();
let (Some(_sl), Some(local), Some(_rem), Some(state)) =
(cols.next(), cols.next(), cols.next(), cols.next())
else {
continue;
};
if state != "0A" {
continue; // not LISTEN
}
let Some((addr, port_hex)) = local.split_once(':') else {
continue;
};
if addr != any_addr {
continue;
}
if let Ok(port) = u16::from_str_radix(port_hex, 16) {
ports.insert(port);
}
}
Ok(ports)
}
/// A v6-only listener on [::]:port forwarding each connection to 127.0.0.1:port.
fn spawn_forwarder(port: u16) -> Result<JoinHandle<()>> {
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))
.context("create v6 socket")?;
// v6only so we coexist with the app's own 0.0.0.0:<port> bind.
socket.set_only_v6(true).context("set v6only")?;
socket.set_reuse_address(true).ok();
socket.set_nonblocking(true).context("set nonblocking")?;
let addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0);
socket.bind(&addr.into()).context("bind [::]")?;
socket.listen(128).context("listen")?;
let listener = tokio::net::TcpListener::from_std(socket.into())
.context("register with tokio")?;
Ok(tokio::spawn(async move {
loop {
let (mut inbound, _peer) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
warn!("mesh-ports: accept failed on [::]:{port}: {e}");
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
};
tokio::spawn(async move {
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
Ok(mut upstream) => {
let _ = tokio::io::copy_bidirectional(&mut inbound, &mut upstream).await;
}
Err(e) => debug!("mesh-ports: upstream 127.0.0.1:{port} refused: {e}"),
}
});
}
}))
}
+113 -39
View File
@@ -97,42 +97,57 @@ async fn get_wan_ip() -> Option<String> {
}
/// Check if UPnP is available by attempting SSDP discovery.
///
/// The socket I/O here is plain blocking `std::net` (its 3s read timeout is
/// enforced by the OS, not by yielding to the async runtime), so it must run
/// on the blocking-pool via `spawn_blocking` — inlined into this "async fn"
/// directly, it used to occupy a tokio worker thread for the full 3s on
/// every call. With only as many worker threads as CPU cores, a handful of
/// concurrent `network.diagnostics` calls (e.g. several Server-settings page
/// loads) could starve the whole runtime and freeze every other in-flight
/// request — root cause of a full-node outage (2026-07-24) that had nothing
/// to do with connection limits and everything to do with blocking sockets
/// on async worker threads.
async fn check_upnp_available() -> bool {
use std::net::UdpSocket;
tokio::task::spawn_blocking(|| {
use std::net::UdpSocket;
let ssdp_request = "M-SEARCH * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
MAN: \"ssdp:discover\"\r\n\
MX: 2\r\n\
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n";
let ssdp_request = "M-SEARCH * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
MAN: \"ssdp:discover\"\r\n\
MX: 2\r\n\
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n";
let socket = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(_) => return false,
};
let socket = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(_) => return false,
};
if socket
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
.is_err()
{
return false;
}
if socket
.send_to(ssdp_request.as_bytes(), "239.255.255.250:1900")
.is_err()
{
return false;
}
let mut buf = [0u8; 2048];
match socket.recv_from(&mut buf) {
Ok((len, _)) => {
let response = String::from_utf8_lossy(&buf[..len]);
response.contains("InternetGatewayDevice") || response.contains("200 OK")
if socket
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
.is_err()
{
return false;
}
Err(_) => false,
}
if socket
.send_to(ssdp_request.as_bytes(), "239.255.255.250:1900")
.is_err()
{
return false;
}
let mut buf = [0u8; 2048];
match socket.recv_from(&mut buf) {
Ok((len, _)) => {
let response = String::from_utf8_lossy(&buf[..len]);
response.contains("InternetGatewayDevice") || response.contains("200 OK")
}
Err(_) => false,
}
})
.await
.unwrap_or(false)
}
/// Add a port forward (stored locally; actual UPnP mapping done on request).
@@ -281,19 +296,41 @@ pub async fn run_diagnostics() -> Result<NetworkDiagnostics> {
}
/// Check if Tor SOCKS proxy is reachable.
///
/// `TcpStream::connect_timeout` blocks the calling OS thread for up to its
/// timeout — same blocking-on-a-worker-thread hazard as `check_upnp_available`
/// above, so this also runs on the blocking pool.
async fn check_tor_connectivity() -> bool {
use std::net::TcpStream;
TcpStream::connect_timeout(
&"127.0.0.1:9050".parse().unwrap(),
std::time::Duration::from_secs(2),
)
.is_ok()
tokio::task::spawn_blocking(|| {
use std::net::TcpStream;
TcpStream::connect_timeout(
&"127.0.0.1:9050".parse().unwrap(),
std::time::Duration::from_secs(2),
)
.is_ok()
})
.await
.unwrap_or(false)
}
/// Check DNS resolution works.
///
/// `to_socket_addrs()` is a blocking libc resolver call with no timeout of
/// its own — on a network with a slow/unreachable DNS server (e.g. right
/// after relocating to a new network) it can hang far longer than the other
/// checks here. Runs on the blocking pool (same reason as the checks above)
/// AND under an explicit timeout, since unlike UPnP/Tor there's no built-in
/// bound to rely on.
async fn check_dns() -> bool {
use std::net::ToSocketAddrs;
"cloudflare.com:443".to_socket_addrs().is_ok()
let lookup = tokio::task::spawn_blocking(|| {
use std::net::ToSocketAddrs;
"cloudflare.com:443".to_socket_addrs().is_ok()
});
tokio::time::timeout(std::time::Duration::from_secs(5), lookup)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(false)
}
// --- Router Compatibility Abstraction ---
@@ -469,3 +506,40 @@ pub async fn get_router_info(data_dir: &Path) -> Result<serde_json::Value> {
},
}))
}
#[cfg(test)]
mod blocking_io_tests {
use super::*;
// Regression test for the 2026-07-24 outage: check_upnp_available,
// check_tor_connectivity, and check_dns each did blocking std::net I/O
// directly on their calling task instead of via spawn_blocking. On a
// small worker pool (4 threads in production), a handful of concurrent
// network.diagnostics calls tied up every worker thread for seconds,
// freezing every other in-flight RPC request. Proves a cheap task
// spawned alongside these checks still gets scheduled promptly, which
// only holds if the checks aren't monopolizing worker threads.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_checks_do_not_starve_other_tasks() {
let cheap = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
std::time::Instant::now()
});
let _ = tokio::join!(
check_upnp_available(),
check_tor_connectivity(),
check_dns()
);
// The assertion is that `cheap` — spawned before the blocking checks
// and sleeping only 5ms — finishes within 1s of being spawned. If the
// checks were still blocking worker threads directly, a 2-worker
// runtime running 3 blocking checks concurrently would starve this
// task well past 1s.
tokio::time::timeout(std::time::Duration::from_secs(1), cheap)
.await
.expect("a concurrently-spawned cheap task must not be starved by blocking network checks")
.expect("cheap task panicked");
}
}
+4 -1
View File
@@ -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
{
+12 -2
View File
@@ -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
+15
View File
@@ -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<String> {
let mut relays: Vec<String> = 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<RelayStore> {
let path = data_dir.join(RELAYS_FILE);
if !path.exists() {
+107 -9
View File
@@ -101,6 +101,15 @@ impl Server {
}
state_manager.update_data(data.clone()).await;
// Real-time wallet push: stream LND transaction events → websocket
// revision nudges, so an incoming on-chain tx shows in the UI within
// seconds of hitting the mempool (works whenever LND is up; retries
// forever otherwise). User req 2026-07-22.
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
// LND wedge watchdog — self-heal the silent "RPC up, server never
// ready" state instead of waiting for a human (100%-uptime req).
crate::api::rpc::lnd::spawn_lnd_health_watchdog();
// Retry Tor address in background — Tor may not be ready at startup
if data.server_info.tor_address.is_none() {
let sm = state_manager.clone();
@@ -203,9 +212,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,
@@ -247,6 +262,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();
@@ -688,12 +720,27 @@ impl Server {
let fips_peer_registry = fips_peer_registry.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut interval = tokio::time::interval(Duration::from_secs(300));
// Steady cadence, but retry fast right after a daemon restart:
// regenerating fips.yaml (this build does, once, on first boot
// after the OTA) restarts the fips daemon, and for a few seconds
// `/run/fips/control.sock` is gone so every `fipsctl connect`
// fails and the node islands until the next tick. Detect that
// exact failure and retry in 15s instead of 5 min — bounded, so a
// node with no fips daemon falls back to the steady cadence
// rather than busy-looping.
const STEADY: Duration = Duration::from_secs(300);
const FAST: Duration = Duration::from_secs(15);
const MAX_FAST_RETRIES: u32 = 8; // ≤2 min of fast retries/episode
let mut fast_retries: u32 = 0;
loop {
interval.tick().await;
let mut daemon_restarting = false;
match crate::fips::anchors::load(&data_dir).await {
Ok(list) if !list.is_empty() => {
let _ = crate::fips::anchors::apply(&list).await;
let results = crate::fips::anchors::apply(&list).await;
daemon_restarting = !results.is_empty()
&& results
.iter()
.all(|r| !r.ok && r.message.contains("control.sock"));
}
Ok(_) => { /* no seed anchors configured yet */ }
Err(e) => {
@@ -717,6 +764,15 @@ impl Server {
let _ = crate::fips::anchors::apply(&direct).await;
}
}
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
fast_retries += 1;
FAST
} else {
fast_retries = 0;
STEADY
};
tokio::time::sleep(next).await;
}
});
}
@@ -920,13 +976,21 @@ impl Server {
main_addr: SocketAddr,
shutdown: impl std::future::Future<Output = ()>,
) -> Result<()> {
let active_connections = Arc::new(tokio::sync::Semaphore::new(1024));
// Separate pools per listener. Federation/peer connections (fips0,
// often over Tor, from nodes we don't control the behavior of) used
// to share one pool with the local web UI listener — when peer
// connections piled up in CLOSE-WAIT without ever completing, they
// starved the shared pool and took the web UI down with them
// (production outage, 2026-07-24). Peer congestion must never be
// able to block a local login.
let main_connections = Arc::new(tokio::sync::Semaphore::new(1024));
let peer_connections = Arc::new(tokio::sync::Semaphore::new(256));
let (tx, rx_main) = tokio::sync::watch::channel(false);
let main_task = tokio::spawn(accept_loop(
self.api_handler.clone(),
TcpListener::bind(main_addr).await?,
active_connections.clone(),
main_connections.clone(),
false, // main listener: no path filter
rx_main,
main_addr,
@@ -936,7 +1000,7 @@ impl Server {
// restart when fips0 comes up after onboarding.
let peer_task = tokio::spawn(peer_late_bind_loop(
self.api_handler.clone(),
active_connections.clone(),
peer_connections.clone(),
tx.subscribe(),
));
@@ -947,7 +1011,9 @@ impl Server {
// Wait up to 5s for in-flight requests.
let drain_start = std::time::Instant::now();
let drain_timeout = std::time::Duration::from_secs(5);
while active_connections.available_permits() < 1024 {
while main_connections.available_permits() < 1024
|| peer_connections.available_permits() < 256
{
if drain_start.elapsed() > drain_timeout {
warn!("Drain timeout reached, forcing shutdown");
break;
@@ -1040,6 +1106,11 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|| path.starts_with("/content/")
}
/// How long a freshly-accepted connection will wait for a connection-pool
/// permit before it's dropped. Bounds worst-case fd/task growth if the pool
/// is ever genuinely saturated; under normal load this never triggers.
const PERMIT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
async fn accept_loop(
handler: Arc<ApiHandler>,
listener: TcpListener,
@@ -1059,8 +1130,35 @@ async fn accept_loop(
}
};
let handler = handler.clone();
let permit = active_connections.clone().acquire_owned().await;
let active_connections = active_connections.clone();
// Acquire the permit *inside* the spawned task, not here.
// This loop must never block on anything but accept()/shutdown:
// the main (5678) and FIPS peer (5679) listeners share one
// semaphore, and a single slow/hung connection holding the
// last permit used to freeze this whole loop — including for
// the OTHER listener — since accept() couldn't be called
// again until a permit freed up. That took down the entire
// web UI in production (2026-07-24) when federation peer
// connections piled up. Now a saturated pool just delays
// (and, past PERMIT_ACQUIRE_TIMEOUT, drops) individual
// connections instead of wedging the acceptor itself.
tokio::spawn(async move {
let permit = match tokio::time::timeout(
PERMIT_ACQUIRE_TIMEOUT,
active_connections.acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(_)) => return, // semaphore closed during shutdown
Err(_) => {
warn!(
"{} connection from {} dropped — connection pool saturated for {}s",
local_addr, peer_addr, PERMIT_ACQUIRE_TIMEOUT.as_secs()
);
return;
}
};
let _permit = permit;
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
let handler = handler.clone();
+6
View File
@@ -3,6 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- Must agree with the embedding neode-ui shell (color-scheme: dark):
browsers only honor a transparent same-origin iframe canvas when
embedder and iframe use the SAME color-scheme — without this, the
shell's :root{color-scheme:dark} forces this frame opaque and the
background art behind the chat vanishes (2026-07-23). -->
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
<meta name="mobile-web-app-capable" content="yes" />
+238
View File
@@ -0,0 +1,238 @@
# Handoff — 2026-07-20 — peer-files diagnosis, FIPS 0.4.1, mobile transport pill
Written for a fresh session that will **cut the OTA release and build the ISO**.
Everything below is already committed and pushed to `gitea-ai/main`. Last release
was `v1.7.105-alpha` (`e2f83c01`); the next one should be **`v1.7.106-alpha`**.
---
## 1. What this release carries (3 commits on top of v1.7.105-alpha)
| Commit | What | User-visible? |
|---|---|---|
| `9e3ac9ba` | Show the FIPS/Tor transport pill on **mobile** peer files | Yes |
| `3ab7fb52` | Log the full anyhow error chain on RPC failures | No (diagnostics) |
| `5fd0d6c3` | Generate `fips.yaml` from typed structs + enable **mDNS LAN discovery** | Indirectly |
### `9e3ac9ba` — mobile transport pill
`PeerFiles.vue:15` wraps the peer title in `hidden md:block` (the global header
carries the name on mobile), and the transport pill was nested inside it — so it
vanished below 768px. Added a separate `md:hidden` pill next to the peer icon.
Frontend was rebuilt and the class verified present in the emitted bundle.
Caveats worth knowing (pre-existing, not introduced here):
- On this code path the backend only ever emits `fips` or `tor`, so the `mesh`
and `lan` branches in `transportPill` (`PeerFiles.vue:609-627`) are dead.
- For **received** mesh messages, `mesh/mod.rs:1519-1533` falls back to a
hardcoded `"tor"` when the transport is unknown — that pill can genuinely lie.
The peer-files pill does not.
### `3ab7fb52` — full error chain in logs
`api/rpc/mod.rs:441` logged only the outermost anyhow context, so every
peer-files failure read exactly `RPC error on content.browse-peer: Failed to
connect to peer` with the real cause discarded. Now `{:#}`. The client-facing
message still goes through `sanitize_error_message(&e.to_string())` (`{}`), so
no internal detail leaks. **This fix applies to every RPC method, not just
browse-peer.**
### `5fd0d6c3` — typed FIPS config + mDNS
`fips/config.rs` built `/etc/fips/fips.yaml` by `format!`-ing a string literal.
Upstream's config structs are `#[serde(deny_unknown_fields)]`, so a wrong key
does not degrade — **the daemon refuses to start and the node leaves the mesh**.
Now a typed serde struct tree, verified field-by-field against jmcorgan/fips
**v0.4.1**, with 4 tests: exact-output snapshot, determinism, mDNS key path, and
the pre-existing schema test. All pass.
Also enables `node.discovery.lan.enabled` (mDNS/DNS-SD, new upstream in v0.4.0)
so co-located nodes peer directly instead of depending on the public anchor.
> ⚠️ **Expected one-time behaviour on first boot after this lands:** the startup
> drift check at `server.rs:864` compares the freshly rendered config against
> what's on disk. The render differs now, so it reinstalls the config and
> restarts the FIPS daemon **once**. This is the intended self-healing path and
> settles immediately. Do not mistake it for a regression.
Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
has no `lan` field **and** no `deny_unknown_fields`, so v0.3.0 daemons ignore it
harmlessly (verified against the v0.3.0 source). It self-activates on upgrade.
---
## 2. FIPS 0.4.1 — validated, but the fleet is NOT rolled
Fleet was on FIPS **0.3.0 / 0.3.0-dev** (2026-05-11). Upstream is **v0.4.1**
(2026-07-19). Verified before touching anything:
- **Wire-compatible** 0.3.0 → 0.4.0 → 0.4.1. Rolling upgrade, any order, no flag day.
- **Config forward-compatible** — every key we emit exists in 0.4.1.
- **Asset names match** what `fips/update.rs` expects (`fips_<ver>_<arch>.deb` +
`checksums-linux.txt`), so the in-product updater should work.
### Upgraded so far (2 of N)
| Node | Before | After | Result |
|---|---|---|---|
| OptiPlex `.198` / `100.114.134.21` | `0.3.0-dev-1` | **0.4.1** | ✅ anchor connected, `is_parent: true`, tree `depth: 4` |
| thinkpad (this machine) | `0.3.0` | **0.4.1** | ✅ service active, but still islanded (see §4) |
The OptiPlex was still running the **old string-rendered config** and 0.4.1
accepted it — empirical confirmation of the compat analysis, not just desk work.
### Upgrade recipe (nodes cannot reach GitHub — sideload)
```bash
# 1. On a host with GitHub access:
curl -sL -o fips_0.4.1_amd64.deb \
https://github.com/jmcorgan/fips/releases/download/v0.4.1/fips_0.4.1_amd64.deb
curl -sL -o checksums-linux.txt \
https://github.com/jmcorgan/fips/releases/download/v0.4.1/checksums-linux.txt
sha256sum fips_0.4.1_amd64.deb # must match checksums-linux.txt
# expected: 9befcc0990c7e08742b5a88f75d753a1088134b20525156688d559a317334ded
# 2. Sideload:
scp fips_0.4.1_amd64.deb archipelago@<node>:/tmp/
# 3. On the node — the same command update.rs uses:
sudo -n systemd-run --collect --wait --quiet --pipe -- \
env DEBIAN_FRONTEND=noninteractive dpkg --force-confold --force-downgrade -i \
/tmp/fips_0.4.1_amd64.deb
# 4. Restart the ACTIVE unit — it is archipelago-fips.service,
# NOT fips.service (which is inactive on these nodes):
sudo -n systemctl restart archipelago-fips.service
# 5. Verify:
fipsctl --version
sudo -n fipsctl show links # expect anchor 185.18.221.160:8443 connected
sudo -n fipsctl show tree # expect is_root: false, depth > 0
```
### ISO implication (important)
`image-recipe/build/auto-installer/Dockerfile.rootfs:23` builds FIPS from
**unpinned upstream main** (`git clone --depth 1`, no rev/tag/checksum, amd64
only). So a freshly built ISO will pick up whatever main is that day — probably
≥0.4.1, but it is not deterministic. Pinning is an open item in
`docs/1.8.0-RELEASE-HARDENING-PLAN.md:319-322`. **Consider pinning to v0.4.1
before building the release ISO** so the shipped version is knowable.
---
## 3. The original bug — peer cloud files not loading
**Status: root-caused for the thinkpad; NOT fully explained.** Being explicit
because it would be easy to read this as closed.
What is established:
- FIPS was fully down on the thinkpad: `fipsctl show peers``[]`, `show links`
`[]`, `show tree``is_root: true, depth 0`. An island.
- Cause is **network egress**, not FIPS config: the thinkpad cannot reach the
public anchor `185.18.221.160` (`fips.v0l.io`) **at all** — 100% packet loss on
ICMP, 443/8443/8668 all time out. `show transports` showed
`packets_sent: 760, packets_recv: 0` on both UDP and TCP.
- Local firewall is **not** the cause (nft/iptables policy `accept`; only stock
Tailscale anti-spoof DROPs).
- The OptiPlex, on the same `/24`, reaches the anchor fine → it's the thinkpad's
WiFi segment (`wlp3s0`), which also blocks L2 to `.198` (`ip neigh``FAILED`).
- With no FIPS tree, everything falls back to Tor. Every peer in
`federation/nodes.json` reads `last_transport: "tor"`, never `"fips"`.
- **Tor itself is healthy**: fetched the OptiPlex's `/content` over Tor 3×,
HTTP 200 in 4.18.5s — well inside the 30s budget at `content.rs:349`.
What is **not** established: why three specific `content.browse-peer` calls
failed today (05:25, 16:37, 16:43 UTC). Tor tested healthy and was never
reproduced. Two hypotheses were tested and **disproved**: the Tor fallback logic
is correct (FIPS-unreachable returns `None` and falls through in Auto mode), and
the legs get independent timeouts (Tor gets a fresh 30s). Best remaining guess is
cold-circuit timeouts on first fetch after idle — **a guess, not a finding.**
`3ab7fb52` means the next occurrence will log the actual cause.
### Corrections to earlier claims in this session
- "Point FIPS at the Tailscale IP" was **wrong**. FIPS routes by npub; the
`ip:port` in `fipsctl connect` is only an underlay endpoint hint.
- "The public anchor may be dead fleet-wide" was **wrong**. Its peer is healthy
(`delivery_ratio` 1.0 both directions, bloom filter syncing). The
`bytes_recv: 0` link counters are simply uninstrumented in 0.3.0.
---
## 4. Open items — decisions NOT taken
1. **Second FIPS anchor (user asked for this; not built).** Needs a host running
FIPS that is reachable from the restricted WiFi. Candidate found: OVH
**`146.59.87.168`** — pings fine from the thinkpad and general egress works
(github 200), while the upstream anchor fails even ICMP there. But it does not
run FIPS yet, so this means **installing FIPS on the box that hosts Gitea**
a production change, deliberately not made unprompted. Code side is easy after:
`fips/anchors.rs:47-50` is a single hardcoded anchor that should become a list
(`default_public_anchor()``default_public_anchors() -> Vec<SeedAnchor>`).
2. **Fleet rollout of FIPS 0.4.1** — only 2 nodes done. `.228`
(`100.64.204.114`) has been **offline ~20h** and could not be included.
3. **Deploying the archipelago binary** carrying `5fd0d6c3` — no node has it yet,
so mDNS is not actually live anywhere. That is what this OTA is for.
4. **mDNS caveat:** on the thinkpad's WiFi, multicast may also be blocked, so
mDNS may not rescue that particular node even after the OTA. It will help
co-located nodes on sane networks.
5. **Pin FIPS in the ISO build** (see §2) — recommended before the release ISO.
---
## 5. Release ritual (from prior sessions — follow exactly)
Working tree at handoff had pre-existing unrelated dirt: `core/Cargo.lock`,
`release-manifest.json`, `releases/manifest.json` modified, and an untracked
`neode-ui/vite.preview.config.mts`. **Stage explicitly by path** — another
agent may share this tree; never `git add -A`.
```bash
V=1.7.106-alpha
# Frontend build — MUST verify dist actually changed (build can silently no-op)
cd neode-ui && npm run build # → web/dist/neode-ui/
grep -r "md:hidden" ../web/dist/neode-ui/assets/PeerFiles-*.js # sanity
# Backend
cd core && cargo build --release -p archipelago
# If you hit `rust-lld: undefined hidden symbol`, it's incremental-cache
# corruption — rebuild with CARGO_INCREMENTAL=0
# Tarball MUST be flat (files at root, no neode-ui/ wrapper) or every fleet UI 403s
tar -czf releases/v$V/archipelago-frontend-$V.tar.gz -C web/dist/neode-ui .
tar -tzf releases/v$V/archipelago-frontend-$V.tar.gz | head -3 # ./ then ./index.html
# Exclude the ~17MB companion APK from tarballs.
# Ship
scripts/create-release.sh $V
scripts/publish-release-assets.sh $V gitea-vps2
git push origin main && git push origin --tags # tag or the Releases page stays empty
git push gitea-ai main # main is protected; use the `ai` account
# Verify the live manifest
curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
```
Notes: vps2 (`146.59.87.168`) is the **primary** OTA manifest host. Signing is
done at the **user's TTY** — do not attempt it unattended. Clean `/tmp` first
(past releases hit ENOSPC). Changelogs must be **layman-readable**, leading with
user benefit.
### ISO
```bash
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh
```
ISO builds are **always unbundled** — the default env silently builds the wrong
full-bundle variant. Only filebrowser + fmcd are baked in. Verify the output
filename contains `unbundled` and is ≈2.4G. The ISO's frontend source is
`/opt/archipelago/web-ui` — rsync dist there first and verify **inside** the ISO.
---
## 6. Node access quick reference
- **thinkpad (`.116`) is the local machine** — do not SSH to it; read
`journalctl -u archipelago` and `/var/lib/archipelago/**` directly.
- **OptiPlex `.198`** = Tailscale `archipelago-5` / `100.114.134.21`, user
`archipelago`. Its LAN IP is unreachable from the thinkpad — use Tailscale.
- `.228` = `archipelago-2` / `100.64.204.114`**offline as of 2026-07-20**, and
it is in real use; don't touch uninvited.
- `archipelago-1` (`100.82.34.38`) is a Ryzen AI Max desktop, **not** the OptiPlex.
- Nodes have no `sqlite3` — use `sudo -n python3` to read the JSON stores.
- `fipsctl` needs `sudo -n` (socket is `root:fips` 0660).
- **Never run `archipelago --version` on fleet nodes** (deployed binaries predate #74).

Some files were not shown because too many files have changed in this diff Show More