Compare commits

..
Author SHA1 Message Date
Dorian 1593c55894 chore(android): update companion apk download 2026-07-27 17:12:52 +01:00
DorianandClaude Fable 5 1cc18e5b72 feat(companion): three-finger opens the hub menu over the dashboard — 0.5.25
The dashboard's three-finger hold now opens the menu overlay in place
instead of jumping to the remote screen; the hub's Remote and Keyboard
cards do the navigating (Keyboard lands directly in keyboard mode via a
nav arg). The dashboard host carries the full Nodes page — add, edit,
remove and QR pairing — and Mesh Party.

Also: the panel scales to 92% of screen height instead of a fixed 560dp
cap so pages fit without scrolling; the FIPS sub-page no longer repeats
the FIPS Mesh header inside the detail card; the safe-area injection
fires an archy-insets event the web UI listens for (update-install
status-bar fix, other half in neode-ui).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
DorianandClaude Fable 5 24582128c5 fix(ui): re-pad mobile dashboard when Android injects safe-area top
The mobile nav sampled --safe-area-top once at mount, but the companion
WebView injects it asynchronously. An authenticated session mounts the
dashboard before the injection lands (fresh installs mount after login,
long after it), so the content padding baked in 0 while the fixed tab
bar grew by the real inset — content slid underneath by exactly the
status-bar height, the update-install-only overlap.

Re-read on the WebView's new archy-insets event, with a retry ladder as
fallback for APKs that predate the event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
DorianandClaude Fable 5 0b038434b1 feat(companion): settings menu hub redesign — 0.5.24
Three-finger menu becomes a contained card hub: Dashboard, Remote,
Keyboard, Nodes, FIPS Mesh, Mesh Party — with Nodes and FIPS as
sub-pages behind a back header. The panel is a centred glass card that
scrolls within its own bounds instead of filling the screen.

The Dark/Classic style toggle leaves the menu and becomes a palette
button next to the settings gear on all three input surfaces (landscape
controller, portrait controller, keyboard mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
DorianandClaude Fable 5 d576f77435 fix(companion): off-LAN load falls back to mesh URL, not dead LAN IP
When neither the LAN origin nor the mesh ULA answers the probe window,
target the mesh URL (when paired) instead of the LAN IP: off-LAN the LAN
address can never answer, and loading it showed a confusing 'can't reach
192.168.x.x' error while the mesh session was still coming up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:12:20 +01:00
archipelagoandClaude Fable 5 7e8d3314d0 docs: backlog — optimise companion QR scan (quicker start/decode, low-light)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:12:01 -04:00
DorianandClaude Fable 5 07772b563f fix(companion): wallet scanner reads dense invoice QRs — 0.5.22
Demo images / Build & push demo images (push) Successful in 2m59s
Root cause (diagnosed live via CDP on a Pixel 9a): camera permission
granted and the native camera streamed (2m35s active) but ZXing returned
zero decodes for Lightning-invoice QRs — window.__archyQrResult received
0 calls. Dense BOLT11 codes need more pixels/module AND sharp focus; the
9a's main lens rests at a far focus and 1280x720 wasn't enough, so dense
invoices never resolved while sparse address QRs did. Fix: analysis at
1920x1080 + a repeating centre autofocus tick (a static hand-held QR
never retriggers continuous-AF on its own).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:51:47 +01:00
archipelagoandClaude Fable 5 d500214766 docs: backlog — ship lightning false-failure fix; companion app version display
Demo images / Build & push demo images (push) Successful in 3m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 05:33:02 -04:00
archipelagoandClaude Fable 5 9cd507269c fix(lightning): never report a slow in-flight payment as failed
Slow multi-hop payments (>15s routing) surfaced as "Payment failed"
while LND settled them in the background: the shared LND REST client's
15s total timeout aborted the synchronous /v1/channels/transactions
wait, and every UI path treated that abort as a definitive failure. The
payment then succeeded anyway and only appeared in history on the next
background poll.

Backend: lnd.payinvoice now decodes the invoice up front for its payment
hash, pays on a dedicated 120s client, and answers status:"pending" with
the hash (never an error) when the wait elapses after the payment was
handed to LND — only a pre-connect failure is still a hard error. New
lnd.paymentstatus RPC reports succeeded/failed/in_flight (with humanized
failure reasons) from /v1/payments.

Frontend: new rpcClient.payLightningInvoice() pays then polls
lnd.paymentstatus to a real terminal state (3s interval, up to 2 min);
all five call sites (send modal, scan modal, web5 unified send, peer-file
purchase, app-launcher payments) migrated. Failure is only shown when LND
itself declares FAILED; a still-settling payment shows an in-flight state
and success fires the transaction refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 05:33:02 -04:00
Dorian aa272bfcf4 chore(android): update companion APK download [skip ci] 2026-07-27 10:19:28 +01:00
DorianandClaude Fable 5 3b6a32b2f8 fix(companion): app webview content clears the status bar; HTTPS toggle on add/edit — 0.5.21
- In-app browser pages (node apps) now start below the status bar again:
  the WebView stays edge-to-edge (page colour fills the bar) and a
  body{padding-top:<statusbar>} injection pushes content down — the
  pre-edge-to-edge look without the black bar.
- Add/Edit server in the menu now has a Use HTTPS toggle (was scheme-locked;
  edit preserved the old scheme, add forced http).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:19:11 +01:00
DorianandClaude Fable 5 c66ef048f4 chore(companion): publish 0.5.20 to download QR (transport handoff + FIPS mesh settings)
Demo images / Build & push demo images (push) Successful in 2m57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:43:28 +01:00
Dorian 32962db6a9 chore(android): update companion APK download [skip ci] 2026-07-27 09:40:08 +01:00
DorianandClaude Fable 5 4edc50ae47 feat(companion): seamless transport handoff + FIPS mesh settings section — 0.5.20
- Transport handoff (Wi-Fi ⇄ 5G, BLE-ready): ArchyVpnService registers a
  ConnectivityManager callback that re-pins the tunnel to the new default
  network (setUnderlyingNetworks) and re-homes the mesh (fresh warmer pass
  → discovery/sessions rebuild on the new path in seconds). Previously the
  tunnel stayed pinned to the network it launched on and roaming stranded
  the mesh until an app restart.
- FIPS Mesh section in the NESMenu settings modal: status, mesh address
  (fd… ULA), identity npub (both copyable), configured peer/anchor count,
  and a one-tap Reconnect (manual re-home). Gives users oversight of what
  the embedded mesh node is doing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:39:47 +01:00
DorianandClaude Fable 5 245fb1a815 chore: release v1.7.116-alpha (signed OTA manifest)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:05:18 +01:00
DorianandClaude Fable 5 6dffd8e2e8 chore: bump version to 1.7.116-alpha + changelog/What's New
Demo images / Build & push demo images (push) Successful in 2m59s
Bundles all post-115 fixes into an OTA release: boot READY-before-recovery
+ Restart=always (no more 'server starting up'), and the app-install
daemon-kill fix (v6 relay only bridges live app ports; port-cleanup
excludes our own PID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:59:48 +01:00
DorianandClaude Fable 5 365f3d7d18 fix(install): mesh app-port relay no longer kills the daemon on install
The v6 app-port relay preemptively bound [::]:<port> for ALL catalog
ports, even apps not installed. Installing such an app (grafana:3000,
photoprism, uptime-kuma, jellyfin — framework-pt 2026-07-27) then hit
'address already in use' from the relay, which triggered
cleanup_stale_pasta_port ->  -> killed archipelago
itself (it held the port) mid-install. The daemon crash-looped and the
half-created apps were rolled back and vanished.

Two fixes:
- relay only bridges a port that a running app already answers on over
  IPv4 (probe 127.0.0.1:port first) — an uninstalled app's port is never
  held, so its install sees a free port and never triggers the cleanup.
- cleanup_stale_pasta_port excludes our own PID from both the ss-based
  kill and the fuser kill, so freeing a port can never terminate the
  daemon even when the relay legitimately holds it (reinstall case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:29:54 +01:00
DorianandClaude Fable 5 70bcbc4acc fix(boot): signal systemd READY before heavy boot recovery + Restart=always
Root cause of 'server starting up' forever / crash-on-install
(framework-pt, v1.7.114->115, 2026-07-26): on a node with many stacks,
the synchronous boot recovery (recover + start_stopped_containers) runs
BEFORE sd_notify(Ready), so the unit sits in 'activating' for minutes.
Anything touching the service in that window — a superseding
start/restart, an install-time reconcile churn — killed a half-started
instance; it exits 0 on SIGTERM and Restart=on-failure then never
restarts it. Node dead behind 'server starting up'.

Fixes:
- signal READY (+ start the watchdog keepalive) BEFORE boot recovery, so
  the unit reaches 'active' in seconds; recovery/reconcile/listener
  continue after. No more minutes-long activating window.
- Restart=always (was on-failure): a clean-exit SIGTERM must still bring
  the daemon back. Manual  is still honored.
- OTA restart via a PID1-owned transient timer (systemd-run --on-active=2)
  instead of a tokio-sleep child of the process being stopped, whose
  start-half was being lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 00:31:54 +01:00
DorianandClaude Fable 5 56e4c30261 fix(companion): edge-to-edge app webview, whole-overlay touch shield, no raw IP titles — 0.5.19
Demo images / Build & push demo images (push) Successful in 3m0s
- app webview draws behind the status bar again (the inset rework
  painted an opaque black bar there); page background owns the top
- the in-app overlay eats EVERY touch its children don't handle —
  a near-miss on Close was falling through to the kiosk AIUI tab
- loading screen never titles itself with a raw mesh IPv6/IP host
- re-land vc37 connect fixes vc38 shipped without: pairing restarts
  the mesh immediately when consent exists; session warmer probes all
  targets concurrently (5s) instead of 20s each in sequence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:31:48 +01:00
DorianandClaude Fable 5 f7208e1769 chore: release v1.7.115-alpha
Signed OTA manifest (release root verified). Also: create-release-manifest
tarball perms check made SIGPIPE-proof so releases cut on macOS too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:24:24 +01:00
DorianandClaude Fable 5 77ee39a3df fix(server+quadlet): app UIs work over the mesh — v6 relay + v4-pinned publishes
Rootless podman's wildcard publish claims [::] but BLACK-HOLES inbound
v6 (accepts, forwards nothing — vaultwarden 'empty response' from the
phone, 2026-07-26), while most apps got no v6 listener at all. Two
halves: quadlets now publish unbound ports on 0.0.0.0 explicitly
(frees the v6 side; the one-time drift/recreate at upgrade is the
deploy vehicle), and the daemon runs a self-selecting V6ONLY relay on
every catalog launch port forwarding raw TCP to the v4 loopback
listener. Rescans every 60s so new installs bridge without a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:48:16 +01:00
DorianandClaude Fable 5 02917cc22c fix(fips): app launch ports allowed through the mesh firewall
The companion opens catalog apps by direct port over the mesh; the
fips0 default-deny baseline blocked every one of them (apps 'stuck'
from the phone, 2026-07-26). Core now writes a second fips.d drop-in
from the generated catalog launch-port list on every install/upgrade.
Service/RPC ports (bitcoind 8332, LND 10009, Tor) stay closed.
generate-app-catalog.py emits the Rust port list alongside the TS one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:23:46 +01:00
DorianandClaude Fable 5 325b9ea9c9 chore: bump version to 1.7.115-alpha (release prep)
Demo images / Build & push demo images (push) Successful in 3m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:05:09 +01:00
DorianandClaude Fable 5 9739bbb3db docs+fix: ship fips0 web-UI firewall allowance from core; changelog + What's New for v1.7.115-alpha
Demo images / Build & push demo images (push) Has been cancelled
fips::config::install() now writes /etc/fips/fips.d/80-web-ui.nft
(tcp 80/8443 accept) and reloads the baseline on every install/upgrade —
the hardening firewall default-denies inbound on fips0 and the UI was
unreachable over the mesh without it (root-caused live 2026-07-26).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:03:25 +01:00
DorianandClaude Fable 5 4db1b85fc5 fix(server): web UI listens on IPv6 — the mesh could never load it
The mesh is IPv6-only; the main web listener bound 0.0.0.0 only, so a
phone reaching a node over its fips0 ULA got RST on :80
(ERR_CONNECTION_ABORTED) — UI over mesh was structurally impossible.
Confirmed 2026-07-26 on framework-pt: v4:80 = 200, v6:80 = refused.
Mirror an IPv4-any main listener with a V6ONLY [::] socket on the same
port; V6ONLY so it coexists with the v4 listener regardless of
net.ipv6.bindv6only.

Also: fips.yaml generator carries the fast-reconnect profile (validated
live on framework-pt since 2026-07-24; snapshot tests updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:58:11 +01:00
archipelago a78aa02890 chore: release v1.7.114-alpha
Demo images / Build & push demo images (push) Successful in 3m1s
2026-07-26 13:40:03 -04:00
archipelagoandClaude Fable 5 0365cc0f9d docs: changelog + What's New for v1.7.114-alpha
Demo images / Build & push demo images (push) Successful in 2m52s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:24:36 -04:00
archipelagoandClaude Fable 5 0880824fb3 fix(ui): seed QRs use the SeedQR standard so hardware wallets can scan them
Demo images / Build & push demo images (push) Successful in 2m52s
Plain-text seed QRs didn't scan into Passport Prime — wallets that
import seeds by QR (Passport, SeedSigner, Keystone, Nunchuk, Sparrow)
expect the SeedQR standard: each BIP39 word as its zero-padded 4-digit
wordlist index, concatenated into a numeric QR.

- new utils/seedqr.ts encodes BIP39 words per the SeedSigner spec
  (@scure/bip39 wordlist; vector-checked abandon=0000, zoo=2047)
- new shared SeedRevealPanel (Words/QR tabs, tap-to-reveal blur) now
  backs the LND reveal AND the Settings→Backup recovery-phrase reveal,
  so every current and future seed reveal behaves the same
- onboarding seed + Settings reveal: QR defaults to SeedQR with a
  plain-text toggle
- LND seed stays plain-text-only with an explicit note: aezeed is not
  BIP39 and only restores into LND-based wallets (Zeus/Blixt/another
  node) — SeedQR-encoding it would just mislead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:09:43 -04:00
archipelagoandClaude Fable 5 c0d34bd836 fix(mesh): serialize serial-port opens between listener and RPC probe
Observed live on .116 after the dedup/backoff fixes: the kiosk browser's
hot-swap auto-probe (mesh.probe-device) still interleaved its handshakes
with the listener's open sequence on the same tty every backoff window —
Linux double-opens ttys silently, both handshakes corrupted each other,
and every collision's open() DTR/RTS-reset the board again. The retry
heuristic from 5f01ec31 narrowed but couldn't close the race.

A static PORT_OPEN_LOCK now covers the listener's whole device-open
sequence and each probe attempt, so exactly one prober touches the port
at a time. With this + the settle/backoff/dedup fixes, the .116 radio
completed its first MeshCore handshake in days (node-fa161601, 8
contacts) and the session has been stable since.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 08:05:42 -04:00
archipelagoandClaude Fable 5 c8d0dda656 feat(ui): Words / QR code tabs on LND seed reveal + onboarding seed
Demo images / Build & push demo images (push) Successful in 2m54s
Both seed screens get the wallet-settings segmented tab style: words
stay the default first view; the QR tab renders the space-joined seed
words for wallets that support seed import by scan. The LND reveal QR
sits behind the same tap-to-reveal blur as the words, and both panes
warn that the code is equivalent to the words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:34:19 -04:00
archipelagoandClaude Fable 5 57c6a4d512 style(ui): grey status dots for closing/force-closing channel cards
Demo images / Build & push demo images (push) Successful in 2m55s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:19:59 -04:00
archipelagoandClaude Fable 5 a32e40da31 fix(mesh): stop probing one physical radio as two devices
/dev/mesh-radio is a udev symlink to a ttyUSB*/ttyACM* node that is also
in SERIAL_CANDIDATES, so one board was detected, probed and DTR/RTS-reset
twice per reconnect cycle (and shown twice in the UI):

- detect_serial_devices() dedupes candidates by canonical path, keeping
  the stable /dev/mesh-radio name
- auto-detect fallback skips the preferred path it just probed this cycle
  instead of immediately resetting the same board again
- probe_device's active-session guard compares canonical paths, so a
  probe via the alias can no longer open the tty the live session holds

Together with the 2s boot-settle and stable-session backoff gate, this
takes a plugged-in CP2102/ESP32 board from up to 9 reset events per
cycle at a permanent 5s retry floor down to one probe sequence per
backoff window — enough for the radio to actually finish booting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:19:54 -04:00
archipelagoandClaude Fable 5 449ed7c1f7 fix(mesh): don't reset reconnect backoff for sessions that die young
Extracted from beff5dd5 on archy-hwconfig (the rest of that commit is
flash-feature code staying on its branch): a device that connects then
drops within seconds — e.g. mid-boot-loop — kept resetting backoff to
5s forever, and every retry's open() toggles DTR/RTS which itself
resets ESP32-family boards. Backoff now only resets after a session
survives STABLE_SESSION_THRESHOLD (20s), so an unstable device gets
progressively longer quiet gaps to actually finish booting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:13:27 -04:00
ssmithxandarchipelago 729cee573a 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>
(cherry picked from commit b14af20d1a)
2026-07-26 07:09:26 -04:00
ssmithxandarchipelago 5b7ebd85b6 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>
(cherry picked from commit 5799c37111)
2026-07-26 07:09:24 -04:00
archipelagoandClaude Fable 5 c6f11f8ddb feat(wallet): on-chain send fee control + BTC/sats amount entry
Demo images / Build & push demo images (push) Successful in 2m55s
- sats/BTC unit toggle on the on-chain amount field with live conversion
  hint; canonical value stays sats end-to-end
- Fast / Standard / Slow fee presets (1 / 6 / 144 block targets) plus a
  custom pane taking target blocks or an explicit sat/vB rate
- confirm pane shows LND's fee estimate for the chosen speed via the new
  lnd.estimatefee RPC (GET /v1/transactions/fee)
- lnd.sendcoins now accepts target_conf / sat_per_vbyte with the same
  mutual-exclusion + range validation as channel opens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:05:10 -04:00
archipelagoandClaude Fable 5 a26090e561 feat(ui): lightning channels All/Active/Pending/Closed tabs + closed-channel history
Demo images / Build & push demo images (push) Successful in 3m6s
Consumes the lnd.closedchannels RPC and closing/force_closing statuses
that shipped backend-side in 00b7e179 but never reached the panel:
- tab bar with per-state counts; pending covers pending_open/closing/force_closing
- closed-channel cards with close type, settled balance, block height and
  closing-tx explorer link
- closing/force_closing status dots + closing-tx link on in-flight closes
- Close button hidden for channels already mid-close

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 06:51:48 -04:00
Claude 2609f60e6c feat(release): gated ISO build pipeline
- scripts/build-iso-release.sh: single gated command from signed release
  to tested ISO (preflight version/signature parity, release gate harness,
  strict catalog drift, full Rust suite, artifact version checks, build,
  mount smoke, best-effort QEMU boot)
- scripts/iso-smoke-test.sh: standalone mount-level ISO verification incl.
  stale-binary version assertion inside the payload
- .gitea/workflows/build-iso.yml: resurrected ISO CI as dispatch-only job
  calling the gated orchestrator (old one was stranded in _archived/)
- tests/release/run.sh: catalog drift now runs --strict (was silently
  always-pass)
- test-iso-qemu.sh: headless mode used -append without -kernel, which
  QEMU rejects; use -display none so -serial file: capture works

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:34:54 -04:00
archipelagoandClaude Fable 5 c4c558954b chore: sign v1.7.113-alpha release manifest
Demo images / Build & push demo images (push) Successful in 2m59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:14:07 -04:00
archipelago d4ea4ed636 chore: release v1.7.113-alpha 2026-07-25 14:41:59 -04:00
archipelagoandClaude Fable 5 0fadbb1d0f docs: sync What's New modal for v1.7.113-alpha
Demo images / Build & push demo images (push) Failing after 48s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:29:48 -04:00
archipelagoandClaude Fable 5 0e5cd24e18 style: rustfmt on lnd channels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:25:10 -04:00
archipelagoandClaude Fable 5 1d8e57d564 docs: changelog for v1.7.113-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:12:41 -04:00
archipelagoandClaude Fable 5 00b7e1798f feat(lnd): closed-channels RPC, closing channels in list, streaming close with txid
- lnd.closedchannels: closed-channel history via /v1/channels/closed
- channel list now includes waiting-close and force-closing pending
  channels with their closing_txid
- closechannel reads the close stream's first update with a dedicated
  client instead of hanging until the closing tx confirms; returns the
  closing txid in display byte order

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:12:19 -04:00
archipelago bca1698682 Merge branch 'main' of http://146.59.87.168:3000/lfg2025/archy
Demo images / Build & push demo images (push) Successful in 3m2s
2026-07-25 10:47:41 -04:00
archipelagoandClaude Fable 5 0641ee85ef feat(wallet): total-bitcoin row at top of wallet card; on-chain gets a chain icon
The wallet card (Home + Web5 views) now leads with a Total Bitcoin row
summing all rails (on-chain + lightning + cashu + fedimint + ark), using
the orange ₿ mark. The on-chain row swaps ₿ for a link/chain icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 10:44:08 -04:00
lfg2025 901cf5713a Merge pull request #123 from fips-companion-5g-hardening
Demo images / Build & push demo images (push) Successful in 3m5s
fix(fips): harden companion mesh joins
2026-07-24 17:52:56 +00:00
Dorian 875da6c630 fix(fips): harden companion mesh joins 2026-07-24 18:47:57 +01:00
Dorian 4589e88442 chore(android): update companion apk download 2026-07-24 18:30:01 +01:00
DorianandClaude Fable 5 5862689949 perf(companion): fips fork with discovery re-fire on topology change — 0.5.15
Demo images / Build & push demo images (push) Successful in 2m56s
Pins fips to Zazawowow/fips-native fast-join-pinned (upstream pinned rev
46494a74 + one patch: pending/queued discovery lookups re-fire the
moment the tree parent lands instead of riding out timeouts started
before the mesh could answer). Measured: session lands 225ms after the
route appears; fresh-join route wait no longer stacks doomed lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:56:17 +01:00
DorianandClaude Fable 5 3995ab5cf5 perf(companion): 5s discovery timeout — fresh-join route lookups fail fast — 0.5.14
Demo images / Build & push demo images (push) Successful in 2m54s
First lookups fire before the phone's tree position settles and are
doomed; at 10s completion timeout each one cost 10s before the 1s
retry could fire (observed 19s to a route on a fresh 5G join, session
3ms after the route — discovery convergence was the whole cost).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:35:50 +01:00
DorianandClaude Fable 5 9d83ee3770 perf(companion): fast-connect mesh profile — 5G cold connect 40s+ → 5.4s — 0.5.13
Demo images / Build & push demo images (push) Successful in 2m59s
Stock fips pacing is tuned for always-on routers: a failed discovery
backs off 30s, session resends gap out to 8-16s, dead links redial at
5s→300s. On a phone that stacked into a 40s+ first connect over
cellular. Config-only tuning in build_config (no fork changes):
discovery backoff 1s (cap 30s), session resends 400ms x1.5 (10 max),
link redial 1s (cap 30s). Measured on-device: anchor +1.2s, node
session +5.4s from cold start over 5G.

Ops note: vps2 anchor daemon had degraded again (multi-second link
RTTs after 15h uptime) — restarted, recycle tightened 1d → 6h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:23:57 +01:00
DorianandClaude Fable 5 7e3d01f633 fix(companion): first connect no longer stalls on unresolvable .fips dial hints — 0.5.12
Demo images / Build & push demo images (push) Successful in 2m59s
A pairing QR minted while the node UI was browsed over the mesh carried
npub….fips as fhost. Android's system DNS can't resolve .fips, so every
direct handshake dial failed and first connect crawled through public-
anchor discovery (2m35s observed) instead of one LAN hop.

- node UI: resolveServerUrl treats .fips names and IPv6 ULA literals as
  phone-unreachable and substitutes the node's LAN IP (same guard as
  tailnet/localhost)
- app: QR parser falls back to the url param's host when fhost is .fips
- app: peer store never keeps .fips dial addresses
- app: peer aliases slugged for the fips host map (spaces dropped
  "Framework PT"/"Mesh anchor" from name resolution entirely)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:40:44 +01:00
lfg2025 385c950f00 Merge pull request 'fix(ecash): change proofs no longer ride along inside sent tokens — double-credit bug' (#122) from fix/cashu-double-credit into main 2026-07-24 12:34:56 +00:00
DorianandClaude Fable 5 98e15b3af0 fix(ecash): change proofs no longer ride along inside sent tokens — double-credit bug
send_token_at split the mint swap's results with
partition(send_denoms.contains(amount)) — membership, not count. When a
change denomination equaled a send denomination (guaranteed when
spending from a proof worth exactly 2x the amount: send [n], change
[n]), the change proofs were serialized INTO the token, and the
receiver redeemed double. Both the wallet send flow and peer-files
purchases mint through this path.

The split now consumes exactly one proof per required send denomination
(everything else returns to the wallet as change) and hard-fails if the
mint returns an incomplete denomination set, so a token can never again
be silently over- or under-packed.

Note: tokens minted BEFORE this fix already contain the extra proofs
and will still redeem at their inflated value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:34:48 +01:00
archipelagoandClaude Fable 5 df88d6d00a chore: signed manifest for v1.7.112-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:04:16 -04:00
archipelago 0dfc3a7cfb Merge remote-tracking branch 'gitea-ai/main'
Demo images / Build & push demo images (push) Successful in 2m51s
2026-07-24 06:35:29 -04:00
archipelagoandClaude Fable 5 b163d30a0e feat(wallet): send success screen — burst animation + copyable payment hash / txid
A settled payment now takes over the modal: animated check with radiating
rings (emerald + one Archipelago-orange), the amount large, method label,
then copyable payment hash (lightning) / transaction ID (on-chain) and a
settling note, with Send-another / Done. Replaces the old alert-line at the
bottom of the form. Reduced-motion honored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 06:34:56 -04:00
lfg2025 85b25dd803 Merge pull request 'fix(companion): touch-shield under the in-app control bar — 0.5.11' (#121) from fix/inapp-bar-touch-shield into main
Demo images / Build & push demo images (push) Successful in 2m54s
2026-07-24 10:32:06 +00:00
DorianandClaude Fable 5 e3db5558e4 fix(companion): touch-shield under the in-app control bar — 0.5.11
The gesture-nav strip below the in-app browser's control bar was painted
black by inset padding, but painted-only areas don't consume touches in
Compose — stray taps fell straight THROUGH the overlay into the kiosk's
tab bar behind it (accidentally opening the AIUI chat tab). The strip is
now an explicit Box: solid black AND click-swallowing. Bottom inset
moved off the Column padding so the shield can own that region.

Served APK: 0.5.11 (vc31).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:31:55 +01:00
lfg2025 91c51c4d97 Merge pull request 'perf(ui): app-launch-speed doctrine for data panels — parallel RPCs + instant last-known paint' (#120) from perf/data-panels into main
Demo images / Build & push demo images (push) Successful in 2m51s
2026-07-24 10:22:53 +00:00
DorianandClaude Fable 5 4343949005 perf(ui): app-launch-speed doctrine for data panels — parallel RPCs + instant last-known paint
Why apps feel instant over the mesh and data panels didn't: an app
launch is ONE streamed HTTP fetch on a warm session; the panels were
built from SERIALIZED RPC round-trips, each costing a full mesh RTT.

- Wallet card (Home): the 7 balance/history RPCs fired one-by-one
  (seconds by construction) — now all 7 in parallel, and the card paints
  a persisted last-known snapshot (balances + recent transactions)
  BEFORE any network round-trip. Refresh replaces it silently.
- Web5 connected nodes: listPeers + federationListNodes fetched
  together instead of stacked.
- Peer files: the cosmetic peer-name lookup no longer blocks the
  catalog/owned fetches it never depended on.

Rules going forward: never serialize independent RPCs; never show a
spinner where last-known data exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:22:46 +01:00
archipelago 5771f318bd chore: release v1.7.112-alpha 2026-07-24 05:36:45 -04:00
archipelagoandClaude Fable 5 408c605001 test: LightningChannels mounts with Pinia — panel setup now uses the tx-explorer store
Demo images / Build & push demo images (push) Successful in 3m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:42:52 -04:00
archipelagoandClaude Fable 5 0cd2164d24 style: cargo fmt across today's touched modules
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:35:12 -04:00
archipelago 5227406341 Merge remote-tracking branch 'gitea-ai/main' 2026-07-24 04:29:16 -04:00
archipelagoandClaude Fable 5 d924c59c6c style: cargo fmt on mesh_ports
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:29:15 -04:00
lfg2025 0fa2a866f5 Merge pull request 'perf(apps): launch never waits on the credentials RPC — 1.2s budget + memo' (#119) from fix/apps-launch-latency into main
Demo images / Build & push demo images (push) Successful in 2m52s
2026-07-24 08:14:47 +00:00
DorianandClaude Fable 5 9fcb68816b perf(apps): launch never waits on the credentials RPC — 1.2s budget + memo
Apps-tab launches awaited package.credentials with a 5s timeout before
opening anything (home-card launches skip the gate — that's why they
always felt instant). The lookup now races a 1.2s budget: past it, the
app launches with the static fallback config while the RPC finishes
into a per-app memo, making every subsequent launch instant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:14:40 +01:00
lfg2025 9a66f22138 Merge pull request 'fix(wallet+companion): pre-demo blockers — Send modal TDZ crash, cold-start inset collapse, loader branding — 0.5.10' (#118) from fix/safe-area-insets into main
Demo images / Build & push demo images (push) Successful in 2m47s
2026-07-24 08:05:30 +00:00
DorianandClaude Fable 5 504944fd08 fix(wallet+companion): pre-demo blockers — Send modal TDZ crash, cold-start inset collapse, loader branding — 0.5.10
- SendBitcoinModal crashed AT MOUNT on device ('Cannot access R before
  initialization'): the invoice-first block's watch() evaluates its
  computed source at setup, which read effectiveMethod while still in
  its temporal dead zone (declared 20 lines later). Send button did
  nothing and the failure poisoned sibling modal data. Block moved below
  effectiveMethod, with a comment so it can't drift back.
- Cold start injected 0px safe-area vars (onPageFinished can beat window
  attachment; rootWindowInsets null) — UI lost top/bottom margins and
  the tab bar sat in the gesture zone eating taps. Injection extracted
  to injectSafeAreaVars(), re-fired from an OnApplyWindowInsetsListener
  when real insets arrive, on page finish, and on reattach.
- MeshLoadingScreen wears the proper brand: pixel 'a' in the black
  circle-container + F*CK IPS MESH wordmark.

Served APK: 0.5.10 (vc30). Reproduced+diagnosed live on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:03:43 +01:00
archipelagoandClaude Fable 5 8af2ca4ac2 docs: complete the v1.7.112-alpha changelog — audio/TV-input/companion-mesh/paid-sharing batch + What's New re-sync
Demo images / Build & push demo images (push) Successful in 2m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:18:37 -04:00
lfg2025 f2b6028ca2 Merge pull request 'feat(companion): night-test polish sweep — 0.5.9' (#117) from feat/final-polish into main
Demo images / Build & push demo images (push) Successful in 2m45s
2026-07-24 01:04:13 +00:00
DorianandClaude Fable 5 e679b4e886 feat(companion): night-test polish sweep — 0.5.9
Everything from tonight's remote field testing:

- Back-to-dashboard fixed: JS bridges on the retained WebView delegated
  through live-composition callbacks (stale closures made apps refuse to
  launch after remote ⇄ dashboard), and reattach forces a layout pass
  (top/bottom UI was wrong until a tap).
- F*CK IPs MESH branded loader: full-screen on relaunch (startup race)
  and during the first connect after scanning a node QR.
- Party 'Share this app' is now a QR of the public vps2 download link
  (scan with any camera → install over any internet); direct APK
  file-share kept as a secondary action.
- Mesh party pairing is MUTUAL: scanner announces itself to the scanned
  phone's Flare /hello — both sides get the peer, a chat entry and a
  '👋 joined the party' message; scanner auto-opens the chat.
- Party scanner asks for CAMERA permission (fresh installs/reinstalls
  landed on a black preview).
- Node: device tokens mint unique companion-<id> names — pairing a
  second phone no longer revokes the first phone's login (the source of
  'reconnecting a lot' and the second phone's failure).

Served APK: 0.5.9 (vc29).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 02:03:47 +01:00
lfg2025 d045f0b499 Merge pull request 'feat(companion): party app-sharing, intro Mesh Party button, kiosk session retention — 0.5.8' (#116) from feat/party-share-intro into main
Demo images / Build & push demo images (push) Successful in 2m44s
2026-07-24 00:20:47 +00:00
DorianandClaude Fable 5 1e20b79c3c feat(companion): kiosk session survives remote ⇄ dashboard navigation
The WEB_VIEW route recreated the WebView on every return from the
remote screen — full reload + re-login every time. The kiosk WebView is
now retained in a holder and reattached live (no race, no reload);
clients/bridges/listeners are re-bound to the new composition on
reattach, and the instance is dropped on retry, disconnect, or server
change so errored/stale sessions still reload for real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:19:57 +01:00
DorianandClaude Fable 5 1bcf6ccadb feat(companion): share-the-app from Mesh Party + party entry on the intro screen — 0.5.8
- Party screen 'Share this app': shares this install's own APK through
  the system sheet (FileProvider over cache/share/), so a nearby friend
  gets the companion via Quick Share/Bluetooth with zero internet — the
  party-mode premise end to end.
- Intro screen: Mesh Party button under Get Started — party works with
  no node at all, so it belongs on the first screen a fresh install
  shows.
- Served APK: 0.5.8 (vc28), ready for the QR/OTA/ISO cut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:16:54 +01:00
lfg2025 9f6294d169 Merge pull request 'feat(companion): mesh party + Flare, keep-warm mesh, restored fast-start, UI fixes — 0.5.7' (#115) from feat/mesh-party-flare-perf into main
Demo images / Build & push demo images (push) Successful in 2m50s
2026-07-24 00:08:13 +00:00
Dorian a0f688b522 Merge main into mesh-party branch (dev-box app-ports work)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	Android/app/build.gradle.kts
#	Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
#	neode-ui/public/packages/archipelago-companion.apk
2026-07-24 01:08:07 +01:00
DorianandClaude Fable 5 f0926ece94 feat(companion): mesh party + Flare, keep-warm mesh, restored fast-start, UI fixes — 0.5.7
Combined session state from both agents (shared checkout):

- Mesh Party / Flare (experimental): phone-to-phone FIPS via party QR,
  Flare chat/beam on :5680, optional fixed UDP listen (2121); party-only
  phones get the baked-in Archipelago anchor so two bare phones reach
  each other across the internet, not just on a hotspot.
- Keep-warm mesh: a running healthy node survives app opens (restart
  only when dead or peers changed — peersDirty); requestMeshRestart for
  config changes. Cold start measured on-device: session in ~3.1s.
- Restored after regression: startup LAN-vs-mesh race (the party work
  was based on a pre-#114 file copy) and same-node routing (BTCPay/
  Pine/HA had returned to opening in the external browser).
- UI: top status-bar scrim removed (bottom-only bars); web app-session
  mobile bar is solid black — app theme colour no longer bleeds through
  the bar and its safe-area strip.

Served APK: 0.5.7 (vc27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:04:36 +01:00
114 changed files with 5831 additions and 2768 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Build Archipelago release ISO (gated)
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
# the iso-builder runner is a live node — builds are deliberate events.
on:
workflow_dispatch:
jobs:
build-iso:
runs-on: iso-builder
timeout-minutes: 180
steps:
- name: Sync source to workspace
run: |
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
echo "=== Source at commit: $(git log --oneline -1) ==="
- name: Install ISO build dependencies
run: |
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
echo "ISO build deps already installed, skipping apt"
else
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common
fi
- name: Build backend + frontend if stale
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
cargo build --release --manifest-path core/Cargo.toml -p archipelago
fi
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
(cd neode-ui && npm ci && npm run build)
fi
- name: Gated ISO build (gates + build + smoke + qemu)
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
bash scripts/build-iso-release.sh
- name: Report artifacts
if: always()
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
+13 -6
View File
@@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
**aborts** if any is missing.
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
6. The first-launch companion modal and Android "Share this app" QR point at
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
repo artifact is built, mirror that exact APK to the VPS2-served path before
calling the release done.
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
skips the clean build and the signature enforcement and is exactly how a broken
@@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
## Verify the published download after shipping
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
what you built and signed:
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
built and signed:
```bash
SERVED=neode-ui/public/packages/archipelago-companion.apk
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
curl -sS -o /tmp/live.apk "$URL"
shasum -a 256 "$SERVED" /tmp/live.apk # must match
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
curl -sS -o /tmp/live-qr.apk "$QR_URL"
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
```
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 26
versionName = "0.5.6"
versionCode = 45
versionName = "0.5.25"
vectorDrawables {
useSupportLibrary = true
+12
View File
@@ -23,6 +23,18 @@
android:usesCleartextTraffic="true"
tools:targetApi="35">
<!-- Party-screen "Share this app": exposes the copied APK from
cache/share/ to the system share sheet, nothing else. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -88,8 +88,18 @@ object ServerQrParser {
private fun parseFips(uri: Uri): FipsPairInfo? {
val npub = uri.getQueryParameter("fnpub")?.trim()
val host = uri.getQueryParameter("fhost")?.trim()
var host = uri.getQueryParameter("fhost")?.trim()
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
// A .fips fhost is a dead dial hint: Android's system DNS can't
// resolve .fips, so every handshake send fails and first connect
// falls back to slow anchor discovery. If the QR's `url` host is a
// real address, dial that instead (QRs minted while the node UI was
// browsed over the mesh carry npub….fips here).
if (host.endsWith(".fips")) {
val urlHost = uri.getQueryParameter("url")
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
}
return FipsPairInfo(
npub = npub,
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
@@ -5,6 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.VpnService
import android.os.Build
import android.util.Log
@@ -34,6 +38,20 @@ class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
// tunnel's underlying network stays pinned to the interface that was
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
// mesh sockets ride a dead network and sessions never recover until the
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
// the tunnel to the new default network via setUnderlyingNetworks and
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
// on the new path within seconds instead of waiting out dead-link
// timeouts.
private var connectivityManager: ConnectivityManager? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
@Volatile
private var currentUnderlying: Network? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
@@ -51,13 +69,27 @@ class ArchyVpnService : VpnService() {
}
val prefs = FipsPreferences(this)
val identity = FipsManager.ensureIdentity(prefs)
val peersJson = prefs.peersJson()
val peersJson = prefs.combinedPeersJson()
if (identity == null || peersJson == "[]") {
Log.w(TAG, "mesh not configured — stopping")
shutdown()
return
}
// Party mode: fixed inbound UDP bind so a nearby phone can dial us
// directly over a shared LAN/hotspot (no internet required).
val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0
// A fresh app open re-triggers the service. Tearing a HEALTHY mesh
// down to rebuild it costs ~8s of anchor+session bring-up on every
// launch (observed live: stop 00:34:38 → session back 00:34:49) and
// is what made "freshly loading the app" slow. Keep a running node;
// restart only when it's dead or a pairing changed the peer set.
if (FipsNative.isRunning() && !FipsManager.peersDirty) {
Log.i(TAG, "mesh already running — keeping warm sessions")
startSessionWarmer()
return
}
FipsManager.peersDirty = false
// Re-establishing while running would strand the old fd; restart clean.
if (FipsNative.isRunning()) FipsNative.stop()
@@ -93,12 +125,24 @@ class ArchyVpnService : VpnService() {
}
val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd)
Log.i(TAG, "mesh start: $result")
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
if (result.contains("\"error\"")) {
shutdown()
} else {
startSessionWarmer()
registerNetworkHandoff()
// Phone-to-phone chat/beam + the phone's own mesh-served page.
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
// Mutual pairing: when a phone that scanned OUR QR announces
// itself, store it as a party peer and re-run the mesh config so
// this side gets the peer + chat entry without scanning back.
FlareServer.onHello = { peer ->
scope.launch {
prefs.upsertPartyPeer(peer)
FipsManager.requestMeshRestart(this@ArchyVpnService)
}
}
}
}
@@ -116,26 +160,40 @@ class ArchyVpnService : VpnService() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
val ulas = try {
prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct()
val targets = try {
prefs.savedServers.first()
.mapNotNull { it.meshIp.ifBlank { null } }
.map { it to 80 } +
// Party phones answer on the flare port, not :80.
fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
} 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,
)
}.distinct()
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
// Probe all targets CONCURRENTLY with a short timeout — the
// old sequential 20s-per-target loop let one cold node starve
// every other target for the whole aggressive window.
targets.map { (ula, port) ->
launch {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(
java.net.InetAddress.getByName(ula),
port,
),
5_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the attempt still drove
// session establishment; try again next round.
}
} catch (_: Exception) {
// Cold path / node away — the connect attempt still
// drove session establishment; try again next round.
}
}
}.forEach { it.join() }
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
@@ -144,14 +202,83 @@ class ArchyVpnService : VpnService() {
}
}
/**
* Track the phone's default network and hand the mesh over to it as the
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
* network instead of dying on the one it launched with.
* 2. re-home the mesh — kick the session warmer so discovery + sessions
* rebuild on the new path immediately; the node's own fast-reconnect
* (1s) redials peers over the new route.
* onAvailable also fires for the FIRST network, which is how the initial
* underlying network gets set.
*/
private fun registerNetworkHandoff() {
if (networkCallback != null) return
val cm = getSystemService(ConnectivityManager::class.java) ?: return
connectivityManager = cm
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handoffTo(network)
}
override fun onLost(network: Network) {
// The lost network was our underlying one — clear the pin so
// the system falls back to whatever default remains; the next
// onAvailable re-pins explicitly.
if (network == currentUnderlying) {
currentUnderlying = null
runCatching { setUnderlyingNetworks(null) }
}
}
}
networkCallback = cb
// requestNetwork tracks the BEST network of the request; when the
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
// one. (registerDefaultNetworkCallback would also work; requestNetwork
// lets us extend to BLE-capable transports later.)
runCatching { cm.requestNetwork(request, cb) }
}
private fun handoffTo(network: Network) {
val changed = network != currentUnderlying
currentUnderlying = network
// Always re-assert; cheap and covers capability changes on the same
// Network object.
runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (changed && FipsNative.isRunning()) {
Log.i(TAG, "network handoff → re-homing mesh on new default network")
// Fresh warmer pass drives immediate rediscovery/session rebuild
// on the new path instead of waiting out dead-link timeouts.
startSessionWarmer()
}
}
private fun unregisterNetworkHandoff() {
val cm = connectivityManager
val cb = networkCallback
if (cm != null && cb != null) {
runCatching { cm.unregisterNetworkCallback(cb) }
}
networkCallback = null
connectivityManager = null
currentUnderlying = null
}
private fun shutdown() {
warmerJob?.cancel()
unregisterNetworkHandoff()
FlareServer.stop()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
unregisterNetworkHandoff()
FipsNative.stop()
scope.cancel()
super.onDestroy()
@@ -21,6 +21,12 @@ object FipsManager {
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow<Boolean> = _consentNeeded
/** True after a pairing changed the peer set while the node was running —
* tells the service a restart is genuinely needed (the ONLY case; a
* routine app open must keep the warm mesh, not rebuild it). */
@Volatile
var peersDirty: Boolean = false
fun consentHandled() {
_consentNeeded.value = false
}
@@ -34,7 +40,16 @@ object FipsManager {
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
_consentNeeded.value = true
peersDirty = true
// Restart the mesh with the new peer RIGHT NOW when consent already
// exists — relying on the consentNeeded collector left a running
// mesh on the OLD peer list whenever the collector wasn't active
// (fresh pairings looked dead until a full app restart).
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
@@ -64,6 +79,22 @@ object FipsManager {
context.startForegroundService(intent)
}
/**
* Re-run the mesh with current prefs (party listen toggled, peer added).
* Marks the peer set dirty so startMesh genuinely restarts the node —
* otherwise the keep-warm fast path would skip the new config.
* First-timers go through the consent flow.
*/
fun requestMeshRestart(context: Context) {
if (!FipsNative.available) return
peersDirty = true
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)
@@ -21,7 +21,13 @@ object FipsNative {
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
external fun start(secret: String, peersJson: String, tunFd: Int): String
/**
* [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
* that port so a nearby phone can dial us directly (party mode); the node
* stays leaf-only either way.
*/
external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String
@@ -3,9 +3,12 @@ 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.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
@@ -22,6 +25,26 @@ internal const val ARCHY_ANCHOR_NPUB =
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
/**
* Public FIPS network anchors (join.fips.network — the dual-transport TCP
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
* fips_network_anchors()). Baked into every pairing at trailing priority so
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
* still joins the public tree and routes to the node through it.
*/
internal val PUBLIC_FIPS_ANCHORS = listOf(
Triple(
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
"23.182.128.74:443",
"tcp",
),
Triple(
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
"217.77.8.91:443",
"tcp",
),
)
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
@@ -34,6 +57,12 @@ class FipsPreferences(private val context: Context) {
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
/** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
private val partyPeersKey = stringPreferencesKey("fips_party_peers")
/** Party mode: accept a direct inbound mesh link (UDP 2121). */
private val partyListenKey = booleanPreferencesKey("fips_party_listen")
/** Name shown in this phone's party QR and outgoing flares. */
private val partyNameKey = stringPreferencesKey("fips_party_name")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
@@ -60,6 +89,157 @@ class FipsPreferences(private val context: Context) {
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
suspend fun partyListen(): Boolean =
context.fipsDataStore.data.first()[partyListenKey] ?: false
val partyListenFlow: Flow<Boolean>
get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
suspend fun setPartyListen(enabled: Boolean) {
context.fipsDataStore.edit { it[partyListenKey] = enabled }
}
suspend fun partyName(): String =
context.fipsDataStore.data.first()[partyNameKey]
?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
suspend fun setPartyName(name: String) {
context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
}
val partyPeersFlow: Flow<List<PartyPeer>>
get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
suspend fun partyPeers(): List<PartyPeer> =
parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
/** Matched by npub, so re-scanning updates the direct-dial address in place. */
suspend fun upsertPartyPeer(peer: PartyPeer) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
prefs[partyPeersKey] = toJson(kept + peer)
}
}
suspend fun removePartyPeer(npub: String) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
prefs[partyPeersKey] = toJson(kept)
}
}
/**
* Node peers + direct-dial party peers, in the fips PeerConfig JSON the
* Rust node deserializes. Party peers get the best priority: on a shared
* LAN/hotspot the direct link beats every anchor path, and while off-LAN
* the failed dial is cheap (auto-reconnect keeps retrying, which is
* exactly what makes the link snap up the moment both phones share WiFi).
* Party peers without an underlay address are mesh-routed and need no
* entry here at all.
*/
suspend fun combinedPeersJson(): String {
val merged = JSONArray(peersJson())
val party = partyPeers()
for (peer in party) {
if (peer.ip.isBlank() || peer.port <= 0) continue
merged.put(JSONObject().apply {
put("npub", peer.npub)
put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", "udp")
put("addr", "${peer.ip}:${peer.port}")
put("priority", 5)
}))
})
}
// A party-only phone (never paired with a node) still needs a public
// rendezvous to reach its peers ACROSS the internet — without it, two
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
// this anchor in; do the same when there are party peers.
if (party.isNotEmpty() &&
(0 until merged.length()).none {
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
}
) {
merged.put(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)
}))
})
}
// Backfill anchor peers for entries paired before newer QR/app
// releases added them. `upsertNodePeer` persists these on re-scan, but
// startup must also self-heal old DataStore state so updating the APK is
// enough to get off-LAN redundancy.
if (merged.length() > 0) {
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
val (npub, addr, transport) = anchor
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
}
}
return merged.toString()
}
private fun addAnchorIfMissing(
peers: JSONArray,
npub: String,
alias: String,
addr: String,
transport: String,
priority: Int,
) {
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
peers.put(JSONObject().apply {
put("npub", npub)
put("alias", alias)
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", transport)
put("addr", addr)
put("priority", priority)
}))
})
}
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val npub = o.optString("npub")
val ula = o.optString("ula")
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
PartyPeer(
npub = npub,
ula = ula,
name = o.optString("name").ifBlank { "Phone" },
ip = o.optString("ip"),
port = o.optInt("port"),
)
}
} catch (_: Exception) {
emptyList()
}
private fun toJson(peers: List<PartyPeer>): String {
val arr = JSONArray()
for (p in peers) {
arr.put(JSONObject().apply {
put("npub", p.npub)
put("ula", p.ula)
put("name", p.name)
put("ip", p.ip)
put("port", p.port)
})
}
return arr.toString()
}
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
@@ -71,16 +251,19 @@ class FipsPreferences(private val context: Context) {
val incoming = mutableListOf<JSONObject>()
incoming += JSONObject().apply {
put("npub", info.npub)
put("alias", alias.ifBlank { "Archipelago" })
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
val addresses = JSONArray()
if (info.udpPort > 0) {
// .fips hosts are unresolvable on Android (no system .fips DNS):
// storing one gives the mesh a dial target that fails every
// handshake and stalls first connect on anchor discovery.
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "udp")
put("addr", "${info.host}:${info.udpPort}")
put("priority", 10)
})
}
if (info.tcpPort > 0) {
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "tcp")
put("addr", "${info.host}:${info.tcpPort}")
@@ -91,9 +274,10 @@ class FipsPreferences(private val context: Context) {
}
for (anchor in info.anchors) {
if (anchor.npub == info.npub) continue
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
incoming += JSONObject().apply {
put("npub", anchor.npub)
put("alias", "Mesh anchor")
put("alias", "mesh-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", anchor.transport)
put("addr", anchor.addr)
@@ -108,7 +292,7 @@ class FipsPreferences(private val context: Context) {
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "Archipelago anchor")
put("alias", "archipelago-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
@@ -116,6 +300,21 @@ class FipsPreferences(private val context: Context) {
}))
}
}
// And the public FIPS network anchors at trailing priority, so one
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
val (npub, addr, transport) = anchor
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
incoming += JSONObject().apply {
put("npub", npub)
put("alias", "fips-network-anchor-${i + 1}")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", transport)
put("addr", addr)
put("priority", 50 + i * 10)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
@@ -129,3 +328,14 @@ class FipsPreferences(private val context: Context) {
}
}
}
/**
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
* that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
* silently drops the peer from name resolution. Slug it instead of losing it.
*/
internal fun hostSafeAlias(alias: String): String =
alias.lowercase()
.replace(Regex("[^a-z0-9.-]+"), "-")
.trim('-', '.')
.ifBlank { "archipelago" }
@@ -0,0 +1,398 @@
package com.archipelago.app.fips
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** One chat/photo message in a party conversation, keyed by the peer's npub. */
data class FlareMessage(
val id: String,
val peerNpub: String,
val fromMe: Boolean,
val name: String,
val text: String = "",
val photoPath: String = "",
val ts: Long,
val status: Status = Status.RECEIVED,
) {
enum class Status { SENDING, SENT, FAILED, RECEIVED }
}
/** In-memory conversation store (demo scope — nothing persists across restarts). */
object FlareStore {
private val _messages = MutableStateFlow<List<FlareMessage>>(emptyList())
val messages: StateFlow<List<FlareMessage>> = _messages
fun add(message: FlareMessage) {
_messages.value = _messages.value + message
}
fun setStatus(id: String, status: FlareMessage.Status) {
_messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
}
}
/**
* Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is
* fine there because FIPS is the encryption + peer-identity layer (same
* stance as the node's ULA-only peer listener). This is what makes the phone
* a *server* on the mesh: another phone (or `curl -6` from any mesh node)
* reaches it by npub-derived address with no port forwarding, DNS, or CA.
*
* FIPS authenticates the node, not the request (project doctrine), so inputs
* are still validated at this boundary: size caps, JSON shape, no
* client-controlled paths.
*/
object FlareServer {
private const val TAG = "FlareServer"
private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
private const val MAX_TEXT_CHARS = 4_000
private const val MAX_HEADER_BYTES = 16 * 1024
private var socket: ServerSocket? = null
private var pool: ExecutorService? = null
@Volatile private var identityName = "Phone"
@Volatile private var identityNpub = ""
@Volatile private var photoDir: File? = null
/** Invoked when a peer announces itself (POST /hello) — pairing used to
* be one-way: only the SCANNING phone learned the other side, so the
* scanned phone had no peer, no chat entry, no way in. The VPN service
* wires this to upsert the peer + restart the mesh config. */
@Volatile var onHello: ((PartyPeer) -> Unit)? = null
@Synchronized
fun start(context: Context, ula: String, myNpub: String, myName: String) {
stop()
identityNpub = myNpub
identityName = myName
photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
val pool = Executors.newCachedThreadPool().also { this.pool = it }
pool.execute {
try {
val server = ServerSocket().apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
}
socket = server
Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
while (!server.isClosed) {
val client = try {
server.accept()
} catch (_: Exception) {
break
}
pool.execute { handle(client) }
}
} catch (e: Exception) {
Log.e(TAG, "flare server died: $e")
}
}
}
@Synchronized
fun stop() {
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
pool?.shutdownNow()
pool = null
}
private fun handle(client: Socket) {
client.use { sock ->
sock.soTimeout = 30_000
try {
val input = BufferedInputStream(sock.getInputStream())
val requestLine = readLine(input) ?: return
val parts = requestLine.trim().split(" ")
if (parts.size < 2) return respond(sock, 400, json("bad_request"))
val (method, path) = parts[0] to parts[1]
var contentLength = 0
var from = ""
var fromName = ""
var headerBytes = requestLine.length
while (true) {
val line = readLine(input) ?: return
if (line.isEmpty()) break
headerBytes += line.length
if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
val idx = line.indexOf(':')
if (idx <= 0) continue
val key = line.substring(0, idx).trim().lowercase()
val value = line.substring(idx + 1).trim()
when (key) {
"content-length" -> contentLength = value.toIntOrNull() ?: 0
"x-from" -> from = value.take(80)
"x-name" -> fromName = value.take(80)
}
}
when {
method == "GET" && (path == "/" || path.startsWith("/?")) ->
respondHtml(sock, profilePage())
method == "POST" && path == "/hello" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveHello(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/flare" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveFlare(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/photo" -> {
if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
val body = readExactly(input, contentLength) ?: return
receivePhoto(from, fromName, body)
respond(sock, 200, """{"ok":true}""")
}
else -> respond(sock, 404, json("not_found"))
}
} catch (e: Exception) {
Log.w(TAG, "request failed: $e")
}
}
}
/** A peer that scanned OUR QR announces itself — mutual pairing. */
private fun receiveHello(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
val ula = o.optString("ula")
if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
if (from == identityNpub) return
val peer = PartyPeer(
npub = from.take(80),
ula = ula.take(64),
name = o.optString("name").take(24).ifBlank { "Phone" },
ip = o.optString("ip").take(40),
port = o.optInt("port", 0),
)
onHello?.invoke(peer)
// Seed the conversation so the chat has a visible entry on this side.
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = peer.npub,
fromMe = false,
name = peer.name,
text = "👋 ${peer.name} joined the party",
ts = System.currentTimeMillis(),
)
)
}
private fun receiveFlare(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
if (!from.startsWith("npub1")) return
val text = o.optString("text").take(MAX_TEXT_CHARS)
if (text.isBlank()) return
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = o.optString("name").take(80).ifBlank { "Phone" },
text = text,
ts = System.currentTimeMillis(),
)
)
}
private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
// Server-generated filename — the sender never controls the path.
val dir = photoDir ?: return
val file = File(dir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes)
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = fromName.ifBlank { "Phone" },
photoPath = file.absolutePath,
ts = System.currentTimeMillis(),
)
)
}
private fun profilePage(): String {
val npub = identityNpub
val name = identityName
return """
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>$name — on the mesh</title>
<style>
body{background:#0a0a0a;color:#eee;font-family:monospace;
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
.card{border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:32px;
max-width:560px;background:rgba(255,255,255,.04)}
h1{color:#f7931a;margin:0 0 8px;font-size:22px}
.npub{word-break:break-all;color:#888;font-size:12px;margin:12px 0}
p{line-height:1.5}
</style></head><body><div class="card">
<h1>⚡ $name</h1>
<div class="npub">$npub</div>
<p>This page is being served <b>by a phone</b>, addressed by its
cryptographic identity over the FIPS mesh.</p>
<p>No port forwarding. No DNS. No certificate authority. No cloud.
The key <i>is</i> the address — and the transport underneath can be
5G, WiFi, or a hotspot with no internet at all.</p>
</div></body></html>
""".trimIndent()
}
// ── tiny HTTP plumbing ──────────────────────────────────────────────────
/** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
private fun readLine(input: InputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\n'.code) return sb.toString().trimEnd('\r')
sb.append(b.toChar())
if (sb.length > MAX_HEADER_BYTES) return null
}
}
private fun readExactly(input: InputStream, length: Int): ByteArray? {
val buf = ByteArray(length)
var off = 0
while (off < length) {
val n = input.read(buf, off, length - off)
if (n == -1) return null
off += n
}
return buf
}
private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
private fun respond(sock: Socket, status: Int, body: String) =
writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
private fun respondHtml(sock: Socket, body: String) =
writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
val reason = when (status) {
200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
413 -> "Payload Too Large"; 431 -> "Headers Too Large"
else -> "Error"
}
val head = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: $contentType\r\n" +
"Content-Length: ${body.size}\r\n" +
"Connection: close\r\n\r\n"
sock.getOutputStream().apply {
write(head.toByteArray(Charsets.ISO_8859_1))
write(body)
flush()
}
}
}
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
object FlareClient {
// Connect timeout must outlive cold mesh-session establishment (~15s via
// the public tree per HANDOFF-2026-07-23); the attempt itself drives
// session setup, same trick as the VPN service's session warmer.
private val http = OkHttpClient.Builder()
.connectTimeout(25, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
/** Announce myself to a freshly scanned peer so pairing becomes MUTUAL —
* their phone gets me as a peer + a chat entry without scanning back.
* Blocking — call from Dispatchers.IO. */
fun sendHello(
peer: PartyPeer,
myNpub: String,
myName: String,
myUla: String,
myIp: String?,
myPort: Int,
): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("ula", myUla)
.put("ip", myIp ?: "")
.put("port", if (myIp != null) myPort else 0)
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/hello").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("text", text)
.put("ts", System.currentTimeMillis())
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/flare").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
http.newCall(
Request.Builder()
.url("${base(peer)}/photo")
.header("X-From", myNpub)
.header("X-Name", myName)
.post(jpeg.toRequestBody("image/jpeg".toMediaType()))
.build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
}
@@ -0,0 +1,102 @@
package com.archipelago.app.fips
import android.net.Uri
import java.net.Inet4Address
import java.net.NetworkInterface
/**
* Phone↔phone mesh pairing ("Mesh Party") QR contract:
*
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
*
* npub + ula alone are enough to chat *through* the mesh (anchors route by
* node address, no underlay info needed). ip/port are present only while the
* showing phone has its inbound UDP listener up (party mode) — the scanner
* then also gets a direct-dial link that works on a shared LAN/hotspot with
* no internet at all. Same versioning stance as the node pairing QR
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
*/
data class PartyPeer(
val npub: String,
val ula: String,
val name: String,
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
val ip: String = "",
val port: Int = 0,
)
object PartyQr {
const val SCHEME_HOST = "party"
private const val SUPPORTED_MAJOR = 1
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
const val PARTY_UDP_PORT = 2121
/** Application-layer chat/beam port, bound only on the mesh ULA. */
const val FLARE_PORT = 5680
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
val b = Uri.Builder()
.scheme("archipelago")
.authority(SCHEME_HOST)
.appendQueryParameter("v", "1")
.appendQueryParameter("npub", npub)
.appendQueryParameter("ula", ula)
.appendQueryParameter("name", name)
if (!ip.isNullOrBlank() && port > 0) {
b.appendQueryParameter("ip", ip)
b.appendQueryParameter("port", port.toString())
}
return b.build().toString()
}
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
fun parse(raw: String): PartyPeer? {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return null
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
if (major != SUPPORTED_MAJOR) return null
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
return PartyPeer(
npub = npub,
ula = ula,
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
)
}
/**
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
* the hotspot-host phone advertises the address its guests can reach.
*/
fun localWifiIpv4(): String? {
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
try {
for (nif in NetworkInterface.getNetworkInterfaces()) {
if (!nif.isUp || nif.isLoopback) continue
for (addr in nif.inetAddresses) {
if (addr is Inet4Address && addr.isSiteLocalAddress) {
candidates += nif.name to addr.hostAddress.orEmpty()
}
}
}
} catch (_: Exception) {
return null
}
val hotspot = candidates.firstOrNull {
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
}
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
?.second
}
}
@@ -0,0 +1,77 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.screens.PixelArtLogo
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
/**
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
* dialing the node over the mesh (relaunch race, post-scan first connect),
* instead of an anonymous spinner. The point of the brand: what's loading
* is a connection to a cryptographic identity, not an IP.
*/
@Composable
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
Box(
Modifier
.fillMaxSize()
.background(SurfaceBlack),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// The brand's circle-container logo (as on the connect screen /
// web login): pixel-art "a" centered in a black disc.
Box(
Modifier
.size(120.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color.Black)
.border(
1.dp,
Color.White.copy(alpha = 0.14f),
androidx.compose.foundation.shape.CircleShape,
),
contentAlignment = Alignment.Center,
) {
PixelArtLogo(Modifier.size(64.dp))
}
Spacer(Modifier.height(20.dp))
Text(
text = "F*CK IPs MESH",
color = BitcoinOrange,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 4.sp,
)
Spacer(Modifier.height(8.dp))
Text(
text = message,
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -108,6 +109,7 @@ fun NESController(
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
onToggleStyle: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
@@ -205,6 +207,7 @@ fun NESController(
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
}
}
}
@@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
}
}
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
* no longer carries it). */
@Composable
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
@@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@@ -75,26 +94,29 @@ fun NESMenu(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
// Contained hub overlay: a centred glass panel (not full-screen) that
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
// inside its own bounds when content is tall. Tapping the dimmed
// backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
@@ -104,17 +126,16 @@ fun NESMenu(
private fun MenuPanel(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
@@ -122,14 +143,15 @@ private fun MenuPanel(
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
var https by remember { mutableStateOf(false) }
fun resetForm() {
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
nm = server.name; addr = server.address; pwd = server.password
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
showAdd = false
}
@@ -137,156 +159,381 @@ private fun MenuPanel(
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
// Preserve fields the compact form doesn't expose (scheme, port).
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
// Preserve port (compact form doesn't expose it); scheme is now editable.
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
} else {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
}
resetForm()
}
var page by remember { mutableStateOf(HubPage.HUB) }
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp)
// Cap height just short of the full screen; the panel wraps short
// content and only scrolls in the rare case it outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg)
.background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Title
Text(
"Menu",
color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add / edit server
if (showAdd || editing != null) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Header: back (on sub-pages) or title, and a close on the hub.
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (page == HubPage.HUB) {
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
IconRound(Icons.Default.Close, "Close") { onDismiss() }
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
Spacer(Modifier.width(12.dp))
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
fontWeight = FontWeight.Medium,
)
Text(
"Cancel",
color = TextMuted,
fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
IconRound(Icons.Default.Close, "Close") { onDismiss() }
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
Spacer(Modifier.height(4.dp))
when (page) {
HubPage.HUB -> {
// Card page — one card per destination. Dashboard first: it's a
// peer of the others so three-finger → hub → Dashboard returns
// to the node UI, same shape as every other option.
if (onBackToWebView != null) {
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
}
if (onScanQr != null) {
// Add server by scanning the node's pairing QR
Box(
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES
}
if (FipsNative.available) {
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
}
if (onMeshParty != null) {
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
}
// Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here.
}
HubPage.NODES -> {
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
if (showAdd || editing != null) {
Column(
Modifier
.size(ROW_H)
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
)
Text(
"Cancel", color = TextMuted, fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
// HTTPS scheme toggle (available on both add and edit).
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.clickable { https = !https }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
Box(
Modifier
.width(46.dp).height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = 3.dp)
.size(20.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (https) Color.White else TextMuted),
)
}
}
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
HubPage.FIPS -> {
FipsSection(embedded = true)
}
}
}
}
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
private enum class HubPage { HUB, NODES, FIPS }
// Mode toggle
MenuItem(
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
private fun HubCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f)) {
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
}
}
}
/** Small circular icon button used in the hub header. */
@Composable
private fun IconRound(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
.clickable { onClick() },
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
}
}
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
private data class FipsInfo(
val available: Boolean,
val running: Boolean,
val npub: String,
val meshAddress: String,
val peerCount: Int,
)
/**
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
* many peers/anchors it's configured with — and a one-tap Reconnect that
* re-homes the mesh (also the manual fix if a network handoff ever misses).
* Collapsed by default so the menu stays compact.
*/
@Composable
private fun FipsSection(embedded: Boolean = false) {
if (!FipsNative.available) return
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var expanded by remember { mutableStateOf(embedded) }
var info by remember { mutableStateOf<FipsInfo?>(null) }
// Load identity/state when the section opens (cheap DataStore + JSON read).
LaunchedEffect(expanded) {
if (expanded && info == null) {
val prefs = FipsPreferences(context)
val id = prefs.identity()
val peers = runCatching {
org.json.JSONArray(prefs.peersJson()).length()
}.getOrDefault(0)
info = FipsInfo(
available = true,
running = FipsNative.isRunning(),
npub = id?.npub.orEmpty(),
meshAddress = id?.address.orEmpty(),
peerCount = peers,
)
}
}
Column(Modifier.fillMaxWidth()) {
// Embedded in the hub's FIPS sub-page the header row would duplicate
// the page title, so only the standalone (collapsible) form shows it.
if (!embedded) MenuItem(
label = "FIPS Mesh",
labelColor = BitcoinOrange,
onClick = { expanded = !expanded },
)
if (expanded) {
val i = info
Column(
Modifier
.fillMaxWidth()
.padding(top = 6.dp)
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (i == null) {
Text("Loading…", color = TextMuted, fontSize = 13.sp)
} else {
FipsRow("Status", if (i.running) "Connected" else "Stopped",
valueColor = if (i.running) BitcoinOrange else TextMuted)
if (i.meshAddress.isNotBlank()) {
FipsRow("Mesh address", i.meshAddress, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
}
if (i.npub.isNotBlank()) {
FipsRow("Identity (npub)", i.npub, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
}
FipsRow("Peers & anchors", i.peerCount.toString())
Text(
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
color = TextMuted, fontSize = 11.sp,
)
MenuItem(
label = "Reconnect mesh",
labelColor = BitcoinOrange,
onClick = {
FipsManager.requestMeshRestart(context)
info = null
if (!embedded) expanded = false
},
)
}
}
}
}
}
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
@Composable
private fun FipsRow(
label: String,
value: String,
valueColor: Color = TextPrimary,
mono: Boolean = false,
onCopy: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
Text(
value,
color = valueColor,
fontSize = if (mono) 11.sp else 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
if (onCopy != null) {
Text("", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}
@@ -43,6 +43,7 @@ fun NESPortraitController(
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
onToggleStyle: (() -> Unit)? = null,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
@@ -151,6 +152,10 @@ fun NESPortraitController(
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
onToggleStyle?.let {
Spacer(Modifier.width(10.dp))
StyleBtn(c, Modifier, it)
}
}
}
}
@@ -238,6 +238,7 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
providerFuture.addListener({
val p = providerFuture.get()
@@ -245,12 +246,15 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// CameraX's analysis default is 640x480 — too few pixels per module
// to decode a modal-sized QR at arm's length. 1280x720 more than
// doubles the pixel density at negligible analysis cost.
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
// lens, which won't focus close) left dense invoices undecodable
// while sparse address QRs still read — the "scanner doesn't pick up
// invoices" report. 1920x1080 roughly doubles module resolution so a
// QR held at the camera's actual focus distance still resolves.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.setTargetResolution(android.util.Size(1920, 1080))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
@@ -261,13 +265,27 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
}
try {
p.unbindAll()
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
// Force a centre autofocus on a repeating tick. A hand-held QR is
// a static scene, so continuous-AF often never retriggers and the
// lens sits at its resting (far) focus — fatal for dense codes.
// A normalized centre point works before the view is measured.
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
.createPoint(0.5f, 0.5f)
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
point,
androidx.camera.core.FocusMeteringAction.FLAG_AF,
).disableAutoCancel().build()
focusScheduler.scheduleWithFixedDelay({
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
focusScheduler.shutdownNow()
provider?.unbindAll()
analysisExecutor.shutdown()
}
@@ -12,15 +12,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
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.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.PartyScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
@@ -31,6 +35,8 @@ object Routes {
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
const val MESH_PARTY = "mesh_party"
const val FLARE = "flare"
}
@Composable
@@ -122,6 +128,9 @@ fun AppNavHost(
) {
composable(Routes.INTRO) {
IntroScreen(
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
onContinue = {
scope.launch {
prefs.markIntroSeen()
@@ -170,15 +179,46 @@ fun AppNavHost(
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
onRemoteKeyboard = {
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
)
}
}
composable(Routes.REMOTE_INPUT) {
composable(
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
arguments = listOf(
navArgument("keyboard") {
type = NavType.BoolType
defaultValue = false
},
),
) { entry ->
RemoteInputScreen(
onBack = {
navController.popBackStack()
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
)
}
composable(Routes.MESH_PARTY) {
PartyScreen(
onBack = { navController.popBackStack() },
onOpenChat = { navController.navigate(Routes.FLARE) },
)
}
composable(Routes.FLARE) {
FlareScreen(
onBack = { navController.popBackStack() },
)
}
}
@@ -0,0 +1,359 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
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.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.FlareMessage
import com.archipelago.app.fips.FlareStore
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.UUID
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
/**
* Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
* via a public anchor (5G) or a direct hotspot link is invisible up here —
* which is the entire point.
*/
@Composable
fun FlareScreen(onBack: () -> Unit) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var myName by remember { mutableStateOf("Phone") }
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
var selectedNpub by remember { mutableStateOf<String?>(null) }
val allMessages by FlareStore.messages.collectAsState()
var draft by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
myName = prefs.partyName()
}
LaunchedEffect(peers) {
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
selectedNpub = peers.firstOrNull()?.npub
}
}
val peer = peers.firstOrNull { it.npub == selectedNpub }
val messages = allMessages.filter { it.peerNpub == selectedNpub }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
fun sendText() {
val target = peer ?: return
val me = identity ?: return
val text = draft.trim()
if (text.isEmpty()) return
draft = ""
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
text = text,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
scope.launch(Dispatchers.IO) {
val ok = FlareClient.sendText(target, me.npub, myName, text)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
fun sendPhoto(uri: Uri) {
val target = peer ?: return
val me = identity ?: return
scope.launch(Dispatchers.IO) {
val jpeg = compressPhoto(context, uri) ?: return@launch
// Local copy so our own bubble renders the sent photo.
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
photoPath = local.absolutePath,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
val photoPicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let { sendPhoto(it) } }
BackHandler { onBack() }
Column(
Modifier
.fillMaxSize()
.background(SurfaceDark)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
// Header
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
peer?.let {
Text(
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
color = BitcoinOrange,
fontSize = 11.sp,
)
}
}
Spacer(Modifier.size(48.dp))
}
// Peer tabs when chatting with more than one phone
if (peers.size > 1) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
peers.forEach { p ->
val active = p.npub == selectedNpub
Text(
p.name,
color = if (active) BitcoinOrange else TextMuted,
fontSize = 13.sp,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
.border(
1.dp,
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
RoundedCornerShape(10.dp),
)
.clickable { selectedNpub = p.npub }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
if (peer == null) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(messages, key = { it.id }) { msg ->
MessageBubble(msg)
}
}
// Composer
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BubbleTheirs)
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
.clickable { photoPicker.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { sendText() }),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { sendText() },
contentAlignment = Alignment.Center,
) {
Text("", color = BitcoinOrange, fontSize = 18.sp)
}
}
}
}
}
@Composable
private fun MessageBubble(msg: FlareMessage) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
) {
Column(
Modifier
.widthIn(max = 300.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
.border(
1.dp,
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
RoundedCornerShape(16.dp),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (msg.photoPath.isNotBlank()) {
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
bmp?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Beamed photo",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.FillWidth,
)
}
}
if (msg.text.isNotBlank()) {
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
}
Text(
when (msg.status) {
FlareMessage.Status.SENDING -> "sending…"
FlareMessage.Status.SENT -> "sent · E2E via mesh"
FlareMessage.Status.FAILED -> "failed — tap to retry later"
FlareMessage.Status.RECEIVED -> msg.name
},
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, bounds)
}
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, opts)
} ?: return@withContext null
val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
bitmap.recycle()
out.toByteArray()
} catch (_: Exception) {
null
}
}
@@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
fun IntroScreen(onContinue: () -> Unit) {
fun IntroScreen(
onContinue: () -> Unit,
// Mesh Party works with no node at all (phone↔phone) — offered right on
// the first screen so a friend who just got the app can join a party.
onMeshParty: () -> Unit = {},
) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
@@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
GlassButton(
text = stringResource(R.string.mesh_party),
onClick = onMeshParty,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
@@ -0,0 +1,519 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.fips.PartyQr
import com.archipelago.app.ui.components.CameraQrPreview
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val CardBg = Color.White.copy(alpha = 0.05f)
private val CardBorder = Color.White.copy(alpha = 0.08f)
/** Public companion download (vps2 demo host — serves the same APK as the
* nodes' QR link). Rendered as the "Share this app" QR. */
private const val APP_DOWNLOAD_URL =
"http://146.59.87.168:2100/packages/archipelago-companion.apk"
/**
* Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the
* two embedded mesh nodes link up: through anchors when there's internet,
* directly over any shared WiFi/hotspot when there isn't.
*/
@Composable
fun PartyScreen(
onBack: () -> Unit,
onOpenChat: () -> Unit,
) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var name by remember { mutableStateOf("") }
var localIp by remember { mutableStateOf<String?>(null) }
var showScanner by remember { mutableStateOf(false) }
var showShareQr by remember { mutableStateOf(false) }
var scanHint by remember { mutableStateOf<String?>(null) }
// Camera permission — fresh installs (and reinstalls: uninstall wipes
// grants) land here with no CAMERA grant, and the raw preview just showed
// black. Ask the moment the scanner opens.
var hasCamera by remember {
mutableStateOf(
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
)
}
val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { hasCamera = it }
LaunchedEffect(showScanner) {
if (showScanner && !hasCamera) {
cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
}
}
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
name = prefs.partyName()
// The hotspot/WiFi address can change while this screen is open
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(3_000)
}
}
val qrPayload = identity?.let { id ->
PartyQr.build(
npub = id.npub,
ula = id.address,
name = name.ifBlank { "Phone" },
ip = if (listenOn) localIp else null,
port = PartyQr.PARTY_UDP_PORT,
)
}
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
BackHandler {
when {
showScanner -> showScanner = false
showShareQr -> showShareQr = false
else -> onBack()
}
}
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
Spacer(Modifier.size(56.dp))
}
// My QR card
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(20.dp))
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
qrBitmap?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(12.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "My mesh party QR",
modifier = Modifier.size(220.dp),
)
}
} ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
identity?.let {
Text(
it.npub.take(16) + "" + it.npub.takeLast(6),
color = TextMuted,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
OutlinedTextField(
value = name,
onValueChange = {
name = it.take(24)
scope.launch { prefs.setPartyName(name) }
},
placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
// Direct-link toggle (hotspot mode)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 12.dp)) {
Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
when {
listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
listenOn -> "Waiting for a WiFi/hotspot address…"
else -> "Off — mesh routes via anchors only"
},
color = if (listenOn) BitcoinOrange else TextMuted,
fontSize = 12.sp,
)
}
Switch(
checked = listenOn,
onCheckedChange = { on ->
scope.launch {
prefs.setPartyListen(on)
FipsManager.requestMeshRestart(context)
}
},
colors = SwitchDefaults.colors(
checkedTrackColor = BitcoinOrange,
checkedThumbColor = Color.White,
),
)
}
}
GlassButton(
text = "Scan a Phone",
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
if (peers.isNotEmpty()) {
Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
peers.forEach { peer ->
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(14.dp))
.clickable { onOpenChat() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 8.dp)) {
Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
peer.npub.take(14) + "" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
color = TextMuted,
fontSize = 11.sp,
)
}
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable {
scope.launch {
prefs.removePartyPeer(peer.npub)
FipsManager.requestMeshRestart(context)
}
}.padding(8.dp),
)
}
}
GlassButton(
text = "Open Flare Chat",
onClick = onOpenChat,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
} else {
Text(
"Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
}
Spacer(Modifier.height(12.dp))
// Hand the app itself to a friend: shares this install's own APK
// through the system sheet (Quick Share/Bluetooth), so a nearby
// phone gets the companion with zero internet — the whole party
// premise.
GlassButton(
text = "Share this app",
onClick = { showShareQr = true },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
Spacer(Modifier.height(12.dp))
}
// "Share this app" — a QR of the public download link, so the other
// phone scans it with its normal camera and installs over any
// internet. (The vps2 demo host serves the same APK the nodes do.)
AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f))
.clickable { showShareQr = false },
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
dlQr?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(14.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Companion download QR",
modifier = Modifier.size(240.dp),
)
}
}
Spacer(Modifier.height(18.dp))
Text(
"Scan with any camera to download\nthe Archipelago Companion",
color = TextPrimary,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
"…or send the APK file directly",
color = BitcoinOrange,
fontSize = 13.sp,
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
)
Spacer(Modifier.height(6.dp))
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
}
}
}
// Party QR scanner overlay
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
if (!hasCamera) {
Column(
Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Camera access is needed to scan a party QR.",
color = TextPrimary,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
GlassButton(
text = "Grant camera access",
onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else CameraQrPreview(onDecoded = { text ->
val peer = PartyQr.parse(text)
when {
peer == null -> scanHint = "Not a mesh party QR"
peer.npub == identity?.npub -> scanHint = "That's your own QR"
else -> {
showScanner = false
scanHint = null
scope.launch {
prefs.upsertPartyPeer(peer)
// Pick up the direct-dial PeerConfig (and the
// listener, if ours is on) immediately.
FipsManager.requestMeshRestart(context)
// Pairing must be MUTUAL: announce ourselves so
// the scanned phone gets us as a peer + a chat
// entry without scanning back. Retried while
// the fresh link/session comes up.
val me = identity
if (me != null) {
launch(Dispatchers.IO) {
for (attempt in 0 until 6) {
val ok = FlareClient.sendHello(
peer = peer,
myNpub = me.npub,
myName = name.ifBlank { "Phone" },
myUla = me.address,
myIp = if (listenOn) localIp else null,
myPort = PartyQr.PARTY_UDP_PORT,
)
if (ok) break
delay(3_000)
}
}
}
onOpenChat()
}
}
}
})
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
Text(
"Close",
color = TextPrimary,
fontSize = 16.sp,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.clickable { showScanner = false }
.padding(20.dp),
)
scanHint?.let {
Text(
it,
color = BitcoinOrange,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp)
.fillMaxWidth(),
)
}
}
}
}
// Scan hints fade so the camera feels live again.
LaunchedEffect(scanHint) {
if (scanHint != null) {
delay(2500)
scanHint = null
}
}
}
/** Render a QR payload as a bitmap (dark modules on white). */
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
val matrix = QRCodeWriter().encode(
payload,
BarcodeFormat.QR_CODE,
size,
size,
mapOf(EncodeHintType.MARGIN to 1),
)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
}
}
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
} catch (_: Exception) {
null
}
/** Share this install's own APK via the system share sheet — a nearby friend
* gets the companion with no internet at all (Quick Share / Bluetooth). */
private fun shareCompanionApk(context: android.content.Context) {
try {
val src = java.io.File(context.applicationInfo.sourceDir)
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
val out = java.io.File(dir, "archipelago-companion.apk")
src.copyTo(out, overwrite = true)
val uri = androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", out,
)
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
android.content.Intent.createChooser(send, "Share Archipelago Companion")
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
} catch (_: Exception) {
// No share targets / copy failed — nothing sensible to do here.
}
}
@@ -4,8 +4,10 @@ import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -54,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(onBack: () -> Unit) {
fun RemoteInputScreen(
onBack: () -> Unit,
onMeshParty: (() -> Unit)? = null,
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
startInKeyboard: Boolean = false,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
@@ -63,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) }
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
@@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit) {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
fun toggleStyle() {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
@@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
@@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
@@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
// Settings + style icons top-right in keyboard mode
Row(
Modifier.align(Alignment.TopEnd).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = { showModal = true },
)
com.archipelago.app.ui.components.StyleBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = ::toggleStyle,
)
}
}
}
}
@@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
visible = showModal,
servers = savedServers,
activeServer = activeServer,
isGamepadMode = isGamepadMode,
controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
@@ -245,11 +263,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
},
onRemote = { isGamepadMode = true; showModal = false },
onKeyboard = { isGamepadMode = false; showModal = false },
onBackToWebView = { showModal = false; onBack() },
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
@@ -75,6 +75,7 @@ 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.MeshLoadingScreen
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
@@ -610,6 +611,14 @@ fun ServerConnectScreen(
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
// Full-screen branded loader while the first connect runs — most
// visibly right after a pairing-QR scan, when the mesh may still be
// establishing (LAN probe → tunnel up → ULA probe can take a while).
// The small inline spinner stays for context; this owns the screen.
if (isConnecting) {
MeshLoadingScreen()
}
}
}
@@ -23,20 +23,28 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -64,19 +72,30 @@ import androidx.compose.runtime.snapshotFlow
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.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.fips.FipsManager
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@@ -87,28 +106,6 @@ 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) {
try {
@@ -137,6 +134,118 @@ private fun isSameHost(url: String, base: String): Boolean {
}
}
/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving
* the kiosk and coming back reattaches the LIVE page — no reload, no
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
private object KioskWebView {
var instance: WebView? = null
var url: String? = null
// Live-composition delegates for the JS bridges (see the factory) — the
// registered interface objects call through these, so reattaching the
// retained view re-points them instead of leaving stale closures.
var onRouteOutbound: (String) -> Unit = {}
var onOpenInApp: (String) -> Unit = {}
var onQrOpen: () -> Unit = {}
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
var onQrClose: () -> Unit = {}
fun drop() {
instance?.let {
(it.parent as? ViewGroup)?.removeView(it)
it.destroy()
}
instance = null
url = null
}
}
/** Inject the safe-area CSS vars from the CURRENT window insets. Android
* WebView doesn't populate env(safe-area-inset-*); worse, on a cold start
* onPageFinished can run before the view is attached — rootWindowInsets is
* null then, and injecting 0px collapsed the UI's top/bottom margins (and
* put the tab bar inside the gesture zone, killing its taps). Called from
* onPageFinished, from the window-insets listener (fires when real insets
* arrive), and on reattach. */
private fun injectSafeAreaVars(view: WebView) {
val insets = view.rootWindowInsets ?: return // listener re-fires when real
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
// Vue components sample the var into reactive state; tell them it
// changed (an authenticated session can mount before we run).
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
})();
""".trimIndent(),
null,
)
}
/** In-app browser pages (node apps + same-node links) don't consume the
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
* their content ran up under the status bar. Pad the document body down by
* the status-bar height: the padded strip shows the page's OWN background
* (padding is inside the element), so the bar keeps the page colour while
* content starts below it — the pre-edge-to-edge look, without the black bar.
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
if (sat <= 0) return
view.evaluateJavascript(
"""
(function() {
var s = document.getElementById('archy-top-inset');
if (!s) {
s = document.createElement('style');
s.id = 'archy-top-inset';
(document.head || document.documentElement).appendChild(s);
}
s.textContent =
'body{padding-top:${sat}px!important;box-sizing:border-box!important;}';
})();
""".trimIndent(),
null,
)
}
/** 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). If NEITHER answers,
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
* unreachable, and loading it just produced a confusing "can't reach
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
* instead means the load retries against the path that's actually coming up,
* and any error shows the mesh address rather than a dead LAN IP. */
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
meshUrl ?: lanUrl
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@@ -175,6 +284,10 @@ fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
onRemoteKeyboard: () -> Unit = {},
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
onMeshParty: (() -> Unit)? = null,
// Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
@@ -185,6 +298,13 @@ fun WebViewScreen(
meshFallbackUrl: String? = null,
) {
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
// loader; later navigations keep just the slim top progress bar.
var firstLoadDone by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { isLoading }.first { !it }
firstLoadDone = true
}
var loadProgress by remember { mutableIntStateOf(0) }
var triedMeshFallback by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
@@ -198,6 +318,13 @@ fun WebViewScreen(
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
var raceNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
// A retained live session exists — reattach instantly: no race, no
// reload, no re-login (remote ⇄ dashboard round trip).
if (KioskWebView.instance != null && KioskWebView.url == serverUrl) {
isLoading = false
startUrl = serverUrl
return@LaunchedEffect
}
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
@@ -226,7 +353,7 @@ fun WebViewScreen(
// 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.
// Assistant, BTCPay) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
@@ -239,6 +366,14 @@ fun WebViewScreen(
// One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) }
// Hub menu overlay state — the three-finger hold opens the menu right here
// over the dashboard (it used to jump to the remote screen).
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var showHubMenu by remember { mutableStateOf(false) }
var showPairScanner by remember { mutableStateOf(false) }
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
@@ -313,7 +448,10 @@ fun WebViewScreen(
text = stringResource(R.string.retry),
onClick = {
// Re-race LAN vs mesh — the network we're on may have
// changed since the last pick.
// changed since the last pick. Drop the retained view:
// an errored session must genuinely reload.
KioskWebView.drop()
webView = null
hasError = false
isLoading = true
triedMeshFallback = false
@@ -327,16 +465,17 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.disconnect),
onClick = onDisconnect,
onClick = {
KioskWebView.drop()
onDisconnect()
},
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)
}
MeshLoadingScreen()
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
@@ -344,7 +483,15 @@ fun WebViewScreen(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
// Reattach the retained kiosk WebView (remote ⇄ dashboard
// must not reload the node UI). Everything configured
// below is idempotent, and re-running it rebinds clients,
// bridges and listeners to THIS composition's state —
// stale closures from the previous visit are replaced.
if (KioskWebView.url != serverUrl) KioskWebView.drop()
val reused = KioskWebView.instance
(reused ?: WebView(context)).apply {
(parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -368,6 +515,14 @@ fun WebViewScreen(
val webViewRef = this
// Re-inject the safe-area vars whenever REAL insets
// arrive — on cold start onPageFinished often beats
// window attachment and would otherwise bake in 0px.
setOnApplyWindowInsetsListener { v, insets ->
(v as? WebView)?.let { injectSafeAreaVars(it) }
v.onApplyWindowInsets(insets)
}
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
@@ -381,20 +536,38 @@ fun WebViewScreen(
}
}
// Bridge callbacks are DELEGATED through the holder:
// the interface objects registered on a retained
// WebView survive recomposition, and re-registering
// them doesn't reliably swap the JS-visible object
// without a reload — with direct closures, a
// remote ⇄ dashboard round trip left the bridges
// writing to a dead composition's state ("apps don't
// launch"). These assignments re-point the live
// interface objects at THIS composition every attach.
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
KioskWebView.onQrOpen = {
walletScannerStatus = null
walletScannerVisible = true
}
KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err }
KioskWebView.onQrClose = { walletScannerVisible = false }
// JS bridge. The web UI calls:
// window.ArchipelagoNative.openExternal(url) — host-routed
// window.ArchipelagoNative.openInApp(url) — force in-app
// Falls back to window.open in a plain mobile browser.
addJavascriptInterface(
if (reused == null) addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { routeOutbound(url) }
webViewRef.post { KioskWebView.onRouteOutbound(url) }
}
@android.webkit.JavascriptInterface
fun openInApp(url: String) {
webViewRef.post { inAppUrl = url }
webViewRef.post { KioskWebView.onOpenInApp(url) }
}
},
"ArchipelagoNative",
@@ -406,24 +579,21 @@ fun WebViewScreen(
// window.ArchipelagoQr.close() — code accepted, tear down
// Decodes flow back through window.__archyQrResult(text);
// a user cancel calls window.__archyQrCancelled().
addJavascriptInterface(
if (reused == null) addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun open() {
webViewRef.post {
walletScannerStatus = null
walletScannerVisible = true
}
webViewRef.post { KioskWebView.onQrOpen() }
}
@android.webkit.JavascriptInterface
fun setStatus(message: String, isError: Boolean) {
webViewRef.post { walletScannerStatus = message to isError }
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
}
@android.webkit.JavascriptInterface
fun close() {
webViewRef.post { walletScannerVisible = false }
webViewRef.post { KioskWebView.onQrClose() }
}
},
"ArchipelagoQr",
@@ -439,34 +609,7 @@ fun WebViewScreen(
isLoading = false
if (view == null) return
// Convert physical pixels → CSS pixels
val density = view.resources.displayMetrics.density
val satPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.statusBars())
?.top ?: 0
val sabPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.navigationBars())
?.bottom ?: 0
val sat = (satPx / density).toInt()
val sab = (sabPx / density).toInt()
// Android WebView doesn't populate env(safe-area-inset-*).
// Set CSS custom properties the web UI can use as fallback:
// var(--safe-area-top, env(safe-area-inset-top, 0px))
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
})();
""".trimIndent(),
null,
)
injectSafeAreaVars(view)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages
@@ -616,7 +759,8 @@ fun WebViewScreen(
}
}
// Three-finger hold (500ms) → navigate to remote input.
// Three-finger hold (500ms) → open the hub menu overlay
// in place (Remote/Keyboard cards do the navigating).
// Three fingers, not two: two-finger scroll/pinch on the
// page collided with the old two-finger hold.
var threeFingerStart = 0L
@@ -634,7 +778,7 @@ fun WebViewScreen(
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
onRemoteInput()
showHubMenu = true
}
}
}
@@ -650,7 +794,21 @@ fun WebViewScreen(
}
webView = this
loadUrl(initialUrl)
if (reused == null) {
KioskWebView.instance = this
KioskWebView.url = serverUrl
loadUrl(initialUrl)
} else {
// Reattached views keep stale measurements until an
// input event — that was the top/bottom UI being
// wrong until a tap. Force a fresh pass, and re-sync
// the page's safe-area vars while we're at it.
post {
requestLayout()
invalidate()
injectSafeAreaVars(this)
}
}
}
},
)
@@ -669,6 +827,39 @@ fun WebViewScreen(
)
}
// Branded first-load screen while the mesh session comes up.
AnimatedVisibility(
visible = isLoading && !firstLoadDone,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
Modifier.fillMaxSize().background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") }
withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") }
},
fontSize = 40.sp,
fontWeight = FontWeight.Black,
letterSpacing = 4.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
"connecting to your archipelago",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
)
Spacer(Modifier.height(28.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
@@ -712,6 +903,68 @@ fun WebViewScreen(
)
}
}
// Hub menu overlay — opened by the three-finger hold, drawn above
// everything (also reachable from the error screen, where switching
// servers is exactly what's needed).
NESMenu(
visible = showHubMenu,
servers = savedServers,
activeServer = activeServer,
onDismiss = { showHubMenu = false },
onSelectServer = { server ->
showHubMenu = false
scope.launch { prefs.setActiveServer(server) }
},
onAddServer = { server ->
scope.launch {
prefs.addSavedServer(server)
if (activeServer == null) prefs.setActiveServer(server)
}
},
onScanQr = { showPairScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
// Editing the live server reloads the kiosk with the new
// address/credentials via the activeServer recomposition.
if (original.serialize() == activeServer?.serialize()) {
prefs.setActiveServer(updated)
}
}
},
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Nothing left to show — back to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
prefs.clearActiveServer()
showHubMenu = false
onDisconnect()
}
}
},
onRemote = { showHubMenu = false; onRemoteInput() },
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
onBackToWebView = { showHubMenu = false },
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
)
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
// open behind it so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showPairScanner,
onDismiss = { showPairScanner = false },
onServerScanned = { scan ->
showPairScanner = false
scope.launch {
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
}
}
@@ -758,7 +1011,17 @@ private fun InAppBrowser(
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) }
// Loader title: never show a raw IP host — a mesh ULA like
// [fd79:1aa:…] is technically the host but reads as garbage on the
// loading screen. Show a neutral name until the page reports its
// real <title> (onReceivedTitle upgrades it).
var title by remember {
mutableStateOf(
android.net.Uri.parse(url).host
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
?: "Archipelago",
)
}
var favicon by remember { mutableStateOf<Bitmap?>(null) }
var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
@@ -796,7 +1059,22 @@ private fun InAppBrowser(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
// Whole-overlay touch shield: every touch not handled by a child
// (control-bar gaps, inset strips) dies here instead of falling
// through to the kiosk's tab bar behind (a near-miss on Close
// was opening the AIUI tab underneath).
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
// Bottom inset handled by the touch-shield strip below the bar.
// No TOP inset padding: the WebView draws edge-to-edge behind the
// status bar so the app's own background fills it — the padded
// version painted an opaque black bar there (user-rejected look).
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
@@ -847,12 +1125,14 @@ private fun InAppBrowser(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
view?.let { injectTopInset(it) }
}
override fun onPageFinished(view: WebView?, u: String?) {
loading = false
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
view?.let { injectTopInset(it) }
}
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
@@ -985,6 +1265,21 @@ private fun InAppBrowser(
)
}
}
// Touch-shield over the gesture-nav strip: solid black AND consumes
// taps — stray touches below the control bar landed on the kiosk's
// tab bar behind this overlay (opening the AIUI chat by accident).
Box(
Modifier
.fillMaxWidth()
.windowInsetsBottomHeight(WindowInsets.navigationBars)
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
)
}
}
@@ -11,6 +11,7 @@
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
<string name="get_started">Get Started</string>
<string name="mesh_party">Mesh Party</string>
<string name="use_https">Use HTTPS</string>
<string name="port_label">Port (optional)</string>
<string name="saved_servers">Saved Servers</string>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
<paths>
<cache-path name="share" path="share/" />
</paths>
+1 -1
View File
@@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fips"
version = "0.3.0-dev"
source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6"
source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f"
dependencies = [
"bech32",
"chacha20poly1305",
+4 -1
View File
@@ -23,7 +23,10 @@ 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"] }
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
# discovery re-fire on topology change (fresh 5G join: first route no longer
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
anyhow = "1.0"
serde_json = "1.0"
hex = "0.4"
+5 -2
View File
@@ -76,8 +76,9 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
out(&env, json)
}
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
mut env: JNIEnv,
@@ -85,11 +86,13 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
secret: JString,
peers_json: JString,
tun_fd: jint,
listen_port: 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) {
let listen_port = u16::try_from(listen_port).unwrap_or(0);
let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
Ok((npub, address)) => {
serde_json::json!({ "npub": npub, "address": address }).to_string()
}
+45 -8
View File
@@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
}
/// 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 {
/// traffic — battery), no DNS responder, TUN enabled but attached to the
/// VpnService fd rather than created.
///
/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
/// local link (party mode); leaf_only still guarantees we never carry
/// third-party transit even while accepting an inbound link.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config {
let mut cfg = Config::default();
cfg.node.identity.nsec = Some(secret.to_string());
cfg.node.identity.persistent = false;
@@ -74,13 +79,33 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
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()),
bind_addr: Some(format!("0.0.0.0:{listen_port}")),
..Default::default()
});
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
cfg.transports.tcp = TransportInstances::Single(Default::default());
// Fast-connect profile — a phone opens the app and expects the node NOW.
// Stock pacing is tuned for always-on routers: a failed discovery backs
// off 30s, session resends gap out to 8-16s, dead links redial at
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
// link/session is down, so steady-state traffic is unchanged.
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
cfg.node.retry.max_retries = 30;
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
cfg.node.rate_limit.handshake_max_resends = 10;
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
cfg.node.discovery.backoff_max_secs = 30;
cfg.node.discovery.retry_interval_secs = 2;
cfg.node.discovery.max_attempts = 3;
// Lookups launched before the tree position settles are doomed; a 10s
// completion timeout made each one cost 10s before the 1s retry could
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
// the resend-within-window above still gives each attempt two shots.
cfg.node.discovery.timeout_secs = 5;
cfg.peers = peers;
cfg
}
@@ -94,7 +119,7 @@ pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
/// 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)> {
pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
stop();
// Android hands the VpnService TUN fd over in non-blocking mode on some
@@ -111,7 +136,7 @@ pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, Str
}
let peers = parse_peers(peers_json)?;
let config = build_config(secret, peers);
let config = build_config(secret, peers, listen_port);
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();
@@ -247,7 +272,7 @@ mod tests {
#[test]
fn config_is_leaf_only_with_tun() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![]);
let cfg = build_config(&id.secret_hex, vec![], 0);
assert!(cfg.node.leaf_only);
assert!(cfg.tun.enabled);
assert_eq!(cfg.tun.mtu(), 1280);
@@ -255,4 +280,16 @@ mod tests {
assert!(!cfg.transports.udp.is_empty());
assert!(!cfg.transports.tcp.is_empty());
}
#[test]
fn listen_port_sets_fixed_udp_bind() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![], 2121);
// Party mode keeps leaf_only — accepting a link is not routing transit.
assert!(cfg.node.leaf_only);
let TransportInstances::Single(udp) = &cfg.transports.udp else {
panic!("expected single UDP transport");
};
assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
}
}
+43 -1
View File
@@ -1,7 +1,49 @@
# Changelog
## v1.7.112-alpha (2026-07-22)
## v1.7.116-alpha (2026-07-27)
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
## v1.7.115-alpha (2026-07-26)
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
## v1.7.114-alpha (2026-07-26)
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
## v1.7.113-alpha (2026-07-25)
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
## v1.7.112-alpha (2026-07-23)
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
- 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.
+1 -51
View File
@@ -84,15 +84,6 @@ 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"
@@ -104,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.111-alpha"
version = "1.7.116-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@@ -170,7 +161,6 @@ dependencies = [
"uuid",
"zbase32",
"zeroize",
"zip",
]
[[package]]
@@ -1185,17 +1175,6 @@ 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"
@@ -6916,37 +6895,8 @@ 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",
]
+1 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.111-alpha"
version = "1.7.116-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -108,10 +108,6 @@ 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"
+3 -1
View File
@@ -202,7 +202,9 @@ impl ApiHandler {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
hyper::Body::from(
r#"{"error":"The seller does not accept Lightning for this item"}"#,
),
));
}
+9 -1
View File
@@ -106,7 +106,7 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
let mut name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
@@ -116,6 +116,14 @@ impl RpcHandler {
if name.is_empty() || name.len() > 64 {
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
}
// The default name was a single shared slot: every pairing popup
// replaced the previous phone's token, silently logging out the
// first phone the moment a second one paired. Default-named mints
// get a unique suffix so each device keeps its own credential;
// explicitly named devices keep replace-in-place semantics.
if name == "companion" {
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
}
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
Ok(serde_json::json!({ "name": name, "token": token }))
}
+5 -12
View File
@@ -443,8 +443,7 @@ impl RpcHandler {
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
}))
});
if let Some(o) = already {
@@ -454,12 +453,9 @@ impl RpcHandler {
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
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!({
@@ -692,10 +688,7 @@ impl RpcHandler {
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
+3 -4
View File
@@ -124,12 +124,15 @@ impl RpcHandler {
}
"lnd.getinfo" => self.handle_lnd_getinfo().await,
"lnd.listchannels" => self.handle_lnd_listchannels().await,
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
"lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
@@ -388,10 +391,6 @@ 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,
+5 -3
View File
@@ -23,9 +23,11 @@ impl RpcHandler {
/// 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 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
+187 -35
View File
@@ -30,6 +30,8 @@ struct ChannelInfo {
active: bool,
status: String,
channel_point: String,
#[serde(skip_serializing_if = "String::is_empty")]
closing_txid: String,
}
#[derive(Debug, Serialize)]
@@ -58,6 +60,10 @@ struct LndChannel {
#[derive(Debug, Deserialize, Default)]
struct LndPendingChannelsResponse {
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
// Cooperative closes waiting for their closing tx to confirm
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
// Force closes serving out their timelock
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
}
#[derive(Debug, Deserialize)]
@@ -65,6 +71,18 @@ struct LndPendingOpenChannel {
channel: Option<LndPendingChannel>,
}
#[derive(Debug, Deserialize)]
struct LndWaitingCloseChannel {
channel: Option<LndPendingChannel>,
closing_txid: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LndForceClosingChannel {
channel: Option<LndPendingChannel>,
closing_txid: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LndPendingChannel {
remote_node_pub: Option<String>,
@@ -74,6 +92,52 @@ struct LndPendingChannel {
channel_point: Option<String>,
}
impl LndPendingChannel {
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
ChannelInfo {
chan_id: String::new(),
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
capacity: parse(&self.capacity),
local_balance: parse(&self.local_balance),
remote_balance: parse(&self.remote_balance),
active: false,
status: status.into(),
channel_point: self.channel_point.unwrap_or_default(),
closing_txid: closing_txid.unwrap_or_default(),
}
}
}
#[derive(Debug, Deserialize, Default)]
struct LndClosedChannelsResponse {
channels: Option<Vec<LndClosedChannel>>,
}
#[derive(Debug, Deserialize)]
struct LndClosedChannel {
chan_id: Option<String>,
remote_pubkey: Option<String>,
capacity: Option<String>,
settled_balance: Option<String>,
close_type: Option<String>,
closing_tx_hash: Option<String>,
channel_point: Option<String>,
close_height: Option<i64>,
}
#[derive(Debug, Serialize)]
struct ClosedChannelInfo {
chan_id: String,
remote_pubkey: String,
capacity: i64,
settled_balance: i64,
close_type: String,
closing_tx_hash: String,
channel_point: String,
close_height: i64,
}
impl RpcHandler {
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
@@ -131,6 +195,7 @@ impl RpcHandler {
"inactive".into()
},
channel_point: ch.channel_point.unwrap_or_default(),
closing_txid: String::new(),
}
})
.collect();
@@ -138,31 +203,20 @@ impl RpcHandler {
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
if let Some(ch) = pch.channel {
let capacity: i64 = ch
.capacity
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let local: i64 = ch
.local_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let remote: i64 = ch
.remote_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
pending_channels.push(ChannelInfo {
chan_id: String::new(),
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
capacity,
local_balance: local,
remote_balance: remote,
active: false,
status: "pending_open".into(),
channel_point: ch.channel_point.unwrap_or_default(),
});
pending_channels.push(ch.into_channel_info("pending_open", None));
}
}
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
if let Some(ch) = wch.channel {
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
}
}
for fch in pending_resp
.pending_force_closing_channels
.unwrap_or_default()
{
if let Some(ch) = fch.channel {
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
}
}
@@ -349,6 +403,46 @@ impl RpcHandler {
Ok(body)
}
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp: LndClosedChannelsResponse = client
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?
.json()
.await
.context("Failed to parse LND closed channels response")?;
let channels: Vec<ClosedChannelInfo> = resp
.channels
.unwrap_or_default()
.into_iter()
.map(|ch| ClosedChannelInfo {
chan_id: ch.chan_id.unwrap_or_default(),
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
capacity: ch
.capacity
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
settled_balance: ch
.settled_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
close_type: ch.close_type.unwrap_or_default(),
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
channel_point: ch.channel_point.unwrap_or_default(),
close_height: ch.close_height.unwrap_or(0),
})
.collect();
Ok(serde_json::json!({ "channels": channels }))
}
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
&self,
params: Option<serde_json::Value>,
@@ -389,27 +483,35 @@ impl RpcHandler {
"Closing Lightning channel"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let (_, macaroon_hex) = self.lnd_client().await?;
// The close endpoint is server-streaming: LND holds the connection
// open and emits updates until the closing tx CONFIRMS on-chain
// (potentially hours). Reading the whole body hangs the RPC even
// though the close already went through, and the shared lnd_client's
// 15s total timeout would abort the stream mid-read. Use a dedicated
// client and return as soon as the first streamed update arrives.
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 url = format!(
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
let resp = client
let mut resp = client
.delete(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to close channel")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse close channel response")?;
if !status.is_success() {
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = body
.get("message")
.and_then(|v| v.as_str())
@@ -417,6 +519,56 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
}
Ok(serde_json::json!({ "success": true }))
// First streamed line is {"result":{"close_pending":…}} on success or
// {"error":…} — the stream reports errors in-band after a 200.
let mut buf: Vec<u8> = Vec::new();
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
while let Some(chunk) = resp.chunk().await? {
buf.extend_from_slice(&chunk);
let line = match buf.iter().position(|&b| b == b'\n') {
Some(pos) => &buf[..pos],
None => &buf[..],
};
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
return Ok::<_, anyhow::Error>(Some(v));
}
}
Ok(None)
})
.await;
match first_update {
Ok(Ok(Some(update))) => {
if let Some(err) = update.get("error") {
let msg = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
}
// txid arrives base64-encoded in internal byte order; flip it
// into the display order explorers use.
use base64::Engine as _;
let closing_txid = update
.pointer("/result/close_pending/txid")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(|mut bytes| {
bytes.reverse();
hex::encode(bytes)
})
.unwrap_or_default();
info!(channel_point, closing_txid, "Channel close initiated");
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
}
Ok(Ok(None)) => Err(anyhow::anyhow!(
"LND ended the close stream without an update — check the channel list"
)),
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
// No update inside the window: the close is almost certainly still
// negotiating with the peer — report initiated, the channel list
// will show it under Closing.
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
}
}
}
+150 -5
View File
@@ -36,6 +36,33 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
// Decode the invoice up front (fast, local) so we know its payment
// hash BEFORE handing it to LND. If the payment outlives our wait
// below, the hash is what lets the UI keep tracking it instead of
// declaring a false failure. Best-effort: a decode hiccup must not
// block the payment itself.
let (decoded_hash, decoded_amt) = match client
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
{
Ok(r) => match r.json::<serde_json::Value>().await {
Ok(d) => (
d.get("payment_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
d.get("num_satoshis")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0),
),
Err(_) => (String::new(), 0),
},
Err(_) => (String::new(), 0),
};
let mut pay_body = serde_json::json!({
"payment_request": payment_request,
});
@@ -43,13 +70,46 @@ impl RpcHandler {
pay_body["amt"] = serde_json::json!(amt.to_string());
}
let resp = client
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
// payment settles or definitively fails, and multi-hop routing with
// retries routinely takes longer than the shared client's 15s budget.
// That 15s abort used to surface as "Payment failed" while LND kept
// paying in the background — the payment then succeeded and appeared
// in history a minute later. Wait up to 120s on a dedicated client,
// and treat a post-connect timeout as IN FLIGHT (status: pending),
// never as failure — only LND may declare a payment failed.
let pay_client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.danger_accept_invalid_certs(true)
.build()
.context("Failed to create HTTP client")?;
let resp = match pay_client
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&pay_body)
.send()
.await
.context("Failed to pay invoice")?;
{
Ok(r) => r,
Err(e) if e.is_connect() => {
// Never reached LND — nothing was sent; this IS a hard error.
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
}
Err(_) => {
// Timed out (or lost the connection) AFTER the payment was
// handed to LND — it may well still succeed. Report pending
// with the hash so the caller can poll lnd.paymentstatus.
info!("payinvoice wait elapsed; payment still in flight");
return Ok(serde_json::json!({
"status": "pending",
"payment_hash": decoded_hash,
"amount_sats": decoded_amt,
}));
}
};
let status = resp.status();
let body: serde_json::Value = resp
@@ -86,20 +146,105 @@ impl RpcHandler {
.and_then(|r| r.get("total_amt"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
.unwrap_or(decoded_amt);
let payment_hash = body
.get("payment_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or(decoded_hash);
Ok(serde_json::json!({
"status": "succeeded",
"payment_hash": payment_hash,
"amount_sats": amount_sat,
}))
}
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
/// "pending"`) to a real terminal state instead of guessing.
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let payment_hash = params
.get("payment_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid payment hash"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!(
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse payments response")?;
let hash_lower = payment_hash.to_lowercase();
let found = body
.get("payments")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter().find(|p| {
p.get("payment_hash").and_then(|v| v.as_str())
== Some(hash_lower.as_str())
})
});
let Some(p) = found else {
// Not in the latest window — either very old or LND never saw it.
return Ok(serde_json::json!({ "status": "unknown" }));
};
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
let status = match lnd_status {
"SUCCEEDED" => "succeeded",
"FAILED" => "failed",
_ => "in_flight",
};
let failure_reason = match p
.get("failure_reason")
.and_then(|v| v.as_str())
.unwrap_or("")
{
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
"Recipient rejected the payment (wrong details or expired invoice)"
}
"FAILURE_REASON_ERROR" => "Payment failed",
_ => "",
};
fn amt(p: &serde_json::Value, key: &str) -> i64 {
p.get(key)
.and_then(|f| f.as_str())
.and_then(|s| s.parse().ok())
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
.unwrap_or(0)
}
Ok(serde_json::json!({
"status": status,
"failure_reason": failure_reason,
"amount_sats": amt(p, "value_sat"),
"fee_sats": amt(p, "fee_sat"),
}))
}
/// List on-chain transactions from LND.
/// Returns all transactions, with incoming (amount > 0) flagged.
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
+109 -1
View File
@@ -124,16 +124,41 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
// Fee control: either a confirmation target or an explicit fee rate
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!(
addr = addr,
amount = amount,
send_all = send_all,
target_conf = target_conf,
sat_per_vbyte = sat_per_vbyte,
"Sending on-chain Bitcoin"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let send_body = match amount {
let mut send_body = match amount {
Some(amount) => serde_json::json!({
"addr": addr,
"amount": amount.to_string(),
@@ -143,6 +168,13 @@ impl RpcHandler {
"send_all": true,
}),
};
if let Some(tc) = target_conf {
send_body["target_conf"] = serde_json::json!(tc);
}
if let Some(rate) = sat_per_vbyte {
// LND REST encodes uint64 as a JSON string
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
@@ -171,6 +203,82 @@ impl RpcHandler {
Ok(serde_json::json!({ "txid": txid }))
}
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
/// UI can show the cost of each preset before the user confirms.
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let addr = params
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
return Err(anyhow::anyhow!("Invalid amount"));
}
let target_conf = params
.get("target_conf")
.and_then(|v| v.as_i64())
.unwrap_or(6);
if !(1..=1008).contains(&target_conf) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
.query(&[
(format!("AddrToAmount[{addr}]"), amount.to_string()),
("target_conf".to_string(), target_conf.to_string()),
])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to estimate fee")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse fee estimate response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
}
// LND REST encodes int64 as JSON strings
let fee_sat = body
.get("fee_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let sat_per_vbyte = body
.get("sat_per_vbyte")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
Ok(serde_json::json!({
"fee_sat": fee_sat,
"sat_per_vbyte": sat_per_vbyte,
}))
}
/// Create a Lightning invoice.
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
///
-132
View File
@@ -1,132 +0,0 @@
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"),
}
}
}
-1
View File
@@ -1,6 +1,5 @@
mod assistant;
mod bitcoin_ops;
mod flash;
mod messaging;
mod safety;
mod status;
+6 -30
View File
@@ -101,36 +101,12 @@ impl RpcHandler {
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?;
let service = self.mesh_service.read().await;
let probe = match service.as_ref() {
Some(svc) => svc.probe_device(&path).await?,
// No mesh service yet (radio never enabled) — probe directly.
None => mesh::listener::probe_device(&path).await?,
};
Ok(serde_json::to_value(probe)?)
}
+2 -6
View File
@@ -89,9 +89,6 @@ 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
@@ -163,7 +160,6 @@ 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)),
@@ -560,8 +556,8 @@ impl RpcHandler {
.is_some(),
None => false,
};
let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(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()
+82 -144
View File
@@ -38,19 +38,7 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
// `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 routers = 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 }))
@@ -99,84 +87,8 @@ impl RpcHandler {
.or_else(|| saved.password.clone())
.unwrap_or_default();
// 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??;
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
// Persist the connection so other views (e.g. the Home dashboard's
// Network tile) can poll `openwrt.get-status` with no params instead
@@ -195,7 +107,62 @@ impl RpcHandler {
.await;
}
Ok(status)
// 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,
}))
}
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
@@ -261,32 +228,15 @@ impl RpcHandler {
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
};
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??;
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
tollgate::provision(&router, &config).await?;
Ok(serde_json::json!({
"ok": true,
"host": host,
"ssid": response_ssid,
"mint_url": response_mint_url,
"ssid": config.ssid,
"mint_url": config.mint_url,
}))
}
@@ -329,27 +279,22 @@ impl RpcHandler {
.or_else(|| saved.password.clone())
.unwrap_or_default();
// 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 router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
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,
})
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,
})
.collect())
})
.await??;
})
.collect();
Ok(serde_json::json!({ "networks": result }))
}
@@ -412,6 +357,9 @@ 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,
@@ -420,17 +368,7 @@ impl RpcHandler {
dhcp_limit,
masq,
};
// 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??;
wan::configure_wisp(&router, &config)?;
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
}
@@ -2324,17 +2324,28 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
}
async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on framework-pt 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args(["fuser", "-k", &format!("{}/tcp", port)])
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
let fuser_kill = format!(
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &fuser_kill])
.output()
.await;
@@ -766,7 +766,11 @@ async fn find_satellite(port: u16) -> Option<String> {
if self_ips.contains(&ip) {
continue;
}
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
set.spawn(async move {
tcp_alive(&ip.to_string(), port, 500)
.await
.then(|| ip.to_string())
});
}
while let Some(res) = set.join_next().await {
if let Ok(Some(ip)) = res {
+38 -10
View File
@@ -848,11 +848,17 @@ pub async fn ensure_audio_stack() {
{
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
Ok(s) => {
warn!("audio: package install exited with {} — will retry next start", 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);
warn!(
"audio: package install failed: {:#} — will retry next start",
e
);
return;
}
}
@@ -878,12 +884,23 @@ pub async fn ensure_audio_stack() {
}
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;
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;
let _ = host_sudo(&[
"systemctl",
"try-restart",
"archipelago-audio-router.service",
])
.await;
info!("audio: router updated");
}
}
@@ -911,10 +928,21 @@ pub async fn ensure_gamepad_keys() {
}
}
if unit_was_missing {
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
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;
let _ = host_sudo(&[
"systemctl",
"try-restart",
"archipelago-gamepad-keys.service",
])
.await;
info!("gamepad: bridge updated");
}
}
@@ -994,10 +1022,10 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
// 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");
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
@@ -111,8 +111,7 @@ impl BootReconciler {
let mut failure_rounds: u32 = 0;
loop {
let installed = orchestrator.manifest_ids().await;
let failures =
crate::container::companion::reconcile(&installed).await;
let failures = crate::container::companion::reconcile(&installed).await;
for (companion, err) in &failures {
tracing::warn!(
companion = %companion,
+17 -10
View File
@@ -248,10 +248,17 @@ impl QuadletUnit {
} else {
proto.as_str()
};
// Keep the rendered directive byte-identical for unbound
// ports so existing units don't read as drifted.
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
// (accepts, forwards nothing — "empty response" from the
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
// which forwards to the v4 loopback listener that works.
// NOTE: this changes the rendered directive for unbound
// ports on purpose — the one-time drift/recreate at upgrade
// is the deploy vehicle for the fix.
if bind.is_empty() {
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
} else {
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
}
@@ -1018,7 +1025,7 @@ mod tests {
u.network = NetworkMode::Bridge("archy-net".into());
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
let s = u.render();
assert!(s.contains("PublishPort=3000:3000/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
}
#[test]
@@ -1167,8 +1174,8 @@ mod tests {
let s = u.render();
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(!s.contains("PublishPort=8332:8332/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
}
#[test]
@@ -1200,8 +1207,8 @@ mod tests {
..QuadletUnit::default()
};
let s = u.render();
assert!(s.contains("PublishPort=8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
@@ -1317,7 +1324,7 @@ app:
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "searxng").render();
assert!(s.contains("PublishPort=8888:8080/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
assert!(!s.contains("Network=host"));
}
@@ -1746,7 +1753,7 @@ app:
assert!(body.contains("Network=archy-net"));
assert!(body.contains("NetworkAlias=lnd"));
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
assert!(body.contains("PublishPort=10009:10009/tcp"));
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
assert!(body.contains("PodmanArgs=--memory=1024m"));
+44 -5
View File
@@ -90,15 +90,13 @@ pub fn archy_anchor() -> SeedAnchor {
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
vec![
SeedAnchor {
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u"
.to_string(),
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(),
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(),
@@ -158,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
.with_context(|| format!("read {}", path.display()))?;
let anchors: Vec<SeedAnchor> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
Ok(anchors)
Ok(repair_legacy_anchor_set(anchors))
}
/// Add the newer redundant public TCP anchors to legacy files that only carry
/// the Archipelago-operated vps2 anchor. A deliberately custom/private anchor
/// list remains authoritative; this only repairs the exact stale shape shipped
/// before the join.fips.network anchors became defaults.
fn repair_legacy_anchor_set(mut anchors: Vec<SeedAnchor>) -> Vec<SeedAnchor> {
let has_archy = anchors.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB);
let has_fips_network = fips_network_anchors()
.iter()
.any(|default| anchors.iter().any(|a| a.npub == default.npub));
if has_archy && !has_fips_network {
for anchor in fips_network_anchors() {
anchors.push(anchor);
}
}
anchors
}
/// Persist the list. Overwrites atomically via write-then-rename so a
@@ -340,6 +355,30 @@ mod tests {
assert!(got.iter().all(|a| a.transport == "tcp"));
}
#[tokio::test]
async fn load_repairs_legacy_archy_only_anchor_file() {
let dir = tempfile::tempdir().unwrap();
save(dir.path(), &[archy_anchor()]).await.unwrap();
let got = load(dir.path()).await.unwrap();
assert!(got.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
for anchor in fips_network_anchors() {
assert!(got.iter().any(|a| a.npub == anchor.npub));
}
}
#[tokio::test]
async fn load_keeps_private_anchor_file_authoritative() {
let dir = tempfile::tempdir().unwrap();
let private = mk("npub1private");
save(dir.path(), std::slice::from_ref(&private))
.await
.unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![private]);
}
#[tokio::test]
async fn removing_one_default_persists_and_keeps_the_other() {
// Editing the anchor list (here removing one default) makes the file
+44
View File
@@ -0,0 +1,44 @@
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
//!
//! Catalog app launch ports (the web UIs the companion opens by direct
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
//! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283,
2342,
3000,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8088,
8089,
8090,
8096,
8123,
8175,
8176,
8240,
8334,
8888,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
23000,
32838,
50002,
];
+119
View File
@@ -48,6 +48,30 @@ pub struct FipsConfig {
pub struct NodeSection {
pub identity: IdentitySection,
pub discovery: DiscoverySection,
pub retry: RetrySection,
pub rate_limit: RateLimitSection,
}
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
/// doubling to 300s) are tuned for stable always-on links; a node redialing
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RetrySection {
pub base_interval_secs: u64,
pub max_backoff_secs: u64,
pub max_retries: u32,
}
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
/// gaps out to 8-16s between resends exactly when a route has just
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
/// window (phone measured session-after-route: 225ms).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RateLimitSection {
pub handshake_resend_interval_ms: u64,
pub handshake_resend_backoff: f64,
pub handshake_max_resends: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -60,6 +84,14 @@ pub struct IdentitySection {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DiscoverySection {
/// Lookup completion timeout. Lookups fired while the tree position is
/// still settling are doomed; failing them fast (5s, not 10s) lets the
/// 1s-backoff retry find the route the moment it exists.
pub timeout_secs: u64,
pub backoff_base_secs: u64,
pub backoff_max_secs: u64,
pub retry_interval_secs: u64,
pub max_attempts: u8,
pub lan: LanDiscoverySection,
}
@@ -123,8 +155,23 @@ impl Default for FipsConfig {
node: NodeSection {
identity: IdentitySection { persistent: true },
discovery: DiscoverySection {
timeout_secs: 5,
backoff_base_secs: 1,
backoff_max_secs: 30,
retry_interval_secs: 2,
max_attempts: 3,
lan: LanDiscoverySection { enabled: true },
},
retry: RetrySection {
base_interval_secs: 1,
max_backoff_secs: 30,
max_retries: 30,
},
rate_limit: RateLimitSection {
handshake_resend_interval_ms: 400,
handshake_resend_backoff: 1.5,
handshake_max_resends: 10,
},
},
tun: TunSection {
enabled: true,
@@ -186,6 +233,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
let _ = tokio::fs::remove_file(&stage).await;
install_result?;
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
// out-of-band) default-denies inbound on fips0 — without an explicit
// allowance the node's web UI is unreachable over the mesh (phones got
// RST on :80 with a healthy session; root-caused 2026-07-26 on
// framework-pt). Ship the allowance as a fips.d drop-in on every
// install/upgrade so no node ever regresses to a UI-less mesh.
sudo_install_dir("/etc/fips/fips.d").await?;
let dropin = "# Written by archipelago on every daemon config install.\n\
# Allows the web UI + peer API through the fips0\n\
# default-deny inbound baseline (fips.nft).\n\
tcp dport 80 accept\n\
tcp dport 8443 accept\n";
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
tokio::fs::write(&nft_stage, dropin)
.await
.context("Failed to stage web-ui nft drop-in")?;
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
let _ = tokio::fs::remove_file(&nft_stage).await;
nft_install?;
// App launch ports: the companion opens catalog apps by direct port
// over the mesh. Ports of apps that aren't installed have no listener,
// so the allowance is inert until an app exists to answer.
let port_list = super::app_ports::APP_LAUNCH_PORTS
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
let app_dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Catalog app launch ports (web UIs) allowed through the fips0\n\
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
tcp dport {{ {port_list} }} accept\n"
);
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
tokio::fs::write(&app_stage, app_dropin)
.await
.context("Failed to stage app-ports nft drop-in")?;
let app_install =
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
let _ = tokio::fs::remove_file(&app_stage).await;
app_install?;
// Make the allowance live immediately; a no-op error when the
// hardening baseline isn't installed on this node yet.
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
match Command::new("sudo")
.args(["nft", "-f", "/etc/fips/fips.nft"])
.output()
.await
{
Ok(out) if !out.status.success() => tracing::warn!(
"nft reload after web-ui drop-in failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
_ => {}
}
}
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
@@ -318,8 +424,21 @@ node:
identity:
persistent: true
discovery:
timeout_secs: 5
backoff_base_secs: 1
backoff_max_secs: 30
retry_interval_secs: 2
max_attempts: 3
lan:
enabled: true
retry:
base_interval_secs: 1
max_backoff_secs: 30
max_retries: 30
rate_limit:
handshake_resend_interval_ms: 400
handshake_resend_backoff: 1.5
handshake_max_resends: 10
tun:
enabled: true
name: fips0
+1
View File
@@ -26,6 +26,7 @@
#![allow(dead_code)]
pub mod anchors;
pub mod app_ports;
pub mod config;
pub mod dial;
pub mod iface;
+34
View File
@@ -81,6 +81,7 @@ async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> {
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
/// on every backend startup once the key is on disk.
pub async fn activate(unit: &str) -> Result<()> {
kill_stale_daemons().await?;
// Order matters: unmask before enable/start, otherwise enable fails
// on a masked unit.
sudo_systemctl("unmask", unit).await?;
@@ -94,9 +95,42 @@ pub async fn stop(unit: &str) -> Result<()> {
}
pub async fn restart(unit: &str) -> Result<()> {
kill_stale_daemons().await?;
sudo_systemctl("restart", unit).await
}
/// Kill orphaned `fips` processes not owned by either known systemd unit.
///
/// Field failure, 2026-07-24: a stale daemon survived outside systemd and kept
/// `0.0.0.0:8443` bound. The supervised daemon then started UDP-only, so every
/// TCP seed-anchor connect failed with "no operational transport" and phones
/// on 5G could not discover the node. We keep the cleanup narrow: preserve the
/// MainPID of both units and terminate only extra `pgrep -x fips` matches.
pub async fn kill_stale_daemons() -> Result<()> {
let script = format!(
r#"keep="$(systemctl show -p MainPID --value {managed} 2>/dev/null; systemctl show -p MainPID --value {upstream} 2>/dev/null)"
for pid in $(pgrep -x fips 2>/dev/null || true); do
case " $keep " in
*" $pid "*) ;;
*) kill "$pid" 2>/dev/null || true ;;
esac
done
"#,
managed = super::SERVICE_UNIT,
upstream = super::UPSTREAM_SERVICE_UNIT,
);
let out = Command::new("sudo")
.args(["sh", "-c", &script])
.output()
.await
.context("sudo stale fips cleanup failed to launch")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
anyhow::bail!("stale fips cleanup failed: {}", stderr);
}
Ok(())
}
/// Resolve which systemd unit is actually supervising the fips daemon
/// on this host. Nodes installed from the archipelago ISO run
/// `archipelago-fips.service`; nodes that were apt-installed (or had
+24 -11
View File
@@ -34,7 +34,6 @@ mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod mesh_ports;
mod ceremony;
mod config;
mod constants;
@@ -57,6 +56,7 @@ mod identity;
mod identity_manager;
mod marketplace;
mod mesh;
mod mesh_ports;
mod monitoring;
mod names;
mod network;
@@ -199,6 +199,26 @@ async fn main() -> Result<()> {
// Now mark this instance as running so the next startup can detect a crash.
crash_recovery::write_pid_marker(&config.data_dir).await?;
// Signal READY *before* the heavy synchronous boot recovery below. On a
// node with many stacks that recovery takes minutes, and the unit sat in
// `activating` the whole time — so anything that touched the service in
// that window (a superseding start/restart, a start-timeout) killed a
// half-started instance, which then exited 0 and (under the old
// Restart=on-failure) never came back: "server starting up" forever,
// reproduced on framework-pt installing apps on 2026-07-26. The daemon's
// real work (recovery, reconcile, listener) continues after READY; being
// "active" early is honest — the process is up and doing its job.
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]);
// Watchdog pings must run DURING the long recovery too, or a slow boot
// trips WatchdogSec. Spawn the keepalive here rather than after serve().
tokio::spawn(async {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(120));
loop {
interval.tick().await;
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
}
});
// Run crash recovery before starting the manifest reconciler. Both paths
// mutate Podman; running them concurrently can corrupt transient runtime
// state and leave netavark/conmon unable to start containers.
@@ -479,16 +499,9 @@ async fn main() -> Result<()> {
// Notify systemd that we're ready (Type=notify)
// Note: first param `false` keeps NOTIFY_SOCKET so watchdog pings work
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]);
// Spawn systemd watchdog ping (WatchdogSec=300, ping every 120s)
tokio::spawn(async {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(120));
loop {
interval.tick().await;
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
}
});
// READY + watchdog keepalive were already signalled/spawned earlier
// (before boot recovery) so the unit reaches `active` in seconds instead
// of sitting in `activating` through a minutes-long recovery.
// Graceful shutdown: wait for SIGTERM or SIGINT
let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
-975
View File
@@ -1,975 +0,0 @@
// 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(())
}
+13 -46
View File
@@ -450,7 +450,10 @@ impl MeshState {
let persisted: PersistedMessages = match serde_json::from_slice(&bytes) {
Ok(p) => p,
Err(e) => {
warn!("mesh: parsing {} failed (skipping restore): {e}", path.display());
warn!(
"mesh: parsing {} failed (skipping restore): {e}",
path.display()
);
return;
}
};
@@ -466,7 +469,10 @@ impl MeshState {
*id = max_id + 1;
}
}
info!("mesh: restored {count} persisted messages (next id {})", max_id + 1);
info!(
"mesh: restored {count} persisted messages (next id {})",
max_id + 1
);
}
}
@@ -510,7 +516,11 @@ pub fn spawn_message_persister(state: Arc<MeshState>) {
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());
warn!(
"mesh: renaming {} -> {} failed: {e}",
tmp.display(),
path.display()
);
continue;
}
last_written = Some(json);
@@ -547,10 +557,6 @@ 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
@@ -622,45 +628,6 @@ pub fn spawn_mesh_listener(
}
}
// 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
+72 -21
View File
@@ -269,12 +269,24 @@ async fn auto_detect_and_open(
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
skip_path: Option<&str>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
let mut paths = super::super::serial::detect_serial_devices().await;
// When falling back from a just-failed preferred path, don't probe that
// same device again in the same cycle — every open() toggles DTR/RTS,
// which resets ESP32-family boards, and back-to-back re-probes are what
// keeps a mid-boot board from ever finishing its boot.
if let Some(skip) = skip_path {
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
let skip_canon = canon(skip);
paths.retain(|p| canon(p) != skip_canon);
}
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
anyhow::bail!(match skip_path {
Some(skip) => format!("No serial devices found in /dev besides {skip}, which was already probed this cycle"),
None => "No serial devices found in /dev".to_string(),
});
}
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
@@ -360,6 +372,16 @@ pub struct DeviceProbe {
pub max_contacts: Option<u16>,
}
/// Serializes serial-port open sequences between the listener's session
/// opens and the RPC probe (`mesh.probe-device`). Linux happily double-opens
/// a tty, and two concurrent handshakes corrupt each other into silence —
/// observed live on .116 (2026-07-26): the kiosk browser's hot-swap
/// auto-probe collided with the listener's cycle on every backoff window, so
/// neither ever succeeded, and each collision's open() DTR/RTS-reset the
/// board again. The probe's retry-across-idle-gaps heuristic (5f01ec31)
/// narrowed but could not close the race; this closes it.
static PORT_OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// 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
@@ -367,11 +389,10 @@ pub struct DeviceProbe {
/// 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.
// Retries kept even with PORT_OPEN_LOCK closing the double-open race:
// the board may still be mid-boot from a previous open's DTR/RTS reset,
// and a later attempt after a quiet gap can succeed where the first
// couldn't.
let mut last_err = None;
for attempt in 0..3u32 {
if attempt > 0 {
@@ -386,6 +407,7 @@ pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
}
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
let _port_guard = PORT_OPEN_LOCK.lock().await;
if super::super::reticulum::probe_rnode(path).await.is_ok() {
return Ok(DeviceProbe {
path: path.to_string(),
@@ -488,19 +510,40 @@ async fn open_preferred_path(
};
}
// 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}")
// 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"),
}
}
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
@@ -943,6 +986,11 @@ pub(super) async fn run_mesh_session(
// set, otherwise try the preferred serial path, falling back to
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None.
//
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
// can't interleave its own handshakes on the same tty (see the lock's
// doc comment). Held only until the device is opened, then released.
let port_guard = PORT_OPEN_LOCK.lock().await;
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
} else if let Some(path) = preferred_path {
@@ -966,6 +1014,7 @@ pub(super) async fn run_mesh_session(
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
Some(path),
)
.await?
}
@@ -976,9 +1025,11 @@ pub(super) async fn run_mesh_session(
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
None,
)
.await?
};
drop(port_guard);
// Update status
{
+7 -4
View File
@@ -191,10 +191,13 @@ impl MeshtasticDevice {
.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() }),
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()
+22 -19
View File
@@ -8,7 +8,6 @@
pub mod alerts;
pub mod bitcoin_relay;
pub mod crypto;
pub mod flash;
pub mod listener;
pub mod meshtastic;
pub mod message_types;
@@ -743,18 +742,10 @@ impl MeshService {
self.server_name = name;
}
/// 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.
/// Start the background mesh listener.
pub fn start(&mut self) -> Result<()> {
if self.listener_handle.is_some() {
return Ok(());
anyhow::bail!("Mesh listener already running");
}
let (shutdown_tx, shutdown_rx) = watch::channel(false);
@@ -1064,17 +1055,29 @@ 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
/// Probe a serial port for a mesh radio without provisioning or keeping
/// it — powers the hot-swap "device detected" modal's current-details
/// view. Refuses 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<()> {
/// with the reconnect loop (whichever loses just retries).
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
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");
if status.device_connected {
if let Some(active) = status.device_path.as_deref() {
// Compare canonical paths: /dev/mesh-radio is a symlink to the
// ttyUSB*/ttyACM* node, and a probe through the alias would
// still open the very tty the live session is holding.
let canon = |p: &str| {
std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p))
};
if canon(active) == canon(path) {
anyhow::bail!("{path} is the active mesh radio — already connected");
}
}
}
Ok(())
drop(status);
listener::probe_device(path).await
}
/// Get message history.
+11 -19
View File
@@ -557,37 +557,29 @@ 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.
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so
/// without this one physical board shows up (and gets probed, and gets its
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which
/// keeps the stable /dev/mesh-radio name when the symlink exists.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
let mut seen_real_paths = std::collections::HashSet::new();
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::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)
let canonical = 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");
if seen_canonical.contains(&canonical) {
debug!(path = %path, canonical = %canonical.display(),
"Skipping alias of an already-detected serial device");
continue;
}
seen_canonical.push(canonical);
devices.push(path.to_string());
}
}
+4 -4
View File
@@ -110,8 +110,8 @@ async fn listening_ports(path: &str, addr_hex_len: usize) -> Result<HashSet<u16>
/// 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")?;
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();
@@ -119,8 +119,8 @@ fn spawn_forwarder(port: u16) -> Result<JoinHandle<()>> {
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")?;
let listener =
tokio::net::TcpListener::from_std(socket.into()).context("register with tokio")?;
Ok(tokio::spawn(async move {
loop {
+39 -113
View File
@@ -97,57 +97,42 @@ 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 {
tokio::task::spawn_blocking(|| {
use std::net::UdpSocket;
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
.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
.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)
Err(_) => false,
}
}
/// Add a port forward (stored locally; actual UPnP mapping done on request).
@@ -296,41 +281,19 @@ 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 {
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)
use std::net::TcpStream;
TcpStream::connect_timeout(
&"127.0.0.1:9050".parse().unwrap(),
std::time::Duration::from_secs(2),
)
.is_ok()
}
/// 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 {
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)
use std::net::ToSocketAddrs;
"cloudflare.com:443".to_socket_addrs().is_ok()
}
// --- Router Compatibility Abstraction ---
@@ -506,40 +469,3 @@ 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");
}
}
+147 -47
View File
@@ -976,31 +976,69 @@ impl Server {
main_addr: SocketAddr,
shutdown: impl std::future::Future<Output = ()>,
) -> Result<()> {
// 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 active_connections = Arc::new(tokio::sync::Semaphore::new(1024));
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?,
main_connections.clone(),
active_connections.clone(),
false, // main listener: no path filter
rx_main,
main_addr,
));
// The mesh is IPv6-only: a phone reaching the node over its fips0
// ULA lands on port 80 over v6, where a 0.0.0.0 listener never
// answers — the UI was structurally unreachable over the mesh
// (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200,
// v6:80 = refused). Mirror an IPv4-any main listener with a
// V6ONLY [::] socket on the same port — v6-only so it coexists
// with the v4 listener regardless of net.ipv6.bindv6only.
let v4_any_port = match main_addr {
SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()),
_ => None,
};
let v6_task = if let Some(port) = v4_any_port {
let v6_addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
match bind_v6_only(v6_addr) {
Ok(listener) => {
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
Some(tokio::spawn(accept_loop(
self.api_handler.clone(),
listener,
active_connections.clone(),
false, // same semantics as the main listener
tx.subscribe(),
v6_addr,
)))
}
Err(e) => {
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
None
}
}
} else {
None
};
// Peer listener: late-binding so we don't need an archipelago
// restart when fips0 comes up after onboarding.
// App UIs over the mesh: rootless podman's port forwarder binds
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
// nothing even with the firewall open (HA/FileBrowser/Gitea/
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
// themselves). Bridge each catalog launch port to its v4 loopback
// listener with a V6ONLY relay — self-selecting: where the app
// already answers on v6 the bind fails and the app wins.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let peer_task = tokio::spawn(peer_late_bind_loop(
self.api_handler.clone(),
peer_connections.clone(),
active_connections.clone(),
tx.subscribe(),
));
@@ -1011,9 +1049,7 @@ 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 main_connections.available_permits() < 1024
|| peer_connections.available_permits() < 256
{
while active_connections.available_permits() < 1024 {
if drain_start.elapsed() > drain_timeout {
warn!("Drain timeout reached, forcing shutdown");
break;
@@ -1022,6 +1058,10 @@ impl Server {
}
let _ = main_task.await;
if let Some(t) = v6_task {
let _ = t.await;
}
relay_task.abort();
let _ = peer_task.await;
info!("Shutdown complete");
@@ -1029,6 +1069,98 @@ impl Server {
}
}
/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the
/// socket never claims the IPv4 side (which the main listener owns) —
/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with
/// EADDRINUSE.
fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
let socket = socket2::Socket::new(
socket2::Domain::IPV6,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)?;
socket.set_only_v6(true)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
tokio::net::TcpListener::from_std(socket.into())
}
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
/// why). Rescans every 60s so ports of freshly installed apps get bridged
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
/// raw TCP to the same port on IPv4 loopback.
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
use std::collections::HashSet;
let mut bridged: HashSet<u16> = HashSet::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
if bridged.contains(&port) {
continue;
}
// ONLY bridge a port that a running app already answers on
// over IPv4. Binding [::]:port for an app that isn't
// installed is actively harmful: it makes that app's
// later install hit "address already in use", and the
// install's port-free step (`fuser -k <port>/tcp`) then
// kills THIS daemon, which holds the port — the exact
// cause of installs failing + apps vanishing on
// framework-pt 2026-07-27. No v4 listener → skip; the
// next rescan picks it up once the app is up.
let v4_up = tokio::time::timeout(
std::time::Duration::from_millis(300),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
.ok()
.and_then(|r| r.ok())
.is_some();
if !v4_up {
continue;
}
let addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
// EADDRINUSE = the app itself already answers on v6 —
// exactly when we must stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port);
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
let mut rx = shutdown_rx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((mut inbound, _)) = accepted else { break };
tokio::spawn(async move {
let Ok(mut outbound) = tokio::net::TcpStream::connect(
("127.0.0.1", port),
)
.await else { return };
let _ = tokio::io::copy_bidirectional(
&mut inbound,
&mut outbound,
)
.await;
});
}
_ = rx.changed() => break,
}
}
});
}
}
_ = shutdown_rx.changed() => return,
}
}
}
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
/// listener and run the normal accept loop. If the bind fails (port
/// already taken, permissions), log and keep retrying. Returns on
@@ -1106,11 +1238,6 @@ 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,
@@ -1130,35 +1257,8 @@ async fn accept_loop(
}
};
let handler = handler.clone();
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.
let permit = active_connections.clone().acquire_owned().await;
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();
+32 -7
View File
@@ -1868,13 +1868,38 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
// UI before systemd kills us. --no-block makes sure systemctl doesn't
// try to wait for the current service (us) to exit cleanly before
// starting the new process — it would deadlock otherwise.
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// systemctl talks to PID 1 over D-Bus — doesn't need the host
// mount namespace, but routing through host_sudo keeps the
// apply flow's sudo calls uniform.
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
});
// PID1-owned timer transient: submit NOW (synchronously, while we are
// definitely alive), fire in 2s from systemd itself. The old approach —
// tokio sleep + `systemd-run --wait -- systemctl --no-block restart` —
// ran as a child of the process being stopped; on v1.7.114->115 the
// stop landed but the start never fired and the node sat dead all
// night. A timer unit owned by PID1 cannot be killed by our own death,
// and Restart=always on the unit is the second net.
let submitted = tokio::process::Command::new("sudo")
.args([
"systemd-run",
"--collect",
"--on-active=2",
"--timer-property=AccuracySec=100ms",
"--",
"systemctl",
"restart",
"archipelago",
])
.status()
.await;
match submitted {
Ok(st) if st.success() => {}
other => {
tracing::warn!(
"detached restart submission failed ({other:?}) — falling back to in-process restart"
);
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
});
}
}
Ok(())
}
+24 -5
View File
@@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
// Mark original proofs as spent
wallet.mark_spent(&indices);
// Separate send proofs from change proofs
let (send, change): (Vec<_>, Vec<_>) = swap_result
.new_proofs
.into_iter()
.partition(|p| send_denoms.contains(&p.amount));
// Separate send proofs from change proofs BY COUNT, not membership:
// partition(contains) put EVERY proof whose denomination appeared in
// send_denoms into the token — when change shared a denomination with
// the send (worst case: spending from a proof worth exactly 2× the
// amount, send [n] + change [n]), the change proofs rode along and the
// receiver was credited double. Consume exactly one proof per needed
// send denomination; everything else is change.
let mut send_needed = send_denoms.clone();
let mut send: Vec<Proof> = Vec::new();
let mut change: Vec<Proof> = Vec::new();
for p in swap_result.new_proofs {
if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) {
send_needed.swap_remove(pos);
send.push(p);
} else {
change.push(p);
}
}
if !send_needed.is_empty() {
anyhow::bail!(
"Mint swap returned incomplete send denominations (missing {:?})",
send_needed
);
}
// Add change proofs back to wallet
if !change.is_empty() {
+20
View File
@@ -15,6 +15,26 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
## Tier 0 — Quick / mechanical, no blockers
- [ ] **Ship the lightning payment false-failure fix in the next release** (fixed
on main 2026-07-27, needs OTA). Slow multi-hop payments (>15s) surfaced as
"Payment failed" while LND settled them in the background — the shared LND
REST client's 15s timeout aborted the synchronous `/v1/channels/transactions`
wait. Now: payinvoice decodes the invoice first for its payment hash, waits
up to 120s on a dedicated client, returns `status: "pending"` (never a
failure) on timeout, and the new `lnd.paymentstatus` RPC + frontend
`payLightningInvoice()` helper poll to a real terminal state (all 5 UI call
sites migrated). Verify on Framework PT with a real multi-hop payment.
- [ ] **Show the app version on the companion mobile-app banner in the app store
and on its install/pairing modal** (user request 2026-07-27) — so it's
obvious at a glance whether the node is serving the latest APK build.
- [ ] **Optimise the companion QR scan — quicker + better** (user request
2026-07-27; deferred to a later session on purpose). The pairing/scan QR
flow works (user-verified on-device 2026-07-27) but should get faster and
smoother: quicker camera start + decode (scan resolution/framerate,
continuous autofocus), more forgiving in low light / at an angle, and
snappier feedback once the code locks. Touch the native-scan path from
PR #104 and the in-app scan modal together so both benefit.
- [ ] **Update `tests/lifecycle/TESTING.md`'s stale Release Gates checklist** (lines
289296) — several boxes are unchecked but actually true now:
- #1 bitcoin-stops: covered by `tests/lifecycle/bats/bitcoin-knots.bats` stop/restart
+2 -1
View File
@@ -162,7 +162,8 @@ All endpoints use JSON-RPC over HTTP POST to `/rpc/v1`.
| `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes |
| `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes |
| `lnd.newaddress` | — | `{ address: string }` | Yes |
| `lnd.sendcoins` | `{ addr: string, amount: number }` | `{ txid: string }` | Yes |
| `lnd.sendcoins` | `{ addr: string, amount?: number, send_all?: bool, target_conf?: number, sat_per_vbyte?: number }` | `{ txid: string }` | Yes |
| `lnd.estimatefee` | `{ addr: string, amount: number, target_conf?: number }` | `{ fee_sat: number, sat_per_vbyte: number }` | Yes |
| `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes |
| `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes |
| `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes |
-55
View File
@@ -110,61 +110,6 @@ lands].
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
tested on real hardware vs. code-reviewed only as this is run.
1. ❑ From the hot-swap modal's step 1 (device already probed), press
**Flash Firmware…** → new step shows firmware-family + board pickers and
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
family, board, AND the checkbox are all set.
2. ❑ Confirm what's currently on the test stick via the existing probe
BEFORE flashing it — don't flash the only known-good device without a
fallback board on hand.
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
only exercise a primary/in-use stick once the flow is proven safe.
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
tail update live, ends at "Flash complete".
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
the esp32s3 release zip.
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
completes (no raw esptool erase/write step for this family — see
`mesh/flash.rs` doc comment).
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
by vid:pid. Board auto-detect should fail closed for it every time
(manual board selection required, "couldn't confirm automatically"
warning shown) — this is expected steady-state behavior, not a gap to
close later.
8. ❑ After a successful flash, the modal automatically re-probes and shows
the NEW firmware's badge/details — same as unplugging and replugging
(Section E item 3), but without physically touching the cable.
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
or point at a bad cached asset) — confirm the error surfaces in the
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
rnodeconf command) actually get the board back to a flashable state.
10. ❑ Cancel button only appears (and only works) while still in the
"Downloading firmware…" stage — once erasing/writing starts, no cancel
affordance is offered.
11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash
(e.g. kill network access mid-download to force a failure), confirm the
mesh listener does NOT auto-resume — `journalctl -u archipelago` should
show a single `Leaving mesh listener stopped after failed flash` line
and then go quiet for that device, not a repeating `mesh::serial:
Opened serial port... Starting Meshcore handshake` cycle every few
seconds. Reconnect manually via the hot-swap modal afterward and confirm
it connects normally (the board itself should be untouched — the
download fails before esptool/rnodeconf ever runs).
12. ❑ Separately, force a device to flap connected/disconnected a few times
in under 20s each (e.g. a marginal USB connection) and confirm
`reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...)
rather than resetting to 5s on every attempt — see
`STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`.
---
After this passes: fold the batch + other agent's work into the next release
-80
View File
@@ -443,86 +443,6 @@ free -h
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
- If on WiFi, try wired Ethernet for more stable connectivity
### 21. LoRa radio firmware flash failed / board unresponsive
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
an error, or the radio no longer enumerates as a serial device after a flash
attempt.
**Diagnosis**:
```bash
# Poll the flash job's last-known stage/error directly
curl -s http://localhost:5678/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"mesh.flash-status","params":{}}'
# Confirm the board is still enumerating at all
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
# esptool/rnodeconf binaries present?
which esptool; ls -la /usr/local/bin/archy-rnodeconf
```
**Solutions**:
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
this is expected risk of the "always erase first" default, not a bug.
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
state esptool can always talk to, regardless of what firmware (if any) is
currently on it.
- With the board in bootloader mode, a manual recovery flash can be run
directly over SSH without the UI:
```bash
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
```
- For Reticulum/RNode boards, the equivalent manual recovery is
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
sequence the UI triggers.
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
installed by the last `self-update.sh` run — check
`sudo journalctl -u archipelago-update` for install failures, or install
`esptool` via `sudo apt-get install esptool` directly.
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
for the next detection poll) — the hot-swap modal re-probes automatically
and shows whatever firmware is actually on the board now.
**Known incident (2026-07-23) — reconnect storm / device boot-loop after a
failed flash**: a real Heltec V3 got stuck cycling "connect → partial
handshake → drop" every 5-15s for 5+ minutes after a `mesh.flash-device`
attempt failed with `Reading firmware download stream`. Root cause was two
compounding issues, both now fixed:
1. `spawn_mesh_listener`'s reconnect backoff (`core/archipelago/src/mesh/listener/mod.rs`)
reset to its 5s minimum any time the prior session had been `device_connected`
at all, even for under a second — so a device that connects-then-drops
repeatedly never actually backed off. Every retry's `open()` toggles
DTR/RTS, which resets many ESP32 boards' MCU (native-USB *and*
CP2102/CH340 auto-reset-circuit boards), so the aggressive retries were
themselves *causing* the boot loop, not just observing one. Fixed by only
resetting backoff when a session ran for at least `STABLE_SESSION_THRESHOLD`
(20s) — see that constant's doc comment.
2. `mesh::flash::start_flash_job`'s post-completion handler auto-resumed the
listener unconditionally, even after a *failed* flash, immediately
re-entering the reconnect loop above with no cooldown. Fixed: on failure
the listener is now deliberately left stopped (reconnect manually via the
UI once the board is confirmed alive); on success there's a 5s settle
delay before resuming, so the board finishes booting from the flash
tool's own reset before Archipelago starts probing it again.
3. Separately, the download itself was failing because `mesh::flash`'s HTTP
client had a blanket 30s request timeout that covered the *entire*
download (including streaming a 170MB Meshtastic zip), not just
connection setup — fixed with a per-chunk stall timeout instead of a
fixed total-transfer cap.
If this symptom recurs (rapid repeating `mesh::serial: Opened serial
port... Starting Meshcore handshake` lines in `journalctl -u archipelago`
without a `LoRa firmware flash` job in progress), it's a NEW instance of the
same class of bug, not the one above — check whether backoff is actually
escalating (`Mesh session error: ... (retry in Xs)` — X should grow past 5s
within a few cycles) before assuming it's flashing-related.
---
## General Maintenance
@@ -365,11 +365,6 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
ca-certificates \
openssl \
chrony \
iputils-ping \
esptool \
python3-venv \
binutils \
libpython3.13 \
locales \
console-setup \
keyboard-configuration \
+1 -1
View File
@@ -83,7 +83,7 @@ QEMU_ARGS=(
# Display mode
if [ "$NOGRAPHIC" = true ]; then
QEMU_ARGS+=(-nographic -append "console=ttyS0")
QEMU_ARGS+=(-display none)
else
QEMU_ARGS+=(-vga virtio -display default)
fi
+7 -1
View File
@@ -31,7 +31,13 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:a
# "-" so a missing/failed guard can never block the service itself.
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
# always (not on-failure): the OTA restart path once stopped the daemon
# cleanly and the queued start never fired (framework-pt, v1.7.114->115,
# 2026-07-26) — the node sat dead all night behind "server starting up".
# Restart=always self-heals any lost start job; an explicit
# `systemctl stop` is still honored (systemd never auto-restarts after
# a manual stop).
Restart=always
RestartSec=5
WatchdogSec=300
TimeoutStartSec=300
+23 -2
View File
@@ -3453,12 +3453,26 @@ app.post('/rpc/v1', (req, res) => {
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
{ chan_id: '', remote_pubkey: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4', capacity: 500000, local_balance: 500000, remote_balance: 0, active: false, status: 'pending_open', channel_point: randomHex(32) + ':0', peer_alias: 'ACINQ' },
{ chan_id: '', remote_pubkey: '02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff', capacity: 800000, local_balance: 350000, remote_balance: 450000, active: false, status: 'closing', channel_point: randomHex(32) + ':0', closing_txid: randomHex(32), peer_alias: 'Bitrefill' },
]
return res.json({
result: {
channels,
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
// Totals sum open channels only, matching the real backend.
total_outbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.local_balance, 0),
total_inbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.remote_balance, 0),
},
})
}
case 'lnd.closedchannels': {
return res.json({
result: {
channels: [
{ chan_id: '840921088110001', remote_pubkey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', capacity: 1000000, settled_balance: 612000, close_type: 'COOPERATIVE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':0', close_height: 903112 },
{ chan_id: '840921088110002', remote_pubkey: '0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f', capacity: 250000, settled_balance: 0, close_type: 'REMOTE_FORCE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':1', close_height: 897540 },
],
},
})
}
@@ -3518,6 +3532,13 @@ app.post('/rpc/v1', (req, res) => {
})
}
case 'lnd.estimatefee': {
// ~141 vB P2WPKH spend at a rate that scales with urgency
const target = params?.target_conf || 6
const rate = target <= 1 ? 22 : target <= 6 ? 8 : 2
return res.json({ result: { fee_sat: rate * 141, sat_per_vbyte: rate } })
}
case 'lnd.decodepayreq': {
return res.json({
result: {
+57 -3
View File
@@ -1,13 +1,14 @@
{
"name": "neode-ui",
"version": "1.7.111-alpha",
"version": "1.7.115-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.111-alpha",
"version": "1.7.115-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
@@ -149,6 +150,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1810,6 +1812,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1833,6 +1836,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2882,6 +2886,18 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3538,6 +3554,28 @@
"win32"
]
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@@ -3885,6 +3923,7 @@
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
@@ -3934,6 +3973,7 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cac": "^6.7.14",
"colorette": "^2.0.20",
@@ -4434,6 +4474,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4919,6 +4960,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5937,6 +5979,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8129,6 +8172,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -8178,6 +8222,7 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@@ -8280,7 +8325,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/leven": {
"version": "3.1.0",
@@ -9036,6 +9082,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9697,6 +9744,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10952,6 +11000,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11207,6 +11256,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11448,6 +11498,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11610,6 +11661,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11623,6 +11675,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -11715,6 +11768,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.111-alpha",
"version": "1.7.116-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -24,6 +24,7 @@
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
},
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
Binary file not shown.
+70
View File
@@ -400,6 +400,76 @@ class RPCClient {
})
}
/** Pay a Lightning invoice and resolve it to a REAL terminal state.
*
* The backend waits up to 120s on LND's synchronous pay endpoint; if the
* payment is still routing after that it answers `status: "pending"` with
* the payment hash instead of an error. This helper then polls
* lnd.paymentstatus until LND itself reports succeeded/failed, so callers
* never show "failed" for a payment that is merely slow the bug where a
* settling payment was declared failed and then appeared in history a
* minute later. Returns `pending` only if the payment is STILL in flight
* after the polling window (rare; caller should say "still settling",
* not "failed"). */
async payLightningInvoice(params: {
payment_request: string
amount_sats?: number
}): Promise<{
status: 'succeeded' | 'failed' | 'pending'
payment_hash: string
amount_sats: number
failure_reason?: string
}> {
const res = await this.call<{
status?: string
payment_hash?: string
amount_sats?: number
}>({
method: 'lnd.payinvoice',
params,
// Above the backend's 120s wait so the backend always answers first.
timeout: 130000,
})
const hash = res.payment_hash || ''
const amount = res.amount_sats || 0
// Older backends have no status field — a plain response was a success.
if (res.status !== 'pending') {
return { status: 'succeeded', payment_hash: hash, amount_sats: amount }
}
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
// Poll to a terminal state: every 3s for up to 2 minutes.
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 3000))
try {
const st = await this.call<{
status: string
failure_reason?: string
amount_sats?: number
}>({
method: 'lnd.paymentstatus',
params: { payment_hash: hash },
timeout: 15000,
})
if (st.status === 'succeeded') {
return { status: 'succeeded', payment_hash: hash, amount_sats: st.amount_sats || amount }
}
if (st.status === 'failed') {
return {
status: 'failed',
payment_hash: hash,
amount_sats: amount,
failure_reason: st.failure_reason || 'Payment failed',
}
}
} catch {
// Transient poll error — keep trying; only LND decides failure.
}
}
return { status: 'pending', payment_hash: hash, amount_sats: amount }
}
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
return this.call({
method: 'node.nostr-publish',
@@ -525,10 +525,10 @@ async function approvePayment() {
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
} else if (method === 'lightning') {
if (pay.invoice) {
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: pay.invoice },
})
// Tracked to a real terminal state slow routing is not a failure.
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
if (res.status === 'pending') throw new Error('Payment is still settling — check your wallet transactions before retrying.')
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
} else {
// Create and immediately return an invoice for the requester to display
@@ -150,7 +150,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen'
// exposes the release-server address.
const DEFAULT_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/archipelago-companion.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
: 'http://146.59.87.168:2100/packages/archipelago-companion.apk'
// Deep-link scheme the companion app registers; carries the server entry the
// app should create (see docs/companion-pairing-qr.md for the contract).
@@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean {
* - a tailnet 100.x address (operator browsing over Tailscale a scanned
* QR carried one of these on 2026-07-22 and the companion sat there
* dialing an IP the phone had no route to)
* - a .fips name or mesh ULA (operator browsing over the mesh a scanned
* QR carried npub.fips as fhost on 2026-07-24; Android's system DNS
* can't resolve .fips, so the phone's direct dial died and first
* connect crawled through anchor discovery instead of the LAN)
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
const { hostname, origin } = window.location
const phoneUnreachable =
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
isTailnetIp(hostname) ||
hostname.endsWith('.fips') ||
hostname.includes(':') // IPv6 literal the node's mesh ULA
if (!phoneUnreachable) return origin
try {
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
@@ -75,7 +75,7 @@
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<div v-else-if="channels.length === 0 && closedChannels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
@@ -85,6 +85,23 @@
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<!-- Status tabs -->
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>
{{ tab.label }}
<span
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
>{{ tab.count }}</span>
</button>
</div>
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -96,7 +113,7 @@
{{ error }}
</div>
<div
v-for="ch in channels"
v-for="ch in filteredChannels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
:class="{ 'bg-white/5': compact }"
@@ -109,12 +126,14 @@
'bg-green-400': channelStatus(ch) === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive',
'bg-gray-400': channelStatus(ch) === 'closing',
'bg-gray-500': channelStatus(ch) === 'force_closing',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
</div>
<button
v-if="channelStatus(ch) !== 'pending_open'"
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
@@ -148,9 +167,10 @@
</p>
</div>
<!-- Funding tx -->
<div v-if="fundingTxid(ch)" class="flex justify-end">
<!-- Funding / closing tx -->
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
<button
v-if="fundingTxid(ch)"
@click="openInMempool(fundingTxid(ch))"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="fundingTxid(ch)"
@@ -160,8 +180,66 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button
v-if="ch.closing_txid"
@click="openInMempool(ch.closing_txid!)"
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
:title="ch.closing_txid"
>
Closing tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
<!-- Closed channel history (All + Closed tabs) -->
<div
v-for="ch in filteredClosed"
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
class="glass-card p-4 opacity-75"
:class="{ 'bg-white/5': compact }"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/30"></span>
<span class="text-white/60 text-sm font-medium">Closed</span>
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
</div>
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
</div>
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<div class="flex justify-between text-xs text-white/50 mb-2">
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
</div>
<div v-if="ch.closing_tx_hash" class="flex justify-end">
<button
@click="openInMempool(ch.closing_tx_hash)"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="ch.closing_tx_hash"
>
Closing tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
<!-- Per-tab empty state -->
<div
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
class="glass-card p-6 text-center"
>
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
</div>
</div>
</Transition>
@@ -299,7 +377,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTxExplorer } from '@/composables/useTxExplorer'
@@ -314,6 +392,18 @@ interface Channel {
active: boolean
status?: string
channel_point?: string
closing_txid?: string
}
interface ClosedChannel {
chan_id?: string
remote_pubkey: string
capacity: number
settled_balance: number
close_type?: string
closing_tx_hash?: string
channel_point?: string
close_height?: number
}
/** Status with a fallback derived from `active` for backends that omit it */
@@ -321,6 +411,48 @@ function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
}
type ChannelTab = 'all' | 'active' | 'pending' | 'closed'
const activeTab = ref<ChannelTab>('all')
/** pending_open, closing and force_closing all live on the Pending tab */
function isPendingState(ch: Channel): boolean {
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
}
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
])
const filteredChannels = computed((): Channel[] => {
switch (activeTab.value) {
case 'closed': return []
case 'active': return channels.value.filter(ch => !isPendingState(ch))
case 'pending': return channels.value.filter(isPendingState)
default: return channels.value
}
})
const filteredClosed = computed((): ClosedChannel[] =>
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
)
const emptyTabMessage = computed((): string => {
switch (activeTab.value) {
case 'active': return 'No open channels.'
case 'pending': return 'No pending or closing channels.'
case 'closed': return 'No closed channels yet.'
default: return 'No channels yet.'
}
})
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
function closeTypeLabel(ch: ClosedChannel): string {
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
@@ -333,6 +465,7 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const closedChannels = ref<ClosedChannel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
@@ -419,6 +552,17 @@ async function loadChannels() {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
// Closed history is a separate RPC a failure here keeps the previous
// list rather than blanking the main channel view.
try {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels',
timeout: 15000,
})
closedChannels.value = closed.channels || []
} catch {
/* keep previous closed list */
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<template v-if="seedTab === 'words'">
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
Tap to {{ hidden ? 'reveal' : 'hide' }}.
</p>
<div class="relative">
<div
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
</div>
<p class="text-xs text-white/40 mt-2">
<template v-if="aezeed">
The code contains your seed words as plain text treat it exactly like the words
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
phrase it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
not into hardware wallets like Passport.
</template>
<template v-else-if="qrFormat === 'seedqr'">
SeedQR scans into Passport, SeedSigner, Keystone and other wallets that import
seeds by QR. Treat this code exactly like the words themselves.
</template>
<template v-else>
Plain text words for wallets that read the phrase as text. Treat this code exactly
like the words themselves.
</template>
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
// Words are always the first view. For BIP39 seeds the QR defaults to the
// SeedQR standard (4-digit wordlist indices what Passport Prime, SeedSigner,
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
// plain-text QR plus an explanation SeedQR-encoding one would be dishonest.
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
const seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
const seedQrAvailable = ref(!props.aezeed)
const hidden = ref(true)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderQr() {
await nextTick()
if (!qrCanvas.value || props.words.length === 0) return
try {
let payload = props.words.join(' ')
if (!props.aezeed && qrFormat.value === 'seedqr') {
const { toSeedQrDigits } = await import('@/utils/seedqr')
const digits = await toSeedQrDigits(props.words)
if (digits) {
payload = digits
} else {
// Not a BIP39 phrase after all only plain text is honest.
seedQrAvailable.value = false
qrFormat.value = 'text'
return // the qrFormat watcher re-renders as text
}
}
const QRCode = await import('qrcode')
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
watch(() => props.words, () => {
seedTab.value = 'words' // fresh reveal always shows words first
hidden.value = true
seedQrAvailable.value = !props.aezeed
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
})
</script>
+365 -49
View File
@@ -1,7 +1,58 @@
<template>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ SUCCESS PANE the payment's moment, not a footnote ============ -->
<template v-if="successInfo">
<div class="text-center py-4">
<div class="send-success-burst mx-auto mb-6">
<span class="burst-ring"></span>
<span class="burst-ring burst-ring-2"></span>
<span class="burst-ring burst-ring-3"></span>
<div class="burst-core">
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successInfo.amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-1">SENT</div>
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
<div v-if="successInfo.hash">
<p class="text-xs text-white/50 mb-1">Payment hash</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
<button
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
@click="copyDetail(successInfo.hash)"
>{{ copiedDetail === successInfo.hash ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
<div v-if="successInfo.txid">
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
<button
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
@click="copyDetail(successInfo.txid)"
>{{ copiedDetail === successInfo.txid ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
</div>
<div class="flex gap-3">
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
</div>
</div>
</template>
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
<template v-if="confirming">
<template v-else-if="confirming">
<div class="mb-3 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Method</span>
@@ -27,6 +78,10 @@
</span>
<span class="text-sm font-medium text-white/80">{{ confirmAmount.toLocaleString() }} sats</span>
</div>
<div v-if="effectiveMethod === 'onchain'" class="flex items-center justify-between">
<span class="text-xs text-white/50">Network fee</span>
<span class="text-sm font-medium text-white/80">{{ feeEstimateLabel }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
@@ -66,7 +121,19 @@
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<div class="flex items-center gap-2">
<label class="text-white/60 text-sm">{{ amountLabel }}</label>
<!-- sats/BTC entry toggle (on-chain only) -->
<div v-if="sendMethod === 'onchain'" class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="u in (['sats', 'btc'] as const)"
:key="u"
@click="setAmountUnit(u)"
class="px-2 py-0.5 rounded text-[11px] font-medium transition-colors"
:class="amountUnit === u ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ u === 'btc' ? 'BTC' : 'sats' }}</button>
</div>
</div>
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
<button
v-if="sendMethod === 'onchain'"
@@ -80,10 +147,11 @@
</button>
</div>
<input
v-model.number="amount"
v-model.number="amountEntry"
type="number"
min="1"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
:min="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
:step="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : amountUnit === 'btc' && sendMethod === 'onchain' ? '0.001' : '1000'"
:disabled="sendAll || pastedInvoiceAmount !== null"
class="w-full input-glass disabled:opacity-50"
/>
@@ -96,6 +164,7 @@
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
Zero-amount invoice enter how many sats to pay.
</p>
<p v-else-if="unitConversionHint" class="text-xs text-white/40 mt-1">{{ unitConversionHint }}</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
@@ -114,6 +183,48 @@
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<!-- Network fee (on-chain only) -->
<div v-if="sendMethod === 'onchain'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">Network fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in onchainFeePresets"
:key="preset.key"
@click="feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ onchainFeePresets.find(p => p.key === feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target blocks</label>
<input
v-model.number="customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
@@ -125,16 +236,6 @@
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-if="resultTxid" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
</div>
<div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div>
<div v-if="resultArk" class="mb-3 alert-success">
<p class="text-xs">{{ resultArk }}</p>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
@@ -167,13 +268,67 @@ const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
// 'auto' remains in the type for the effectiveMethod logic but is no longer
// offered as a tab (hidden per operator request 2026-07-22).
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
const amount = ref<number>(0)
// --- Amount entry with a sats/BTC unit toggle (on-chain). `amountEntry` is
// --- what the user types in the chosen unit; `amount` stays the canonical
// --- sats value the rest of the flow reads and writes.
const amountUnit = ref<'sats' | 'btc'>('sats')
const amountEntry = ref<number>(0)
const amount = computed<number>({
get: () =>
amountUnit.value === 'btc'
? Math.round((amountEntry.value || 0) * 100_000_000)
: Math.floor(amountEntry.value || 0),
set: (sats: number) => {
amountEntry.value = amountUnit.value === 'btc' ? (sats || 0) / 100_000_000 : sats || 0
},
})
/** Switch entry unit, converting whatever is already typed. */
function setAmountUnit(unit: 'sats' | 'btc') {
if (unit === amountUnit.value) return
const sats = amount.value
amountUnit.value = unit
amount.value = sats
}
// Only the on-chain tab offers BTC entry leaving it snaps back to sats so
// the lightning/ecash flows (and their sats-only hints) stay consistent.
watch(sendMethod, (m) => {
if (m !== 'onchain' && amountUnit.value !== 'sats') setAmountUnit('sats')
})
const amountLabel = computed(() =>
sendMethod.value === 'onchain' && amountUnit.value === 'btc' ? 'Amount (BTC)' : t('sendBitcoin.amountSats')
)
const unitConversionHint = computed(() => {
if (sendMethod.value !== 'onchain' || !amountEntry.value) return ''
return amountUnit.value === 'btc'
? `= ${amount.value.toLocaleString()} sats`
: `= ${(amount.value / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')} BTC`
})
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const resultArk = ref('')
// Set on a completed send flips the modal to the success pane.
const successInfo = ref<{
amount: number
methodLabel: string
hash?: string
txid?: string
note?: string
} | null>(null)
const copiedDetail = ref('')
function copyDetail(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
copiedDetail.value = text
setTimeout(() => {
if (copiedDetail.value === text) copiedDetail.value = ''
}, 1500)
}
const ecashToken = ref('')
// "Send all funds" sweeps the whole on-chain balance (explicit on-chain tab only)
@@ -193,22 +348,78 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats make the user type the new amount.
else if (prev !== null) amount.value = 0
// --- On-chain network fee: presets map to LND confirmation targets; custom
// --- takes a block target or an explicit sat/vB rate (rate wins).
type OnchainFeePreset = 'fast' | 'standard' | 'slow' | 'custom'
const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'fast', label: 'Fast', hint: 'Targets the next block (~10 minutes)', confTarget: 1 },
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'slow', label: 'Slow', hint: 'Confirms within ~144 blocks (about a day)', confTarget: 144 },
{ key: 'custom', label: 'Custom' },
]
const feePreset = ref<OnchainFeePreset>('standard')
const customConfTarget = ref<number | null>(null)
const customSatPerVbyte = ref<number | null>(null)
// Resolved at review time so confirm + send use the same params.
const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>({})
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
if (feePreset.value !== 'custom') {
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
}
const rate = customSatPerVbyte.value
const conf = customConfTarget.value
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
error.value = 'Custom fee requires target blocks or sats per vByte'
return null
}
// Fee estimate for the confirm pane (best-effort LND's own estimator).
const feeEstimate = ref<{ fee_sat: number; sat_per_vbyte: number } | null>(null)
const feeEstimateLoading = ref(false)
const feeEstimateLabel = computed(() => {
if (feeEstimate.value) {
return `~${feeEstimate.value.fee_sat.toLocaleString()} sats · ${feeEstimate.value.sat_per_vbyte} sat/vB`
}
if (resolvedFeeParams.value.sat_per_vbyte) {
return `${resolvedFeeParams.value.sat_per_vbyte} sat/vB (custom)`
}
if (feeEstimateLoading.value) return '…'
return isSweep.value ? 'deducted from swept amount' : 'estimated at broadcast'
})
async function loadFeeEstimate() {
feeEstimate.value = null
// Explicit sat/vB shows as-is; sweeps have no fixed amount to estimate on.
if (resolvedFeeParams.value.sat_per_vbyte || isSweep.value) return
const addr = dest.value.trim()
const amt = confirmAmount.value
if (!addr || amt < 546) return
feeEstimateLoading.value = true
try {
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
method: 'lnd.estimatefee',
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
timeout: 10000,
})
if (res.fee_sat > 0) feeEstimate.value = res
} catch {
/* estimate is a preview — the label falls back to prose */
} finally {
feeEstimateLoading.value = false
}
}
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
@@ -231,6 +442,25 @@ const effectiveMethod = computed(() => {
return 'lightning'
})
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
// MUST come after effectiveMethod: watch() evaluates its source getter at
// setup, and reading a const still in its temporal dead zone crashed the
// whole modal at mount ("Cannot access 'R' before initialization").
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted.
@@ -309,20 +539,37 @@ function review() {
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
error.value = t('sendBitcoin.amountSats'); return
}
if (method === 'onchain') {
const fee = onchainFeeParams()
if (!fee) return
resolvedFeeParams.value = fee
void loadFeeEstimate()
} else {
resolvedFeeParams.value = {}
feeEstimate.value = null
}
void loadConfirmBalance()
confirming.value = true
}
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
ecashToken.value = ''
confirming.value = false
successInfo.value = null
emit('close')
}
/** Reset the form for a fresh payment straight from the success screen. */
function sendAnother() {
successInfo.value = null
confirming.value = false
dest.value = ''
amount.value = 0
sendAll.value = false
error.value = ''
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
@@ -345,11 +592,9 @@ async function send() {
processing.value = true
error.value = ''
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
const method = effectiveMethod.value
const paidAmount = confirmAmount.value
try {
if (method === 'ark') {
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
@@ -359,7 +604,7 @@ async function send() {
// Ark sends can wait on round participation.
timeout: 130000,
})
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
successInfo.value = { amount: paidAmount, methodLabel: 'Sent via Ark', note: 'The transfer settles with the next Ark round.' }
} else if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
@@ -375,23 +620,38 @@ async function send() {
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({
method: 'lnd.payinvoice',
params: { payment_request: dest.value.trim() },
})
resultHash.value = res.payment_hash
// Waits out slow multi-hop routing and only reports failure when LND
// itself declares the payment failed never on a timeout.
const res = await rpcClient.payLightningInvoice({ payment_request: dest.value.trim() })
if (res.status === 'failed') {
error.value = res.failure_reason || t('web5.sendFailed')
return
}
successInfo.value = {
amount: paidAmount,
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
hash: res.payment_hash || undefined,
...(res.status === 'pending'
? { note: 'This payment is taking longer than usual to settle. It will appear in your transactions once it completes.' }
: {}),
}
} else {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: isSweep.value
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
? { addr: dest.value.trim(), send_all: true, ...resolvedFeeParams.value }
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
})
resultTxid.value = res.txid
successInfo.value = {
amount: paidAmount,
methodLabel: isSweep.value ? 'Swept on-chain' : 'Sent on-chain',
txid: res.txid,
note: 'On-chain payments confirm over the next blocks.',
}
}
emit('sent')
// Back to the form pane so the success/token panes are visible.
// Success pane (or the token pane for ecash mints) takes over the modal.
confirming.value = false
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
@@ -400,3 +660,59 @@ async function send() {
}
}
</script>
<style scoped>
/* Success burst pop-in check inside radiating rings, wallet palette
(emerald for the settled payment, one Archipelago-orange ring). */
.send-success-burst {
position: relative;
width: 7rem;
height: 7rem;
}
.burst-core {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
}
.burst-check {
stroke-dasharray: 32;
stroke-dashoffset: 32;
animation: burst-draw 0.45s ease-out 0.25s forwards;
}
.burst-ring {
position: absolute;
inset: 0;
border-radius: 9999px;
border: 2px solid rgba(16, 185, 129, 0.45);
animation: burst-ripple 1.8s ease-out infinite;
}
.burst-ring-2 {
animation-delay: 0.45s;
}
.burst-ring-3 {
animation-delay: 0.9s;
border-color: rgba(249, 115, 22, 0.35);
}
@keyframes burst-pop {
from { transform: scale(0.3); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes burst-draw {
to { stroke-dashoffset: 0; }
}
@keyframes burst-ripple {
0% { transform: scale(0.7); opacity: 0.9; }
100% { transform: scale(2); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.burst-core, .burst-check, .burst-ring { animation: none; }
.burst-check { stroke-dashoffset: 0; }
.burst-ring { display: none; }
}
</style>
+9 -8
View File
@@ -701,16 +701,17 @@ async function confirmSend() {
error.value = ''
try {
if (action.value === 'pay-invoice') {
const params: Record<string, unknown> = { payment_request: dest.value }
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params,
timeout: 60000,
})
// Waits out slow multi-hop routing and only reports failure when LND
// itself declares the payment failed never on a timeout.
const res = await rpcClient.payLightningInvoice(params)
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
successAmount.value = res.amount_sats || effectiveAmount.value
successVerb.value = 'PAID'
successDetail.value = 'Lightning invoice paid'
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
successDetail.value = res.status === 'pending'
? 'Payment in flight — it will appear in your transactions once it settles'
: 'Lightning invoice paid'
successRef.value = res.payment_hash
} else if (action.value === 'send-onchain') {
const res = await rpcClient.call<{ txid: string }>({
@@ -1,7 +1,7 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Apply Archipelago Settings' : 'Flash Firmware'"
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
@@ -101,111 +101,9 @@
"Keep As Is" uses the radio exactly as it is nothing on it is changed,
and you can hot-swap radios any time.
</p>
<button
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
:disabled="!!connecting"
@click="openFlashStep"
>
Flash Firmware
</button>
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
</div>
<!-- Step 3: erase + reflash destructive, opt-in only -->
<div v-else-if="step === 'flash'">
<!-- Once a job exists (started via startFlash), ALWAYS show the
progress/result view below including on failure. The old
condition (`!active && stage !== 'done'`) was also true for a
FAILED job (active:false, stage:'failed'), which silently sent
the user back to this picker instead of showing the error. -->
<template v-if="!flashJob">
<p class="text-white/60 text-xs mb-3">
Downloads the latest firmware from upstream and writes it to
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
<select v-model="flashFamily" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum RNode</option>
</select>
</div>
<div>
<label class="block text-sm text-white/80 mb-1">Board</label>
<select v-model="flashBoard" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically double check before flashing.
Flashing the wrong board's image can brick it.
</p>
</div>
</div>
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
<label class="flex items-start gap-2 text-xs text-red-300">
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
<span>
This <strong>erases the entire chip</strong>, including any existing
keys, identity, and contacts. This cannot be undone.
</span>
</label>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
@click="startFlash"
>
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
</button>
</div>
</template>
<!-- Progress -->
<template v-else>
<div class="text-center py-2">
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full bg-orange-400 transition-all"
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
></div>
</div>
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
</div>
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
</div>
<div class="flex gap-2 mt-4">
<button
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
class="glass-button px-4 py-2 rounded-lg text-sm"
@click="cancelFlash"
>
Cancel
</button>
<button
v-if="!flashJob?.active"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="closeFlashStep"
>
Done
</button>
</div>
</template>
</div>
<!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else>
<p class="text-white/60 text-xs mb-3">
@@ -292,7 +190,7 @@
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
@@ -301,7 +199,7 @@ const mesh = useMeshStore()
const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2 | 'flash'>(1)
const step = ref<1 | 2>(1)
const connecting = ref<false | 'keep' | 'setup'>(false)
const error = ref('')
const probing = ref(false)
@@ -366,10 +264,7 @@ const rfPreset = computed(() => {
// (Re)probe + (re)apply presets each time a new device surfaces the modal
watch([show, devicePath], async ([visible]) => {
if (!visible) {
stopFlashPoll()
return
}
if (!visible) return
step.value = 1
error.value = ''
imageFailed.value = false
@@ -454,118 +349,6 @@ async function applySetup() {
connecting.value = false
}
}
// Step 3: erase + reflash
const flashFamily = ref<FlashFirmwareFamily | ''>('')
const flashBoard = ref<FlashBoard | ''>('')
const flashConfirmed = ref(false)
const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
case 'done': return 'Flash complete'
case 'failed': return 'Flash failed'
default: return ''
}
})
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value
flashConfirmed.value = false
flashJob.value = null
error.value = ''
step.value = 'flash'
}
function stopFlashPoll() {
if (flashPollTimer) {
clearInterval(flashPollTimer)
flashPollTimer = null
}
}
async function pollFlashStatus() {
try {
const status = await mesh.flashStatus()
flashJob.value = status
if (!status.active) {
stopFlashPoll()
if (status.done && !status.error) {
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
// card reflects whatever firmware is actually on the board now.
const path = devicePath.value
probing.value = true
try {
probe.value = await mesh.probeDevice(path)
} catch {
probe.value = null
} finally {
probing.value = false
}
}
}
} catch {
stopFlashPoll()
}
}
async function startFlash() {
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
starting.value = true
error.value = ''
try {
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
stopFlashPoll()
flashPollTimer = setInterval(pollFlashStatus, 1500)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
} finally {
starting.value = false
}
}
async function cancelFlash() {
try {
await mesh.flashCancel()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to cancel'
}
}
function closeFlashStep() {
stopFlashPoll()
step.value = 1
}
</script>
<style scoped>
+1
View File
@@ -415,6 +415,7 @@
"totalEarned": "Total Earned",
"monthlyAvg": "Monthly Avg",
"ecashBalance": "Ecash Balance",
"totalBitcoin": "Total Bitcoin",
"onChain": "On-chain",
"lightning": "Lightning",
"ecash": "Ecash",
+1
View File
@@ -413,6 +413,7 @@
"totalEarned": "Total ganado",
"monthlyAvg": "Promedio mensual",
"ecashBalance": "Saldo Ecash",
"totalBitcoin": "Bitcoin total",
"onChain": "On-chain",
"lightning": "Lightning",
"ecash": "Ecash",
-47
View File
@@ -57,23 +57,6 @@ export interface MeshDeviceProbe {
max_contacts: number | null
}
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
/** Live progress for the one flash job that can run at a time. */
export interface FlashJobStatus {
active: boolean
board?: FlashBoard
family?: FlashFirmwareFamily
path?: string
stage?: FlashStage
percent?: number | null
log_tail?: string[]
done?: boolean
error?: string | null
}
/** Params accepted by mesh.configure (superset of the status fields). */
export interface MeshConfigureParams {
enabled?: boolean
@@ -375,32 +358,6 @@ export const useMeshStore = defineStore('mesh', () => {
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
})
}
/** Available firmware version(s) for a family v1 only ever returns
* ["latest"], since firmware is always fetched from upstream at flash
* time rather than pinned/bundled. */
async function flashListFirmware(family: FlashFirmwareFamily): Promise<string[]> {
const res = await rpcClient.call<{ versions: string[] }>({
method: 'mesh.flash-list-firmware',
params: { family },
})
return res.versions
}
/** Erase + reflash a detected radio. `board` is optional omit it to let
* the backend auto-resolve from the port's USB vid:pid; if that fails
* (e.g. Heltec V4 not yet in the vid:pid table), it errors and the UI
* must ask the user to pick the board explicitly. Always erases first. */
async function flashDevice(path: string, family: FlashFirmwareFamily, board?: FlashBoard): Promise<void> {
await rpcClient.call({
method: 'mesh.flash-device',
params: board ? { path, family, board } : { path, family },
})
}
async function flashStatus(): Promise<FlashJobStatus> {
return rpcClient.call<FlashJobStatus>({ method: 'mesh.flash-status' })
}
async function flashCancel(): Promise<void> {
await rpcClient.call({ method: 'mesh.flash-cancel' })
}
let globalDetectTimer: ReturnType<typeof setInterval> | null = null
/** App-wide light poll so the detected-device modal works on every page
* (the Mesh view's own 5s poll takes over while it is mounted). */
@@ -1037,10 +994,6 @@ export const useMeshStore = defineStore('mesh', () => {
undismissedDetectedDevices,
dismissDetectedDevice,
probeDevice,
flashListFirmware,
flashDevice,
flashStatus,
flashCancel,
startGlobalDetection,
fetchPeers,
fetchMessages,
+21
View File
@@ -0,0 +1,21 @@
/**
* SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime,
* SeedSigner, Keystone, Nunchuk, Sparrow, ): each BIP39 word becomes its
* zero-padded 4-digit wordlist index (00002047), concatenated into one
* digit stream and rendered as a numeric-mode QR. A 24-word seed is 96
* digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr
*
* Only valid for real BIP39 mnemonics LND's aezeed shares the wordlist but
* is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it.
*/
export async function toSeedQrDigits(words: string[]): Promise<string | null> {
if (words.length === 0) return null
const { wordlist } = await import('@scure/bip39/wordlists/english.js')
const digits: string[] = []
for (const raw of words) {
const idx = wordlist.indexOf(raw.trim().toLowerCase())
if (idx < 0) return null // not a BIP39 word — caller falls back to text
digits.push(idx.toString().padStart(4, '0'))
}
return digits.join('')
}
+3 -3
View File
@@ -685,9 +685,9 @@ onBeforeUnmount(() => {
min-height: var(--app-session-mobile-bar-height, 84px);
padding: 10px 16px;
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
/* Solid black, not translucent: the app iframe's theme colour bled
through the bar and its safe-area strip on phones. */
background: #000;
border-top: 1px solid rgba(255, 255, 255, 0.06);
transform: translateZ(0);
}
+40 -26
View File
@@ -646,37 +646,51 @@ function launchAppNow(id: string) {
useAppLauncherStore().openSession(id)
}
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
try {
const result = await rpcClient.call<AppCredentialsResponse>({
// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
// hostage for its full 5s timeout over the mesh (home-card launches skip this
// gate entirely, which is why they always felt instant). First launch waits at
// most LAUNCH_CRED_BUDGET_MS; the RPC keeps running in the background and its
// answer is memoized, so every later launch of that app resolves instantly.
const LAUNCH_CRED_BUDGET_MS = 1200
const credentialsCache = new Map<string, AppCredentialsResponse | null>()
function fetchCredentials(id: string): Promise<AppCredentialsResponse | null> {
return rpcClient
.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: id },
timeout: 5000,
})
const credentials = resolveAppCredentials(id, result)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
} catch {
const credentials = resolveAppCredentials(id, null)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
.then((r) => {
credentialsCache.set(id, r)
return r
})
.catch(() => {
credentialsCache.set(id, null)
return null
})
}
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
const result = credentialsCache.has(id)
? credentialsCache.get(id) ?? null
: await Promise.race([
fetchCredentials(id),
// Budget exceeded launch with the static fallback config; the
// in-flight RPC still lands in the cache for next time.
new Promise<null>((resolve) => setTimeout(() => resolve(null), LAUNCH_CRED_BUDGET_MS)),
])
const credentials = resolveAppCredentials(id, result)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
}
function closeCredentialModal() {
+71 -16
View File
@@ -522,8 +522,10 @@ const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? for
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
onMounted(async () => {
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// Paint last-known wallet figures BEFORE any network round-trip.
hydrateWalletSnapshot()
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// Poll wallet balances/transactions like Web5.vue does without this a
// pending on-chain receive (or a fresh instant payment) only shows up
// after a manual wallet action or a remount.
@@ -583,25 +585,78 @@ function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
}
}
// Last-known wallet snapshot, hydrated before ANY network round-trip so the
// card paints real figures instantly (app-launch-speed doctrine: over the
// mesh every serialized RPC costs a full RTT never make the user watch it).
const WALLET_SNAPSHOT_KEY = 'archy-wallet-snapshot-v1'
function hydrateWalletSnapshot() {
try {
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
if (!raw) return
const s = JSON.parse(raw)
walletOnchain.value = s.onchain ?? 0
walletLightning.value = s.lightning ?? 0
walletEcash.value = s.ecash ?? 0
walletFedimint.value = s.fedimint ?? 0
walletArk.value = s.ark ?? 0
walletConnected.value = s.connected === true
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
}
function persistWalletSnapshot() {
try {
localStorage.setItem(WALLET_SNAPSHOT_KEY, JSON.stringify({
onchain: walletOnchain.value,
lightning: walletLightning.value,
ecash: walletEcash.value,
fedimint: walletFedimint.value,
ark: walletArk.value,
connected: walletConnected.value,
// Enough for the Transactions modal's first paint; refresh replaces it.
transactions: walletTransactions.value.slice(0, 50),
}))
} catch { /* storage full — snapshot is best-effort */ }
}
async function loadWeb5Status() {
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
// there is a balance"). On failure keep the last-known value the refs start
// at 0, so only the very first load before any success shows 0.
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
// from the persisted snapshot, so 0 only ever shows on a genuinely fresh node.
//
// All seven calls are independent fire them TOGETHER. Serialized, this
// block cost 7 × (mesh RTT + backend time); parallel it costs one slowest
// call, which is what makes the card feel like an app launch.
const balances = Promise.allSettled([
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true })
.catch(() => { walletConnected.value = false }),
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })
.then(res => { walletFedimint.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 })
.then(res => { walletArk.value = res.spendable_sats ?? 0 }).catch(() => { /* keep last-known */ }),
])
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
// already unifies both) previously only LND transactions were fetched
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
// appeared in the Transactions modal even though the balance included it.
let lndTxs: WalletTransaction[] = []
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const })) } catch { /* keep last-known transactions */ }
let lightningTxs: WalletTransaction[] = []
try { const res = await rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }); lightningTxs = res.transactions || [] } catch { /* keep last-known transactions */ }
let ecashTxs: WalletTransaction[] = []
try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ }
walletTransactions.value = [...lndTxs, ...lightningTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp)
// already unifies both) so Cashu/Fedimint receives appear in the modal.
const histories = Promise.allSettled([
rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 })
.then(res => (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const }))).catch(() => [] as WalletTransaction[]),
rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 })
.then(res => res.transactions || []).catch(() => [] as WalletTransaction[]),
rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 })
.then(res => (res.transactions || []).map(ecashToWalletTransaction)).catch(() => [] as WalletTransaction[]),
]).then((results) => {
const merged = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
// Keep last-known list when every history call failed this round.
if (merged.length > 0 || results.some(r => r.status === 'fulfilled')) {
walletTransactions.value = merged.sort((a, b) => b.time_stamp - a.time_stamp)
}
})
await Promise.allSettled([balances, histories])
persistWalletSnapshot()
}
// System stats
+59 -1
View File
@@ -44,7 +44,19 @@
<!-- Word Grid -->
<div v-if="words.length > 0" class="w-full max-w-[600px]">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
<!-- Words / QR tabs words first; QR for wallets that import by scan -->
<div class="flex gap-1 mb-2 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<div v-if="seedTab === 'words'" class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
<div
v-for="(word, i) in words"
:key="i"
@@ -55,6 +67,26 @@
</div>
</div>
<div v-else class="flex flex-col items-center gap-2 py-2">
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
<p class="text-xs text-white/50 text-center max-w-[420px]">
{{ qrFormat === 'seedqr'
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
: 'Plain text words — for wallets that read the phrase as text.' }}
Treat this code exactly like the words themselves.
</p>
</div>
<!-- Warning -->
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
<p class="text-xs sm:text-sm text-orange-300/90">
@@ -99,6 +131,32 @@ import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const words = ref<string[]>([])
// Words / QR code view of the seed words are the default first view.
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
// hardware wallets like Passport Prime / SeedSigner actually import; plain
// text stays available for wallets that read the phrase as text.
const seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderSeedQr() {
await nextTick()
if (!seedQrCanvas.value || words.value.length === 0) return
try {
let payload = words.value.join(' ')
if (qrFormat.value === 'seedqr') {
const { toSeedQrDigits } = await import('@/utils/seedqr')
const digits = await toSeedQrDigits(words.value)
if (digits) payload = digits
else qrFormat.value = 'text' // non-BIP39 word only text is honest
}
const QRCode = await import('qrcode')
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
const confirmed = ref(false)
const loading = ref(false)
const waitingForServer = ref(false)
+24 -19
View File
@@ -794,15 +794,19 @@ function goBack() {
onMounted(async () => {
if (props.peerId) {
// Find the peer by onion address
try {
const result = await rpcClient.federationListNodes()
const peers = result?.nodes ?? []
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
} catch {
// Continue with just the onion address
}
await Promise.all([loadCatalog(), loadOwned()])
// The peer-name lookup is cosmetic the catalog only needs the onion we
// already have. Serialized, it added a full mesh round-trip before the
// files even started loading.
await Promise.all([
rpcClient.federationListNodes()
.then((result) => {
const peers = result?.nodes ?? []
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
})
.catch(() => { /* continue with just the onion address */ }),
loadCatalog(),
loadOwned(),
])
} else {
loading.value = false
}
@@ -1316,8 +1320,8 @@ async function payWithInvoice() {
/**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. No QR/polling: lnd.payinvoice only returns once the payment
* settles, so the payment_hash is immediately valid as the download gate token.
* release the file. payLightningInvoice resolves to a real terminal state, so
* on success the payment_hash is immediately valid as the download gate token.
*/
async function payWithLightning() {
const item = payItem.value
@@ -1337,14 +1341,15 @@ async function payWithLightning() {
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
return
}
// 2. Pay it from our own node. Returns only after settlement.
const pay = await rpcClient.call<{ payment_hash?: string; payment_error?: string }>({
method: 'lnd.payinvoice',
params: { payment_request: inv.bolt11 },
timeout: 120000,
})
if (pay?.payment_error) {
lnError.value = `Payment failed: ${pay.payment_error}`
// 2. Pay it from our own node. Tracked to a REAL terminal state a slow
// multi-hop route resolves via status polling instead of a false failure.
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
return
}
// 3. Settled pull the file using the payment hash as the gate token.
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
// The backend captures the aezeed at wallet-init time; until the user
@@ -166,20 +167,8 @@ async function confirmBackedUp() {
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<SeedRevealPanel :words="revealedWords" aezeed />
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import LightningChannels from '@/components/LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -44,7 +45,11 @@ describe('LightningChannels', () => {
total_outbound: 60_000,
})
const wrapper = mount(LightningChannels)
// The panel's setup pulls a Pinia store via useTxExplorer — mount with a
// fresh Pinia or setup throws before the first render.
const wrapper = mount(LightningChannels, {
global: { plugins: [createPinia()] },
})
await flushPromises()
expect(wrapper.text()).toContain('peer-pubkey')
@@ -208,6 +208,11 @@ function onResize() {
updateTabBarHeight()
}
function onInsetsInjected() {
readSafeAreaTop()
updateTabBarHeight()
}
onMounted(() => {
updateTabBarHeight()
// Re-measure after the first paint: on mount the bar may not have its final
@@ -216,13 +221,21 @@ onMounted(() => {
requestAnimationFrame(updateTabBarHeight)
readSafeAreaTop()
window.addEventListener('resize', onResize)
// Re-read after WebView injection has had time to run. The injected
// safe-area-bottom padding changes the bar's height, so re-measure too.
setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500)
// The Android WebView injects --safe-area-top asynchronously and fires this
// event when it lands. An authenticated session mounts the dashboard BEFORE
// the injection (fresh installs mount after login, long after it), so a
// one-shot read here bakes in 0 and content slides under the growing fixed
// tab bar the update-install-only overlap bug.
window.addEventListener('archy-insets', onInsetsInjected)
// Fallback retry ladder for APKs that predate the event.
for (const delay of [500, 1500, 3000, 6000]) {
setTimeout(onInsetsInjected, delay)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('archy-insets', onInsetsInjected)
})
// Re-measure on route changes
+14 -1
View File
@@ -112,9 +112,18 @@
</transition>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between p-3 bg-white/10 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold">&#x20bf;</span>
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
</div>
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-orange-500" role="img" aria-label="On-chain" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
@@ -232,6 +241,10 @@ defineEmits<{
const showIncomingTxPanel = ref(false)
const walletTotal = computed(() =>
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
)
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
@@ -362,13 +362,79 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.116-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.116-alpha</span>
<span class="text-xs text-white/40">July 27, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nodes no longer get stuck on "server starting up" after an update or reboot. The backend now reports ready immediately and recovers its apps in the background, and it always restarts itself if it ever goes down the days-long "server starting up" hang is gone.</p>
<p>Installing apps no longer crashes the node. A recent change that made app screens reachable over the mesh was holding onto every app's port in advance, so installing an app collided with it and the port-cleanup took the backend down and rolled the install back. Installs are clean now.</p>
<p>Rolls up v1.7.115: app screens and the dashboard load over the mesh out of the box, with IPv6 support end to end, and nodes rejoin the mesh in seconds after their rendezvous point restarts.</p>
</div>
</div>
<!-- v1.7.115-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.115-alpha</span>
<span class="text-xs text-white/40">July 26, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.</p>
<p>The node's web interface also answers on IPv6 everywhere it answers on IPv4 the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.</p>
<p>Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.</p>
</div>
</div>
<!-- v1.7.114-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.114-alpha</span>
<span class="text-xs text-white/40">July 26, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.</p>
<p>The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.</p>
<p>Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.</p>
<p>Type on-chain amounts in whichever unit you think in a sats/BTC switch on the amount field converts as you type.</p>
<p>Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.</p>
</div>
</div>
<!-- v1.7.113-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.113-alpha</span>
<span class="text-xs text-white/40">July 25, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet only the amount you meant to send leaves it.</p>
<p>Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.</p>
<p>The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.</p>
<p>The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.</p>
</div>
</div>
<!-- v1.7.112-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.112-alpha</span>
<span class="text-xs text-white/40">July 22, 2026</span>
<span class="text-xs text-white/40">July 23, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.</p>
<p>Plug in a game controller and drive the whole TV interface with it navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.</p>
<p>The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.</p>
<p>Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.</p>
<p>Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.</p>
<p>The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.</p>
<p>Every app your node serves on your home network is now also reachable over the mesh remote access covers the apps themselves, not just the dashboard.</p>
<p>Selling files: you now choose which payment methods you accept (Lightning, ecash, ) and buyers are only offered those enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.</p>
<p>Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.</p>
<p>Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step no more accidental purchases.</p>
<p>Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.</p>
<p>A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.</p>
<p>Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address switching bitcoin versions no longer strands them.</p>
<p>If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.</p>
<p>Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>

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