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
59 changed files with 2862 additions and 365 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"
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 38
versionName = "0.5.18"
versionCode = 45
versionName = "0.5.25"
vectorDrawables {
useSupportLibrary = true
@@ -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()
@@ -113,6 +131,7 @@ class ArchyVpnService : VpnService() {
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
@@ -153,19 +172,28 @@ class ArchyVpnService : VpnService() {
} catch (_: Exception) {
emptyList()
}.distinct()
for ((ula, port) in targets) {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
20_000,
)
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.
@@ -174,8 +202,75 @@ 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)
@@ -183,6 +278,7 @@ class ArchyVpnService : VpnService() {
}
override fun onDestroy() {
unregisterNetworkHandoff()
FipsNative.stop()
scope.cancel()
super.onDestroy()
@@ -41,7 +41,15 @@ object FipsManager {
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
peersDirty = true
_consentNeeded.value = 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. */
@@ -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,27 +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, onMeshParty)
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
@@ -105,16 +126,14 @@ 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)?,
) {
@@ -124,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
}
@@ -139,161 +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,
)
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
)
// Phone↔phone mesh pairing + chat
if (onMeshParty != null) {
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
/** 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)
}
}
}
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
/** 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
},
)
}
}
}
}
}
@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,
)
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,9 +12,11 @@ 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
@@ -177,11 +179,25 @@ 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()
@@ -189,6 +205,7 @@ fun AppNavHost(
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
)
}
@@ -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, onMeshParty: (() -> Unit)? = null) {
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, onMeshParty: (() -> Unit)? = null) {
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, onMeshParty: (() -> Unit)? = null) {
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, onMeshParty: (() -> Unit)? = null) {
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
@@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
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, onMeshParty: (() -> Unit)? = null) {
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, onMeshParty: (() -> Unit)? = null) {
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,10 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
}
}
},
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() } },
)
@@ -88,8 +88,11 @@ 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
@@ -179,6 +182,38 @@ private fun injectSafeAreaVars(view: WebView) {
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,
@@ -198,13 +233,17 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
}
/** 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. */
* (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
lanUrl
meshUrl ?: lanUrl
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
@@ -245,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.
@@ -323,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
@@ -708,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
@@ -726,7 +778,7 @@ fun WebViewScreen(
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
onRemoteInput()
showHubMenu = true
}
}
}
@@ -851,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)
}
},
)
}
}
@@ -897,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) }
@@ -935,12 +1059,21 @@ private fun InAppBrowser(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
// Bottom inset handled by the touch-shield strip below the bar —
// NOT by padding: a padded area only paints, it doesn't consume,
// so taps in the gesture strip fell straight THROUGH this overlay
// into the kiosk's tab bar behind it (accidental AIUI-tab hits).
// 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 + WindowInsetsSides.Top)
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
),
) {
// WebView + loading overlay fill the area above the bottom control bar.
@@ -992,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) {
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 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.
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.112-alpha"
version = "1.7.116-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.113-alpha"
version = "1.7.116-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -129,8 +129,10 @@ impl RpcHandler {
"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,
+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)`.
///
@@ -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;
+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
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;
+23 -10
View File
@@ -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())
+19 -5
View File
@@ -87,6 +87,18 @@ const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
/// Maximum reconnect delay (cap for exponential backoff).
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
/// Minimum time a session must run before we trust it enough to reset
/// backoff to the minimum. Without this gate, a device that connects then
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
/// auto-reset-circuit boards alike), turning a device that's merely
/// unstable into a self-sustaining boot loop that outlasts whatever
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
/// flash left it in a marginal state.
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
/// Number of consecutive write failures before we consider the device dead
/// and trigger a reconnection cycle.
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
@@ -560,6 +572,7 @@ pub fn spawn_mesh_listener(
return;
}
let session_start = std::time::Instant::now();
match session::run_mesh_session(
&state,
&data_dir,
@@ -582,13 +595,14 @@ pub fn spawn_mesh_listener(
{
Ok(()) => {
info!("Mesh session ended cleanly");
// Session was established before ending — reset backoff
reconnect_delay = RECONNECT_DELAY_INIT;
// Only trust a session that actually ran for a while —
// see STABLE_SESSION_THRESHOLD's doc comment.
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
reconnect_delay = RECONNECT_DELAY_INIT;
}
}
Err(e) => {
// Check if session was ever connected (vs failed to open)
let was_connected = state.status.read().await.device_connected;
if was_connected {
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
reconnect_delay = RECONNECT_DELAY_INIT;
}
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
+38 -7
View File
@@ -269,10 +269,23 @@ 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(),
});
}
for path in &paths {
debug!(path = %path, "Probing for mesh radio device");
@@ -359,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
@@ -366,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 {
@@ -385,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(),
@@ -963,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 {
@@ -986,6 +1014,7 @@ pub(super) async fn run_mesh_session(
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
Some(path),
)
.await?
}
@@ -996,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
{
+12 -3
View File
@@ -217,11 +217,20 @@ impl MeshtasticDevice {
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
// auto-reset) reset on a DTR/RTS transition, so deassert both and
// settle before the handshake below. 300ms is nowhere near a real
// firmware boot time (LoRa radio init alone can take longer) —
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
// Meshtastic's open() doing this same reset, a single auto-detect
// cycle trying multiple protocols in sequence kept re-resetting the
// board before it ever finished booting from the PREVIOUS attempt's
// reset, on both a Heltec V3 and V4, regardless of firmware family —
// a self-sustaining "never finishes booting" loop with a boot-time
// root cause hiding behind what looked like a per-protocol failure.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(Duration::from_millis(2000)).await;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self {
+50 -4
View File
@@ -38,6 +38,14 @@ use tokio::sync::watch;
use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// How long `MeshService::stop()` waits for the listener task to notice its
/// shutdown signal and exit gracefully before force-aborting it. See
/// `stop()`'s doc comment for the real incident this guards against: without
/// a hard abort fallback, a slow-to-notice listener could be left running
/// forever, orphaned, racing a later independently-started listener on the
/// same serial port.
const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
@@ -967,8 +975,36 @@ impl MeshService {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.listener_handle.take() {
let _ = handle.await;
if let Some(mut handle) = self.listener_handle.take() {
// Bounded wait for graceful shutdown, with a hard abort as
// fallback — confirmed live 2026-07-23: a caller-side timeout
// wrapping stop() (mesh::flash's STOP_LISTENER_TIMEOUT) cancelled
// this await when the listener was slow to notice its shutdown
// signal (mid multi-candidate probe), but `.take()` above had
// already cleared `listener_handle` to None — so MeshService
// believed it was stopped while the task kept running, orphaned
// (dropping a JoinHandle does not abort the task it points to).
// A later start() then spawned a second, fully independent
// listener session racing the orphaned one on the same serial
// port — neither could ever get a clean response, so every
// mesh.configure/probe against that device failed indefinitely
// even though the device itself was fine.
//
// Awaiting `&mut handle` (not `handle` by value) is what makes
// the fallback possible: the Future is polled through the
// reference, so if the timeout fires, this task's own `handle`
// binding is still ours to call `.abort()` on afterward —
// unlike moving `handle` into the timeout future outright, which
// would drop (and thus orphan) it on timeout with nothing left
// to abort.
if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut handle)
.await
.is_err()
{
warn!("Mesh listener did not shut down gracefully in time — aborting it");
handle.abort();
let _ = handle.await;
}
}
if let Some(handle) = self.deadman_handle.take() {
handle.abort();
@@ -1027,8 +1063,18 @@ impl MeshService {
/// 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");
}
}
}
drop(status);
listener::probe_device(path).await
+28 -3
View File
@@ -58,11 +58,20 @@ impl MeshcoreDevice {
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
// auto-reset) reset on a DTR/RTS transition, so deassert both and
// settle before the handshake below. 300ms is nowhere near a real
// firmware boot time (LoRa radio init alone can take longer) —
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
// Meshtastic's open() doing this same reset, a single auto-detect
// cycle trying multiple protocols in sequence kept re-resetting the
// board before it ever finished booting from the PREVIOUS attempt's
// reset, on both a Heltec V3 and V4, regardless of firmware family —
// a self-sustaining "never finishes booting" loop with a boot-time
// root cause hiding behind what looked like a per-protocol failure.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(Duration::from_millis(2000)).await;
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
@@ -547,14 +556,30 @@ 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.
///
/// 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_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 canonical = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| std::path::PathBuf::from(path));
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());
}
}
+142
View File
@@ -988,8 +988,54 @@ impl Server {
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(),
active_connections.clone(),
@@ -1012,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");
@@ -1019,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
+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(())
}
+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 |
+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.113-alpha",
"version": "1.7.115-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.113-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.113-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
@@ -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>
+203 -12
View File
@@ -78,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'">
@@ -117,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'"
@@ -131,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"
/>
@@ -147,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">
@@ -165,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
@@ -208,7 +268,47 @@ 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('')
@@ -248,6 +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 })
// --- 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
@@ -367,6 +539,15 @@ 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
}
@@ -439,18 +620,28 @@ 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() },
})
successInfo.value = { amount: paidAmount, methodLabel: 'Paid over Lightning', hash: 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 },
})
successInfo.value = {
amount: paidAmount,
+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 }>({
+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('')
}
+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)
+11 -10
View File
@@ -1320,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
@@ -1341,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">
@@ -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
@@ -362,6 +362,44 @@ 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">
+2 -14
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
const { t } = useI18n()
@@ -329,20 +330,7 @@ defineExpose({ loadBackups })
</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" />
<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" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
@@ -291,10 +291,10 @@ async function unifiedSend() {
unifiedSendError.value = t('web5.pasteInvoice')
return
}
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: unifiedSendDest.value.trim() },
})
// 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: unifiedSendDest.value.trim() })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
sendResultHash.value = res.payment_hash
} else {
if (!unifiedSendDest.value.trim()) {
+19 -18
View File
@@ -1,28 +1,29 @@
{
"version": "1.7.113-alpha",
"release_date": "2026-07-25",
"changelog": [
"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 \u2014 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."
"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."
],
"components": [
{
"current_version": "1.7.116-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
"name": "archipelago",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago",
"sha256": "3027a33f6e98ddd87b7a37e852501c1e4323823174aa5e164f4d484e9221b201",
"size_bytes": 51560408
"new_version": "1.7.116-alpha",
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
"size_bytes": 51787208
},
{
"name": "archipelago-frontend-1.7.113-alpha.tar.gz",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago-frontend-1.7.113-alpha.tar.gz",
"sha256": "f830d6d2180ac9fa9623fe5120f4591a53b18ae1f85cd17716517f783ead71cf",
"size_bytes": 177963652
"current_version": "1.7.116-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
"new_version": "1.7.116-alpha",
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
"size_bytes": 178062490
}
]
],
"release_date": "2026-07-27",
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.116-alpha"
}
+19 -18
View File
@@ -1,28 +1,29 @@
{
"version": "1.7.113-alpha",
"release_date": "2026-07-25",
"changelog": [
"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 \u2014 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."
"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."
],
"components": [
{
"current_version": "1.7.116-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
"name": "archipelago",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago",
"sha256": "3027a33f6e98ddd87b7a37e852501c1e4323823174aa5e164f4d484e9221b201",
"size_bytes": 51560408
"new_version": "1.7.116-alpha",
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
"size_bytes": 51787208
},
{
"name": "archipelago-frontend-1.7.113-alpha.tar.gz",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago-frontend-1.7.113-alpha.tar.gz",
"sha256": "f830d6d2180ac9fa9623fe5120f4591a53b18ae1f85cd17716517f783ead71cf",
"size_bytes": 177963652
"current_version": "1.7.116-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
"new_version": "1.7.116-alpha",
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
"size_bytes": 178062490
}
]
],
"release_date": "2026-07-27",
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.116-alpha"
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# Gated ISO release build — the single command that turns a signed release
# on `main` into a tested installer ISO.
#
# Stages (fail-fast, each logged with timing):
# 0. preflight — Linux, clean tree on main, version parity across
# Cargo.toml / package.json / releases/manifest.json /
# CHANGELOG / git tag, manifest signature present
# 1. gates — tests/release/run.sh (static + frontend + backend
# slice), strict catalog drift, FULL cargo test suite
# 2. artifacts — release binary embeds the version, frontend dist
# matches, AIUI present (OTA-strip regression guard)
# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
# 5. qemu — headless boot test (skippable with --no-qemu)
#
# Usage:
# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
#
# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-gates) SKIP_GATES=1 ;;
--no-qemu) NO_QEMU=1 ;;
--bundled) UNBUNDLED=0 ;;
--rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
shift
done
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
PASS=() FAIL=()
stage() { # stage <name> <cmd...>
local name="$1"; shift
local t0=$SECONDS
echo
echo "═══ [$name] $*"
if "$@"; then
echo "═══ [$name] PASS ($((SECONDS - t0))s)"
PASS+=("$name")
else
local rc=$?
echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
FAIL+=("$name")
summary 1
fi
}
summary() {
echo
echo "──────── ISO release build summary ────────"
printf 'PASS: %s\n' "${PASS[@]:-none}"
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
exit "${1:-0}"
}
# ── Stage 0: preflight ───────────────────────────────────────────────
preflight() {
[ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
[ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
if [ -n "$(git status --porcelain)" ]; then
echo "working tree is not clean — release ISOs build from committed state only:"
git status --porcelain | head -20
return 1
fi
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
local ui_ver manifest_ver
ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
echo " Cargo.toml: $VERSION"
echo " package.json: $ui_ver"
echo " releases/manifest: $manifest_ver"
[ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
[ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
head -5 CHANGELOG.md | grep -qF "v$VERSION" \
|| { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|| { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
# The ISO must only ever be cut from a ceremony-signed manifest.
python3 - <<'EOF' || return 1
import json, sys
m = json.load(open("releases/manifest.json"))
sig, by = m.get("signature"), m.get("signed_by", "")
if not sig or not by.startswith("did:key:"):
print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
sys.exit(1)
print(f" manifest signed by {by[:32]}…")
EOF
echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
}
stage "preflight" preflight
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
# ── Stage 1: gates ───────────────────────────────────────────────────
if [ "$SKIP_GATES" = "0" ]; then
stage "release-gate-harness" bash tests/release/run.sh
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
# Full Rust suite — the release harness only runs a 6-module slice;
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
else
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
fi
# ── Stage 2: artifact verification ───────────────────────────────────
verify_artifacts() {
local bin="core/target/release/archipelago"
[ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
strings "$bin" | grep -qF "$VERSION" \
|| { echo "release binary does not embed $VERSION — stale build"; return 1; }
echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
[ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
|| { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
echo " frontend dist contains $VERSION"
# AIUI must ride inside the dist BEFORE packaging or OTA upgrades
# silently strip it from nodes in the field.
[ -f web/dist/neode-ui/aiui/index.html ] \
|| { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
echo " AIUI present in frontend dist"
}
stage "verify-artifacts" verify_artifacts
# ── Stage 3: build the ISO ───────────────────────────────────────────
build_iso() {
local env_args=(
UNBUNDLED="$UNBUNDLED"
BUILD_FROM_SOURCE=0
DEV_SERVER=localhost
ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
)
[ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
}
stage "build-iso" build_iso
find_iso() {
ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
}
ISO="$(find_iso)"
[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
# ── Stage 4: mount-level smoke test ──────────────────────────────────
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
# The ISO's kernel cmdline has no serial console, so the serial-log
# sanity grep can miss a perfectly healthy boot. Run it, report it,
# but don't fail an otherwise-green build on it.
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
echo "═══ [qemu-boot] PASS"
PASS+=("qemu-boot")
else
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
PASS+=("qemu-boot(inconclusive)")
fi
else
echo; echo "═══ [qemu-boot] SKIPPED"
fi
# ── Done ─────────────────────────────────────────────────────────────
SHA_FILE="$ISO.sha256"
[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
echo
echo "════════════════════════════════════════════════════"
echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
echo "════════════════════════════════════════════════════"
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
echo
echo " Next steps (publisher, offline mnemonic required):"
echo " 1. scripts/sign-iso-checksums.sh $ISO"
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
echo " v$VERSION Gitea release assets"
summary 0
+1 -1
View File
@@ -142,7 +142,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
# SIGPIPE-safe: use awk to read only the first line and exit,
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
# don't kill the whole `set -euo pipefail` script.
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
case "$root_mode" in
drwxr-xr-x|drwxr-x*x*)
echo " Tarball root perms OK: $root_mode"
+45
View File
@@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
return "\n".join(lines)
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
"""
distinct = sorted(set(list(ports.values()) + extra_ports))
lines = [
"//! 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] = &[",
]
lines.extend(f" {port}," for port in distinct)
lines.extend(["];", ""])
return "\n".join(lines)
# Keep in lockstep with APP_PORTS overrides in
# neode-ui/src/views/appSession/appSessionConfig.ts.
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
with path.open("r", encoding="utf-8") as fh:
catalog = json.load(fh)
@@ -178,6 +204,11 @@ def main() -> int:
default=[],
help="Catalog JSON path to update. May be passed multiple times.",
)
parser.add_argument(
"--rust-app-ports",
default="core/archipelago/src/fips/app_ports.rs",
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
)
parser.add_argument(
"--app-session-config",
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
@@ -201,6 +232,20 @@ def main() -> int:
print(f"{path}: updated")
else:
print(f"{path}: updated 0 fields")
if args.rust_app_ports:
ports = {
app_id: port
for app_id, app in manifests.items()
if (port := manifest_launch_port(app))
}
rust_path = Path(args.rust_app_ports)
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
if rust_old != rust_content:
rust_path.write_text(rust_content, encoding="utf-8")
print(f"{rust_path}: updated")
else:
print(f"{rust_path}: updated 0 fields")
print(f"total_updated={total}")
return 0
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Mount-level smoke test for an Archipelago installer ISO.
#
# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
# payload, and — the check that has bitten before — that the backend
# binary inside the ISO actually embeds the version the filename claims.
#
# Usage:
# scripts/iso-smoke-test.sh <path-to-iso> [expected-version]
#
# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
# (loop mount). Exits non-zero on the first hard failure; prints a
# PASS/FAIL table either way.
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ISO="${1:-}"
EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
echo "usage: $0 <path-to-iso> [expected-version]" >&2
exit 2
fi
FAIL=0
ok() { echo " OK: $*"; }
bad() { echo " FAIL: $*"; FAIL=1; }
warn() { echo " WARN: $*"; }
echo "ISO smoke test"
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " Version: $EXPECTED_VERSION (expected)"
# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
case "$(basename "$ISO")" in
*"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
*) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
esac
MNT="$(mktemp -d)"
INITRD_DIR=""
cleanup() {
sudo umount "$MNT" 2>/dev/null || true
rmdir "$MNT" 2>/dev/null || true
[ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
}
trap cleanup EXIT
if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
echo " FAIL: could not loop-mount ISO" >&2
exit 1
fi
# ── Required boot + installer files ──────────────────────────────────
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
isolinux/isolinux.bin isolinux/isolinux.cfg \
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
archipelago/auto-install.sh archipelago/rootfs.tar; do
if [ -e "$MNT/$f" ]; then
ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
else
bad "missing $f"
fi
done
# ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live"
else
bad "grub.cfg missing boot=live"
fi
# ── initrd must contain live-boot scripts ────────────────────────────
if command -v unmkinitramfs >/dev/null 2>&1; then
INITRD_DIR="$(mktemp -d)"
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
ok "initrd has live-boot scripts"
else
bad "initrd missing live-boot scripts"
fi
else
warn "unmkinitramfs not installed — skipping initrd live-boot check"
fi
# ── Backend binary inside the ISO embeds the expected version ────────
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
# shipped and the fleet rejected its fips.yaml on Activate)
BIN_IN_ISO=""
if [ -f "$MNT/archipelago/bin/archipelago" ]; then
BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
ok "payload backend binary embeds $EXPECTED_VERSION"
else
bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
fi
else
# Fall back to the copy inside rootfs.tar
TMPBIN="$(mktemp -d)"
if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
usr/local/bin/archipelago 2>/dev/null; then
if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
ok "rootfs backend binary embeds $EXPECTED_VERSION"
else
bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
fi
else
bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
fi
sudo rm -rf "$TMPBIN"
fi
# ── Frontend payload present ─────────────────────────────────────────
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
ok "frontend payload (archipelago/web-ui/index.html)"
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
ok "AIUI included in frontend payload"
else
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
fi
else
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
fi
echo
if [ "$FAIL" = "1" ]; then
echo "ISO SMOKE TEST: FAILED"
exit 1
fi
echo "ISO SMOKE TEST: PASSED"
+1 -1
View File
@@ -62,7 +62,7 @@ summary() {
# ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
# Every release must surface its CHANGELOG entry in the Settings "What's New"
# modal. The modal hardcodes a block per version and has drifted behind before
# (sat at v1.7.84 while the fleet shipped to v1.7.92). Fail if any CHANGELOG