Compare commits

...

182 Commits

Author SHA1 Message Date
archipelago
8c2a55eb4c docs: add FED-05 inter-node lightning channel-open UX to Phase 1 2026-07-29 10:59:48 -04:00
archipelago
07b167d68e docs: insert Phase 1 federation/mesh hardening + Phase 2 UI performance; renumber 3-8 2026-07-29 10:59:08 -04:00
archipelago
21de734385 docs(qr): scanner snappiness research + companion-dev handover; 10/s native decode
All checks were successful
Demo images / Build & push demo images (push) Successful in 4m16s
Research findings and a concrete split of work: web-side items for this
repo (pre-warm camera, torch toggle, continuous focus, keep-stream-
alive) and a native handover list for the companion dev (pre-warmed
CameraX + ML Kit, QR-only format, 720p keep-latest analysis, torch,
zoom nudge, haptic dismiss) with acceptance criteria and how to
measure. Quick win landed now: live scan runs at 10 scans/sec when the
platform has a native BarcodeDetector, keeping 4/s only for the
JS-worker fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:51:39 -04:00
archipelago
8bea3707ca feat(lightning): instant pay feedback, balances never vanish mid-payment
Some checks failed
Demo images / Build & push demo images (push) Failing after 4m20s
Framework-pt report: a paid invoice stalled the UI with no success
shown, and lightning/total balances disappeared until it settled.
Three compounding causes, three fixes:

- Backend payinvoice's synchronous wait drops 120s → 8s. Fast payments
  (the majority) still settle in one round trip; slow multi-hop routes
  return pending + payment_hash quickly and the caller's 3s poll takes
  over — instead of the modal freezing for up to two minutes.
- payLightningInvoice gains an onPending hook: SendBitcoinModal and the
  scan modal now flip to a visible "Settling…" success pane the moment
  the payment goes pending (safe to close), and the ongoing poll
  upgrades it to Paid — or replaces it with LND's real failure.
- One slow lnd.getinfo poll (5s budget) flipped the Home wallet card to
  "disconnected", hiding balances the user already knew. Three
  consecutive failures are now required (~30s) before the card gives up;
  last-known balances keep rendering throughout.

rpc-client tests 75/75.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:47:39 -04:00
archipelago
b7310ecfaf docs: create onboarding summary 2026-07-29 10:46:33 -04:00
archipelago
938dfb1453 docs: ingest 11 docs from docs/ (#2387) 2026-07-29 10:45:57 -04:00
archipelago
49ec294dea fix(update): concurrent apply reads as progress, not failure; idle-IO extraction
All checks were successful
Demo images / Build & push demo images (push) Successful in 5m18s
"Another update operation is already running" surfaced as a scary
failure while the update was in fact applying fine (OptiPlex, v1.7.118
rollout). The apply path now joins the in-flight install — same
overlay, same wait-for-new-version polling — and a concurrent download
attempt shows a calm in-progress note (EN+ES strings added). The
backend's tarball extractions run under ionice -c3 nice -n10 so a
200MB update can't starve podman/status calls into multi-minute
timeouts on small disks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:38:11 -04:00
archipelago
00f1892bf8 feat(demo): auto-firing device-detection modal + transport pills on most peers
- ~8s into a session a second "freshly plugged" RNode appears on
  /dev/ttyACM0, so the global mesh setup modal (and its flash step)
  demos itself shortly after opening the Mesh page. Fixed plugged_at
  means "Not now" sticks for the whole browser session.
- Transport pills (LoRa/FIPS/Tor) now offered for every demo peer
  except mountain-node, which stays radio-only for contrast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:36:43 -04:00
archipelago
9e77a4229c docs: map existing codebase 2026-07-29 10:25:54 -04:00
archipelago
bea7f24a4f feat(demo): mock coverage for v1.7.117/118 features
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m19s
- mesh.transport-advice: peer 1 federated (LoRa/FIPS/Tor pills in the
  image modal), others radio-only.
- Scripted Flash LoRa job: flash-list-firmware + flash-device +
  flash-status advancing download → erase → write → done over ~35s
  with live log tail and percent, plus cancel.
- Demo messages carry per-message transport (meshcore/reticulum/fips/
  tor variety for the pills + animated route modal) and
  sender_pubkey/sender_seq so reactions/replies work.
- Chat-action acks: send-reaction/reply/read-receipt, edit/delete/
  forward, send-channel, mesh.refresh, reboot-radio.
- Services classification demo: self-deployed "podsteadr" stack — main
  app launchable, its MediaMTX backend (ui:null) files under Services
  with no Launch button, mirroring ui_detection's verdicts.

All verified against the running mock: 11/11 runtime checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:20:41 -04:00
archipelago
14feb1feb9 chore: release v1.7.118-alpha
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m19s
2026-07-29 08:35:38 -04:00
archipelago
d2642856c1 chore: fold Cargo.lock version bump from v1.7.117 release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:54:32 -04:00
archipelago
338bfd43a7 docs: v1.7.118-alpha changelog + What's New
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:53:44 -04:00
archipelago
3da1d0b0f7 fix(ui): mesh unread badges persist seen-state; more button + animated route modal
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
Unread badges came back on every visit (framework showed a phantom "2"
with nothing new): unreadCounts was memory-only, so each page load
replayed the entire message history as "new". Seen-state now persists
as a per-contact highest-seen-message-id watermark in localStorage
(ids are backend-monotonic across restarts); first run after this
ships seeds the watermark from history so nobody gets a wall of stale
badges. Opening a chat advances and persists the watermark for all
twins of the merged conversation.

The hop-route modal the user asked for is now reachable from a visible
per-message "⋯" button (the transport pill remains clickable too), has
a fallback title/branch for messages that predate transport tracking,
and animates: endpoints and link reveal in sequence, a pulse travels
the link, and relay dots blink in order — all disabled under
prefers-reduced-motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:52:56 -04:00
archipelago
500aebb3e2 fix(mesh): radio tools ship via OTA + ISO; transport flag gated on daemon support
v1.7.117 broke Reticulum mesh on OTA-only fleet nodes two ways: the
update swaps only the backend binary and frontend tarball, so nodes
kept a stale archy-reticulum-daemon whose argparse exits on the new
--enable-transport flag (mesh session died on every spawn — confirmed
on framework-pt), and they never had archy-rnodeconf at all, so the
in-app Flash LoRa flow failed with a bare "No such file or directory".

Four-part fix:
- The Rust supervisor probes `daemon --help` and only passes
  --enable-transport when the daemon advertises it; unsupported daemons
  run edge-only exactly as pre-1.7.117 (tested against stub daemons
  both ways + missing-binary fail-safe).
- Both PyInstaller tools ride the frontend tarball's runtime payload
  (radio-tools/) and bootstrap.rs promotes them to /usr/local/bin on
  startup when bytes differ — the first OTA path that ever updates
  them. create-release.sh now rebuilds them every release and the
  manifest script hard-fails if they're missing.
- The ISO bundles archy-rnodeconf alongside the daemon (it never did).
- Flash LoRa reports "tool not installed — update the node" instead of
  the bare spawn error when rnodeconf is absent everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:51:22 -04:00
archipelago
04c056acdb chore: release v1.7.117-alpha
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m16s
2026-07-29 07:01:45 -04:00
archipelago
d0463196a3 docs: changelog + What's New — Reticulum relay (transport mode) bullet
Some checks failed
Demo images / Build & push demo images (push) Failing after 59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:23:05 -04:00
archipelago
8e0939170c feat(mesh): archy nodes run as RNS transport nodes
The Reticulum daemon gains --enable-transport, which writes
enable_transport = yes into the RNS config it regenerates on every
start, and the Rust supervisor always passes it. Archy nodes now relay
RNS traffic and rebroadcast announces, so archy nodes (and Sideband/
NomadNet peers) beyond direct RF range discover and reach each other
through any archy node in between — edge-only operation left every
node limited to its own radio horizon. RNS's per-interface airtime
caps bound the extra announce overhead on LoRa.

Verified: config generation with the flag on/off, daemon --selftest
green with transport enabled, mesh test module 116/116.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:21:43 -04:00
archipelago
5c19effdd1 style: cargo fmt — clear formatting drift blocking the release gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:53:33 -04:00
archipelago
8e51164321 docs: v1.7.117-alpha changelog covers everything since 1.7.116 + What's New sync
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m4s
Rewrites the prepared 1.7.117 section to include the mesh flash flow,
first-class Reticulum fixes, radio-first routing, mesh chat polish, the
transactions-modal phone fixes, services-vs-apps classification, the
lightning slow-payment fix, cached-resource page loads, load-shedding,
FIPS uptime hardening, and companion 0.5.25. What's New modal block
regenerated from the new bullets (sync-whats-new --check passes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:49:45 -04:00
archipelago
da14c135e4 feat(apps): backend-only services classify as services with no Launch button
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
A published port no longer implies a web UI. The package scanner used to
synthesize interfaces.main.ui="true" for any container with a port or
onion address, so headless backends — including self-deployed compose
stacks like podsteadr — showed up as launchable apps. New ui_detection
module decides instead: a manifest interfaces declaration (catalog
overlay first, disk second) is definitive; undeclared apps get a short
HTTP probe of the launch port (HTML page, redirect, or browser auth
wall = UI; JSON APIs, raw TCP, dead ports = service), with cached
verdicts and probes gated on running containers. Frontend canLaunch
now refuses curated services outright and only treats a bare runtime
address as launchable for curated known apps.

Works identically for manifest apps and containers deployed by hand
outside the orchestrator. ui_detection tests 6/6, frontend suite
696/696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:48:38 -04:00
archipelago
d7c5d39747 feat(ui): transactions modal filter tabs pin to top with blur on scroll
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m59s
Chips become sticky inside the modal's scroll region with a dimmed
blurred band, so the rail filter stays reachable while rows scroll
underneath. Verified in headless chromium: pinned at scroll-region top
after deep scroll, touch swipes starting on the chips still scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:01:22 -04:00
archipelago
6ba39041d0 feat(ui): mesh header "Flash LoRa" button opens the in-app flash flow
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s
Replaces the external flasher.meshcore.co.uk link with a button that
opens the global device-setup modal directly at its flash step, via a
new manual entry point in the mesh store (flashFlowPath). Manual opens
target the connected radio (else the first detected stick), skip the
read-only probe — the port is held by the live session and a second tty
opener corrupts it; the backend flash job stops the listener itself —
and close the modal instead of stepping back to the detection screen.
Button is disabled with a hint when no radio is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 04:43:38 -04:00
archipelago
c99f1c7b77 fix(ui): transactions modal touch scrolling on phones
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m3s
The tx list kept a vestigial overflow-y-auto from before the modal
contract refactor made BaseModal's slot wrapper the scroller. With a
modal open, modal-scroll-locked applies overscroll-behavior:contain to
every .overflow-y-auto inside the overlay, so touch scrolls latched
onto the non-scrollable inner list and could not chain up to the real
scroller — the modal was unscrollable on any touch device. Wheel input
latches onto the scrollable ancestor directly, which is why desktop
never showed it. Verified with headless-chromium touch synthesis at
360x640/320x568: list scrolls to bottom, background stays contained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 04:32:41 -04:00
archipelago
7326bb9262 fix(mesh): unpinned preferred path must stay an auto-detect candidate
Post-merge regression from combining two individually-correct changes:
main's 2026-07-23 fix makes open_preferred_path bail WITHOUT touching the
port when no device_kind is pinned, while the hw-config branch's skip_path
dedup excludes the preferred path from the auto-detect fallback on the
assumption it was already probed this cycle. Together, on a single-radio
node with no pin (the common fleet state), the only candidate was never
probed at all and the mesh never came up — hit live on archi-dev-box
right after deploying merged main.

Fix: when device_kind is None, skip open_preferred_path entirely and go
straight to auto_detect_and_open with skip_path=None. The pinned path
keeps the existing probe-then-skip fallback.

Verified live on archi-dev-box: radio auto-detected, Reticulum daemon
ready, 5 persisted peers loaded. Mesh tests 116 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:34:02 -04:00
archipelago
3589c3a6b9 Merge archy-hwconfig into main — hw-config flash-firmware flow
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m3s
Brings the hw-config branch (radio firmware flashing modal step 3,
flasher packaging + PyInstaller runtime hook, self-update hardening)
onto main, already reconciled with the probe/dedup/name work via
fb1f4bf0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:02:25 -04:00
archipelago
0aa3941c40 feat(ui): mesh chat polish — transport pills in image modal, hop-route modal, reaction dropdown, real read-tracking
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m29s
- Image quality modal: 'Send via' pills (LoRa / FIPS / Tor) when the
  peer is federation-reachable — mesh.transport-advice now returns
  has_fips + last_transport alongside has_tor. Picking FIPS/Tor routes
  the image over the content-ref path instead of the radio.
- Attachment modals (transport chooser, image quality, new hop modal)
  Teleport to body so the backdrop dims the FULL viewport — rendered
  in-place they sat inside a transformed glass panel that trapped
  position:fixed to the right chat panel.
- Click a message's transport pill → route modal: radio hops + live
  SNR/RSSI quality for LoRa transports, overlay/circuit shape for
  FIPS/Tor, delivery + E2E state.
- Reactions move behind a compact 'React ▾' dropdown with a larger
  12-emoji palette.
- Unread badges now clear like a normal chat app: opening a contact
  clears ALL twins of the merged conversation (badge sums every
  contact_id — clearing just the clicked one left it stuck), and only
  once the chat has scrolled to the latest messages; scrolled up into
  history, new arrivals accumulate until you scroll back down.
- Refresh button shows only the spinner while refreshing (text+spinner
  overflowed the fixed button width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:01:36 -04:00
archipelago
e62f911810 fix(mesh): Reticulum resource transfers actually deliver — 4 root causes
E2E-verified both directions dev-box<->x250 over real RF (5KB image ~60s):

1. Sender daemon never called link.identify() — receiver's
   get_remote_identity() was None, so every arrived transfer carried an
   empty source_hash and the Rust side dropped it (now also warns
   instead of silently vanishing it).
2. Receiver treated resource.data as bytes, but RNS hands a concluded
   Resource's data as a file-like BufferedReader — b64encode raised
   TypeError and the transfer was lost even when attributed.
3. Radio twins of merged contacts carry the peer's Archipelago ed25519
   key as pubkey_hex, not an RNS hash — prefix lookup could never match
   ('Unknown Reticulum prefix', observed live). resolve_dest_hash now
   falls back to matching the announce-bound arch_pubkey_hex.
4. The daemon RPC socket kept asyncio's default 64KiB line limit; any
   attachment >~48KB overflowed it and tore down the whole daemon
   connection ('reticulum-daemon is gone'). Raised to 16MiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:00:44 -04:00
archipelago
fb1f4bf0e3 Merge main into archy-hwconfig — reconcile probe/dedup/name work
Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:03:19 -04:00
archipelago
79c3cc5947 fix(mesh): radio-first transport policy + attachments to merged contacts route via the radio twin
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m50s
Two halves of the same twin-resolution gap, found live while testing
images between archi-dev-box and archy-x250-dev:

- peer_dest_prefix resolved the given contact row's own pubkey. For the
  UI's merged conversation (the federation-synthetic id) that's the
  Archipelago ed25519 identity key, NOT a radio routing key — so every
  Reticulum resource send (images/files over LoRa) failed with 'Unknown
  Reticulum prefix' while the UI showed the message as sent. It now
  resolves through the radio twin (same arch identity, radio-range id).
- send_typed_wire sent EVERY federation-synthetic contact over the
  federation path (FIPS→Tor), even with the same node one LoRa hop away.
  Policy per operator: LoRa first when the payload fits and the radio
  twin is reachable, then FIPS, then Tor. Verified live: text to the
  merged contact now logs 'Radio-first routing' and lands with
  transport=reticulum on the peer.

Also restyles the mesh-chat attachment download controls: the pre-fetch
button was a bare .btn that squished to text width in the narrow mobile
bubble; now a full-width glass pill with a download icon and fetch
spinner, and the on-image overlay swaps the emoji glyph for a crisp SVG
in a properly-sized glass circle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:16:40 -04:00
archipelago
c4f24f3efa test(mesh): mesh/Reticulum test gate — unit tests + daemon selftest + live-node assertions
tests/mesh/run-mesh-tests.sh: cheap-first layers — 116 Rust mesh unit
tests, the daemon --selftest (now also asserting the announce app_data
wire contract and the set_name verb), and opt-in live assertions against
a running node (radio connected, named, mesh.refresh/broadcast, no
ARCHY-blob peer names). Verified green on archi-dev-box and archy-x250-dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:00:58 -04:00
archipelago
0acfba40db docs(reticulum): 2026-07-28 checkpoint — RNode connect + name propagation fixed, E2E verified
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:56:03 -04:00
archipelago
3f76b4960a feat(ui): mesh Refresh/Broadcast feedback, Set Recommended modal with probe progress bar, live list refresh
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m43s
- Refresh button: real handler — calls the new mesh.refresh (radio
  re-query) plus contacts/federation/outbox re-reads, disabled with a
  spinner while running (was an unawaited cache repaint with no feedback
  that skipped half the list's data sources).
- Broadcast button: success ('Sent ✓') and failure states with the error
  in the tooltip; failures no longer vanish as unhandled rejections.
- The store's 5s status poll no longer wipes the error banner each tick.
- Contacts/aliases, federation nodes and the outbox badge refresh every
  ~30s (were mount-only and went permanently stale).
- Peer-list empty state keys on the merged list, so federation rows and
  the channel rows still render with no radio attached.
- Device setup modal: 'Set Recommended' naming, probe progress bar with
  stage labels instead of an anonymous spinner.
- Device panel: name save clears properly (empty = fall back to server
  name) and the confirmation reflects the new live apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:37:16 -04:00
archipelago
a8c4694c36 fix(mesh): first-class Reticulum — probe boot-race, live config apply, name propagation, daemon-death detection
Root causes found and fixed after live debugging on archi-dev-box (all
verified on real RNode hardware, archi-dev-box <-> archy-x250-dev E2E):

- probe_rnode raced the board's own boot: opening the port pulses DTR/RTS
  through USB-UART bridges (CP2102/Heltec V3), the ESP32 power-cycles and
  spends ~2.5-3s in boot ROM, and the KISS DETECT written 300ms after open
  landed in the void — so an RNode could NEVER connect on these boards.
  Now: immediate probe (fast path), then drain-until-quiet boot settle and
  a second DETECT with a fresh response window.
- MeshService::configure() only restarted the listener on enable/disable —
  device_kind/device_path/advert_name/RF-param changes were silent no-ops
  until a full process restart (the setup modal's apply/keep-as-is did
  nothing). Material config changes now bounce the listener; the open
  sequence races the shutdown signal so stop() no longer burns the full
  15s timeout mid-probe; mesh.configure applies in the background instead
  of stalling every status poll behind the service write-lock.
- The mesh name was write-only: config.advert_name had no reader,
  server.set-name never reached the mesh service, and Reticulum's
  set_advert_name was a no-op (daemon display name fixed at spawn, and the
  ARCHY:2 announce blob REPLACED the LXMF display name — every archy node
  was anonymous on RNS). Now: advert_name > server name precedence feeds
  the session, renames restart it live, the daemon gets --display-name at
  spawn plus a set_name RPC verb, and announces carry the LXMF-standard
  msgpack name with the identity blob appended as an extra list element
  stock clients (Sideband/NomadNet) ignore.
- Dead reticulum-daemon was invisible for up to 30min (RX-stall watchdog):
  child exit / RPC-EOF now fails try_recv_frame so the session reconnects.
- Setup modal re-trigger loop: plugged_at used the tty node's mtime, which
  bumps on every open — each probe invalidated the dismissal key. Use
  btime/ctime (only change on real plugs).
- ARCHY:2 identity adverts (re-emitted every 60s over Reticulum) stomped
  the federation twin's real name with a synthetic Archy-… placeholder and
  nulled its position; blob-only announces no longer assert a name, blob
  strings can never become display names, and stale blob names are healed
  at peers.json load.
- mesh.broadcast on Meshtastic sent heartbeat+time only (no identity);
  SendAdvert now also fires a want_response NodeInfo broadcast.
- New mesh.refresh RPC: actively re-queries the radio contact table (the
  UI Refresh button previously only re-read server caches).
- Reticulum peers now track last_advert (announce time) and mark existing
  peers reachable on inbound traffic.
- Boot auto-enable no longer force-enables mesh when an operator
  explicitly disabled it (only fires when no config file exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:36:52 -04:00
archipelago
537c52d11c feat(ui): FIPS network + seed-anchor cards render from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m6s
The two FIPS containers on the Server page were the last network cards
still fetching-on-mount into local refs — every visit was a blank card
until fips.status / fips.list-seed-anchors answered. Both now ride the
cached-resource layer: FipsNetworkCard shares the server.fips-summary
key with the Local Network card's FIPS row (one fetch, never disagree),
seed anchors cache under server.fips-seed-anchors, and mutations
write the RPC's authoritative result straight into the cache. The 15s
status poll skips hidden tabs — revalidate-on-focus covers the return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:48:34 -04:00
archipelago
2971623145 fix(server): accept loop can never starve — shed load instead of parking
The HTTP accept loop parked on acquire_owned() when the connection
budget drained, freezing accept() for every client (the .228
session-flapping / CLOSE-WAIT signature). Permits drained because
half-open clients and hung upstreams held them indefinitely.

- try_acquire_owned + immediate 503-and-close when the budget is
  exhausted; the accept loop itself never blocks
- 30s http1_header_read_timeout drops slowloris/half-open clients
- 900s watchdog bounds non-upgraded connections; websocket upgrades
  are exempt (legitimately long-lived)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:10:39 -04:00
archipelago
43130f33f0 fix(container): companion probes are passive — no builds inside reconcile checks
Root cause of the .198 load spiral (2026-07-28): needs_repair() called
ensure_image_present() every 30s tick to render the expected unit, so
under IO pressure the image-existence check timed out, read as "image
missing", and a 900s podman build ran inside the PROBE while the
companion was up — each build pegging the disk that made probes fail.

- needs_repair() is now build/pull-free: unit file present → service
  is-active (10s cap; a hung systemctl reads as "assume active", never
  as dead) → unit matches one of the three image refs install_one could
  have written → context-newer-than-image staleness only when the unit
  uses the auto-built :latest.
- Per-companion 10-min repair cooldown after a failed install_one, so a
  failing build retries at most every REPAIR_COOLDOWN instead of every
  reconcile tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:54:29 -04:00
archipelago
73228114b9 feat(ui): B5 — /ws/db pushes revalidate cached resources
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m5s
Bridge WebSocket patches into the resource layer: a /peer-health/<onion>
patch invalidates that peer's cloud.peer-browse entry and the federation
node list; /package-data patches invalidate the tor-services list.
invalidate() debounces 800ms and refetches only keys with mounted
subscribers, so patch storms cost one revalidation per key; the 30s
staleness reconciliation remains the backstop for unmapped data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:49:36 -04:00
archipelago
8907cc47d9 feat(ui): Credentials + OpenWrtGateway render from cached resources — B4 complete
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m10s
- Credentials: identity.list + identity.list-credentials become
  useCachedResource entries; explicit reloads still toast on failure.
- OpenWrtGateway: openwrt.get-status caches in the shared store (revisits
  paint the last router state instantly); the connect flow's
  params/No-router-configured semantics are preserved on top of the entry.
- ContainerApps assessed and left as-is: its Pinia store already persists
  across navigation, keeps last data on error, and gates the spinner on
  empty — same class as Apps/Marketplace/Fleet.

This closes the B4 rollout list from docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:27:05 -04:00
archipelago
8fd72b947a test(ui): adapt SWR contract tests to the cached-resource layer — 692/692
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s
The keeps-data-visible-while-refreshing tests for PeerFiles, Server, and
LightningChannels mounted without Pinia (the converted components now
pull the resources store in setup) — add createPinia to the mounts.
LightningChannelsPanel: refresh the main channel list before the closed
history so the primary entry gets the first response, and null-guard
both fetchers' response shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:55:12 -04:00
archipelago
ea254f63af feat(ui): Server page renders from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s
network summary (4-RPC allSettled aggregate), fips row, vpn peers,
interfaces, and tor services become useCachedResource entries — revisits
paint instantly, background refreshes keep content on screen. Mutations
write through the cache: DNS apply + the 15s vpn poll patch the network
aggregate via optimistic() instead of refetching all four RPCs; peer
removal filters the cached list. loading/refreshing flags derive from
entry loadState (drops the hand-rolled hasLoaded bookkeeping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:45:17 -04:00
archipelago
1a306c7450 feat(ui): Federation adopts the cached-resource store (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m7s
federation nodes + dwn.status become useCachedResource entries: revisits
paint the node list instantly, `loading` fires on true first-load only
(the old showLoader semantics), the 5s poll refreshes silently like the
old surfaceErrors:false path, and explicit reloads after mutations still
surface failures in the error banner. Replaces the hand-rolled
loadNodesWithOptions SWR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:36:26 -04:00
archipelago
a969f892ea feat(ui): Lightning channels panel renders from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m1s
lnd.listchannels (+summary) and lnd.closedchannels become separate
useCachedResource entries: reopening the panel paints the last channel
lists instantly and revalidates behind them; a closed-history failure
keeps its last list without touching the main view (same semantics as
the old nested try). Open/close mutations still force a refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:26:53 -04:00
archipelago
43e50e669e feat(ui): Monitoring renders from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m7s
monitoring.current/history/alerts/alert-rules become useCachedResource
entries: revisiting the page paints the last snapshot, chart, and alert
list instantly and the 5s poll revalidates behind them (refreshes dedup
in the store; errors keep last-known values instead of blanking).
Alert-rule toggles and acknowledgements refresh their entries after the
mutation as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:22:06 -04:00
archipelago
529c7fe25d feat(ui): Web5 wallet/profits render from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m2s
- lnd.getinfo and wallet.networking-profits become useCachedResource
  entries (web5.lnd-info / web5.networking-profits): revisits paint
  instantly from cache, errors keep last-known values, refreshes dedup.
- walletConnected is now derived from the lnd-info entry (with a manual
  disconnect override preserving the connect/disconnect toggle).
- Drop the eager wallet.ecash-balance + lnd.gettransactions loaders and
  their 30s polling — they fed only the hidden wallet card; the lnd-info
  poll remains for the connected pill until B5 moves it to WS-push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 05:03:47 -04:00
archipelago
d605d0d544 feat(ui): PeerFiles renders from the shared peer-browse cache (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m11s
- PeerFiles.vue reads the SAME `cloud.peer-browse:<onion>` entry Cloud.vue's
  per-peer fan-in fills, so Cloud → peer files paints instantly from cache
  and revalidates behind it; catalog/error/loading/transport are now
  computed views over the store entry.
- preview-peer fan-out is capped at 3 concurrent with a queue (was one 30s
  RPC per media item, all at once, unbounded) and aborts on unmount.
- browse + preview RPCs drop to maxRetries:1 — retry×3 turned one slow
  peer into a 90s spinner.
- fix useCachedResource's interface types: `ReturnType<typeof computed<T>>`
  resolves to the writable overload (WritableComputedRef), which broke
  vue-tsc against the plain computed() returns; use ComputedRef<T>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 04:46:26 -04:00
archipelago
a3f07d5ac6 feat(fips): resilience — connectivity watcher with immediate anchor re-apply, rebindable peer listener, cached service probe, warm-path union
Phase A3 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md (RC5), measured against
the A2 dial_stats baseline:

- 25s connectivity watcher in the fips supervisor: re-applies seed anchors
  immediately on an anchor-link drop, on startup-disconnected, AND on
  silent data-path death (connect_fails growing with zero fips_ok — the
  live .198 failure where the daemon reported "connected" while every
  dial blackholed and the 300s tick never healed it). Bounded to one
  re-apply per 60s.
- anchors::apply is now concurrent with a 15s per-connect cap — the old
  serial loop waited unbounded on each `sudo fipsctl connect`, so one
  hung subprocess stalled the whole periodic tick.
- rebindable peer listener: the accept loop returns after persistent
  accept errors (was: continue forever = inbound-dead until restart) and
  peer_late_bind_loop rebinds — also on fips0 ULA change.
- is_service_active gets a 10s TTL cache (was up to 2 systemctl spawns
  per dial attempt and per warm-tick peer).
- the warm tick now warms the union of federation peers + configured
  seed anchors (direct anchor links used to go cold between 300s ticks),
  skipping the redundant per-peer service check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 03:57:12 -04:00
archipelago
c83bade022 feat(ui): Cloud page renders from cache — per-peer incremental fan-in, live FIPS/Tor badges, per-path folder cache
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m24s
Part B3 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md — the worst
fetch-on-every-navigation offender converted to the cached-resource layer:

- section counts / peer nodes / my files / paid items are cached resources:
  revisits paint instantly, refresh happens behind the content
  (sticky-ready), errors keep last-known data
- peer files: per-peer cached browse entries replace the all-or-nothing
  Promise.allSettled — each peer's rows render the moment it answers, with
  "still fetching from N peers" + unreachable counts; browse-peer runs
  with maxRetries:1 so one dead peer costs its timeout once, not ×3
- peer cards get a live transport badge (FIPS green / Tor amber, with
  measured latency) from the transport field the browse response already
  carried — the per-peer FIPS-uptime view, for free
- cloud store: per-path listing cache with stale-while-revalidate
  navigate() and a last-wins guard; CloudFolder no longer reset()s the
  store on every folder entry (that wipe forced a spinner each time)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:49:30 -04:00
archipelago
67454974b2 feat(ui): shared stale-while-revalidate layer — useCachedResource + resources store + rpc-client abort/dedup/retry controls
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m28s
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.

- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
  with sticky-ready (never regress ready→loading), keep-last-value on
  error, per-key in-flight dedup, sessionStorage snapshot hydrate,
  debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
  hydrate, TTL-gated background revalidate, revalidate-on-focus,
  abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
  in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:54:51 -04:00
archipelago
e24e0a6473 feat(fips): fallback telemetry — per-reason counters in fips.status + last-transport recording on all dial sites
Phase A2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md (RC6). Fallbacks to Tor
were debug!-only and uncounted, so "FIPS uptime" was unfalsifiable and
paths that were 100% Tor by construction went unnoticed for months.

- fips::telemetry: process-lifetime counters for FIPS successes and the
  six fallback reasons (no_npub, service_inactive, dns_fail, connect_fail,
  http_404, http_5xx), exposed as `dial_stats` in fips.status
- dial.rs: every fallback branch now counts + logs at info! with a
  `reason` field (resolve/connect/status branches)
- PeerRequest::record_transport(data_dir): opt-in hook that writes the
  transport actually used to federation storage off the hot path — wired
  into the dial sites that never recorded (DWN sync ×3, mesh blob fetch,
  federation deploy notify, onion-rotation notify, node messages via a
  new send_to_peer data-dir param)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:54:35 -04:00
archipelago
eb2fc0f37b fix(fips): P0 uptime fixes — open peer port 5679, allow /blob+/dwn, fix LAN anchor port, un-deaden direct peering, fast-fail budgets
Phase A1 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md — the five changes that
made FIPS fall back to Tor even when a FIPS path existed:

- RC0: the fips.d drop-in now opens PEER_PORT 5679 (was 80+8443 only, so
  every hardened node firewalled peers' FIPS dials; 28k drops on .198)
- RC4: /blob/ and /dwn/ added to the peer-path allowlist — mesh file
  sharing and DWN sync were 404 → 100% Tor by construction
- RC2-G2: lan_fips_anchors dials PUBLISHED_UDP_PORT (2121) instead of the
  dead 8668, with a drift-guard test against the rendered daemon config
- RC2-G1: direct LAN peering actually runs now — mDNS TXT advertises the
  FIPS npub, discovery calls set_fips_npub, and the anchor tick hydrates
  npubs from federation storage for peers on older builds
- RC3: FIPS attempt budget is a hard cap (retry no longer doubles it) and
  the 12 hot call sites get explicit fips_timeout fast-fail so Tor keeps
  its full budget (browse-peer, preview, /blob, DWN, node-message,
  rotation notifies)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:42:44 -04:00
archipelago
94b5374f66 Merge public-prelaunch: open-source launch prep + FIPS unit-fallback coverage + companion safe-area fix
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:16:27 -04:00
archipelago
9f65f1e7ae docs: FIPS near-100% uptime + optimistic UI state plan — live-proven root causes (nft 5679 drop, .228 daemon skew, dead LAN peering, no fast-fail) + phased execution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:14:23 -04:00
Dorian
c598bb8796 fix(android): preserve top inset for fixed app headers 2026-07-27 20:11:02 +01:00
Dorian
70996203f9 fix: complete fips unit fallback coverage 2026-07-27 19:28:53 +01:00
Dorian
709922c293 fix: harden fips startup and app port relays 2026-07-27 19:18:39 +01:00
Dorian
0aee010f9c chore: prepare repository for public launch 2026-07-27 17:51:43 +01:00
archipelago
c5eeb4392f docs: open-source readiness plan — phases 0-6 for public launch
Consolidated plan from the deep repo review: credential rotation, secret
scrub, repo restructure, registry parameterization, docs overhaul, deep
code cleanup, and fresh-history publish mechanics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:38:01 -04:00
5457b98c66 Merge pull request 'Companion 0.5.25 — dashboard hub menu, adaptive panel, safe-area update fix' (#124) from release/1.7.115-prep into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m57s
2026-07-27 16:31:43 +00:00
Dorian
1593c55894 chore(android): update companion apk download 2026-07-27 17:12:52 +01:00
Dorian
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
Dorian
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
Dorian
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
Dorian
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
archipelago
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
Dorian
07772b563f fix(companion): wallet scanner reads dense invoice QRs — 0.5.22
All checks were successful
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
archipelago
d500214766 docs: backlog — ship lightning false-failure fix; companion app version display
All checks were successful
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
archipelago
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
Dorian
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
Dorian
c66ef048f4 chore(companion): publish 0.5.20 to download QR (transport handoff + FIPS mesh settings)
All checks were successful
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
Dorian
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
Dorian
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
Dorian
6dffd8e2e8 chore: bump version to 1.7.116-alpha + changelog/What's New
All checks were successful
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
Dorian
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
Dorian
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
Dorian
56e4c30261 fix(companion): edge-to-edge app webview, whole-overlay touch shield, no raw IP titles — 0.5.19
All checks were successful
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
Dorian
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
Dorian
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
Dorian
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
Dorian
325b9ea9c9 chore: bump version to 1.7.115-alpha (release prep)
All checks were successful
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
Dorian
9739bbb3db docs+fix: ship fips0 web-UI firewall allowance from core; changelog + What's New for v1.7.115-alpha
Some checks failed
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
Dorian
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
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m1s
2026-07-26 13:40:03 -04:00
archipelago
0365cc0f9d docs: changelog + What's New for v1.7.114-alpha
All checks were successful
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
archipelago
0880824fb3 fix(ui): seed QRs use the SeedQR standard so hardware wallets can scan them
All checks were successful
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
archipelago
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
archipelago
c8d0dda656 feat(ui): Words / QR code tabs on LND seed reveal + onboarding seed
All checks were successful
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
archipelago
57c6a4d512 style(ui): grey status dots for closing/force-closing channel cards
All checks were successful
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
archipelago
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
archipelago
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
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 b14af20d1acfc053957ec22234773b5c59582a66)
2026-07-26 07:09:26 -04:00
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 5799c3711121f4a04635d2b1c720ce9089ff3f12)
2026-07-26 07:09:24 -04:00
archipelago
c6f11f8ddb feat(wallet): on-chain send fee control + BTC/sats amount entry
All checks were successful
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
archipelago
a26090e561 feat(ui): lightning channels All/Active/Pending/Closed tabs + closed-channel history
All checks were successful
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
archipelago
c4c558954b chore: sign v1.7.113-alpha release manifest
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:14:07 -04:00
archipelago
d4ea4ed636 chore: release v1.7.113-alpha 2026-07-25 14:41:59 -04:00
archipelago
0fadbb1d0f docs: sync What's New modal for v1.7.113-alpha
Some checks failed
Demo images / Build & push demo images (push) Failing after 48s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:29:48 -04:00
archipelago
0e5cd24e18 style: rustfmt on lnd channels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:25:10 -04:00
archipelago
1d8e57d564 docs: changelog for v1.7.113-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:12:41 -04:00
archipelago
00b7e1798f feat(lnd): closed-channels RPC, closing channels in list, streaming close with txid
- lnd.closedchannels: closed-channel history via /v1/channels/closed
- channel list now includes waiting-close and force-closing pending
  channels with their closing_txid
- closechannel reads the close stream's first update with a dedicated
  client instead of hanging until the closing tx confirms; returns the
  closing txid in display byte order

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

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

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

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

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

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

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

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

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

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

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

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

Served APK: 0.5.11 (vc31).

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

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

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

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

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

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

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

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

Served APK: 0.5.9 (vc29).

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

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

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

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

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

Served APK: 0.5.7 (vc27).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:46:51 -04:00
42e98d6a86 Merge pull request 'fix(ci): demo-images path filter watched .github, file lives in .gitea' (#105) from fix/demo-images-path-filter into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m4s
2026-07-23 20:30:13 +00:00
5799c37111 fix(mesh): DTR/RTS reset settle time was far shorter than real boot time
Every one of Reticulum/Meshcore/Meshtastic's open() deasserts DTR/RTS on
every connection attempt (needed to clear stale line state, but the
transition itself resets ESP32-S3 native-USB boards and CP2102/CH340-
bridged boards wired for Arduino-style auto-reset — acknowledged in the
existing code comments). Each only waited 300ms before expecting a
handshake response — nowhere near real firmware boot time (LoRa radio
init alone routinely takes longer).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 02:18:55 +00:00
7d31ca5d65 First commit 2026-07-23 00:33:55 +00:00
223 changed files with 17710 additions and 2122 deletions

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"

View File

@ -1,16 +1,16 @@
## Summary
<!-- Brief description of what this PR does -->
<!-- What changed and why? -->
## Changes
## Verification
-
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
## Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (if backend changes)
- [ ] No new compiler warnings
- [ ] Tested on live server
- [ ] Rust formatting/clippy/tests pass when backend code changed.
- [ ] Frontend type-check/build/tests pass when frontend code changed.
- [ ] App manifests validate when app packaging changed.
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.

View File

@ -8,11 +8,11 @@ on:
env:
RUST_VERSION: stable
NODE_VERSION: 18
NODE_VERSION: 20
jobs:
rust:
name: Rust (fmt + clippy + test)
name: Rust
runs-on: ubuntu-latest
defaults:
run:
@ -28,17 +28,17 @@ jobs:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Check formatting
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Tests
- name: Test
run: cargo test --all-features
frontend:
name: Frontend (type-check + lint)
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
@ -52,14 +52,31 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache: npm
cache-dependency-path: neode-ui/package-lock.json
- name: Install dependencies
- name: Install
run: npm ci
- name: Type check
run: npm run type-check
- name: Test
run: npm test
- name: Build
run: npm run build
manifests:
name: App Manifests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate manifests
run: |
for manifest in apps/*/manifest.yml; do
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
done

35
.gitignore vendored
View File

@ -1,10 +1,9 @@
# SSH keys (sandbox copies)
# SSH keys and sandbox copies
.ssh/
# Rust build output
target/
**/target/
Cargo.lock
# Node.js
node_modules/
@ -12,7 +11,6 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
pnpm-debug.log*
# Build outputs
@ -28,49 +26,46 @@ build/
*.swo
*~
.DS_Store
._*
Thumbs.db
# Environment and local overrides
.env
.env.local
.env.*.local
.env.production
core/.env.production
scripts/deploy-config.sh
# Logs
logs/
*.log
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
*.temp
# Build artifacts
# Image / release artifacts
*.iso
*.img
*.dmg
*.app
*.apk
*.keystore
*.s9pk
*.tar.gz
# Release artifacts live in Gitea Release attachments, not Git history.
# Release artifacts live in release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
# macOS build output
build/macos/
# Image recipe output
image-recipe/output/
image-recipe/*.iso
image-recipe/*.img
# Loop tool artifacts (created in every subdirectory)
# Loop tool artifacts
*/loop/
loop/loop/
loop/loop.log.bak
@ -78,19 +73,17 @@ loop/loop.log.bak
# Separate repos nested in tree
web/
._*
# Resilience harness reports (generated, contains session cookies)
# Resilience harness reports contain session cookies.
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.claude/
.pnpm-store/
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.

View File

@ -0,0 +1,32 @@
# Ingest Conflict Report
Mode: new (fresh bootstrap — no existing .planning/ context to check against)
Precedence: ADR > SPEC > PRD > DOC (no per-doc overrides present)
## Conflict Detection Report
### BLOCKERS (0)
(none)
### WARNINGS (0)
(none)
### INFO (4)
[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory
Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope
Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene.
[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults
Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field)
Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md.
[INFO] ADR numbering gap — ADR-010 absent from ingest set
Found: Classified ADRs run 001009 and 011; no classification exists for an ADR-010
Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record.
[INFO] Cross-reference graph is acyclic
Found: cross_refs edges: ADR-007 → ADR-003; ADR-009 → docs/app-manifest-spec.md (+ code paths); SPEC → out-of-set docs and code only (app-developer-guide.md, manifest-hooks-design.md, marketplace-protocol.md, core/container/src/manifest.rs, api/rpc/package/stacks.rs)
Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed.

115
.planning/PROJECT.md Normal file
View File

@ -0,0 +1,115 @@
# Archipelago
## What This Is
Archipelago is a self-hosted personal-server platform: a Rust daemon (workspace at `core/`)
plus a Vue 3 frontend (`neode-ui/`, built to `web/dist/neode-ui/`) running on Debian nodes
with rootless Podman, managing ~40 declarative, manifest-driven apps (Bitcoin, Lightning,
mesh/LoRa, federation, media, and more). It ships as OTA-updated releases to a live fleet
and is actively shipping v1.7.x alpha releases. This milestone drives it to the
**developer-ready app platform** north star.
## Core Value
A third-party developer can publish an app via the signed/decentralized registry and a user
can install it on their node — every app manifest-driven, manifests shipped via the signed
registry (not OTA disk files), all rootless, secure, robust, and 100%-uptime-capable.
## Current State (brownfield baseline, 2026-07-29)
- Single-node production gate is **GREEN** (5/5 on .228, 2026-06-23) — that exit criterion is met.
- ~40 apps are manifest-based and Quadlet-migrated; all multi-container stacks use the
orchestrator stack pattern; the legacy per-app installer anti-pattern is deleted.
- Workstream B (registry-distributed manifests) phases 1+2 are code-complete; the signing
ceremony is done (release-root pinned in `anchor.rs`); the fleet flip is not yet authorized.
- Workstream C (marketplace) is design-only (`docs/marketplace-protocol.md`); no tooling or
trust UX built. Developer CLI suite (`archy app …`) does not exist yet.
- Phase-3 Quadlet default-flip is validated opt-in on .228/.198 but not default.
- Declared next exit criteria: the multinode pass (`docs/multinode-testing-plan.md`) and the
remaining workstreams.
## Requirements
### Validated
- ✓ Single-node lifecycle gate green 5× on .228 (install/UI/stop/start/restart/reinstall/
reboot-survive/daemon-restart-survive/uninstall) — 2026-06-23
- ✓ Manifest-driven app packaging for all ~40 apps incl. multi-container stacks (workstream A)
- ✓ Signed catalog + release-root signing ceremony (workstream B phases 1+2, code-complete)
### Active
See `.planning/REQUIREMENTS.md` — 20 v1 requirements across MNODE / LIFE / REG / SEC / DEV / MKT,
all mapped to phases in `.planning/ROADMAP.md`.
### Out of Scope
- Rootful containers, Docker, privileged containers — invariant (ADR-001/ADR-009)
- Per-app Rust installers / OS-level provisioning — the anti-pattern being deleted
- Centralized gatekept app store — decentralized Nostr marketplace instead (ADR-006)
- Web5 DWN spec compliance — deprioritized after TBD shutdown (ADR-011)
- Custom live voice-call protocol — deprioritized per user 2026-07-01; revisit later
- DHT/iroh distribution backbone (workstream D) — design-only, tracker-marked backlog; v2
## Context
- Repo: `core/` Rust workspace (no root Cargo.toml), `neode-ui/` Vue frontend, `apps/` manifests,
`tests/lifecycle/` + `tests/multinode/` gates, `docs/` authoritative plans.
- Authoritative narrative: `docs/PRODUCTION-MASTER-PLAN.md`; day-to-day open list:
`docs/UNIFIED-TASK-TRACKER.md`. Codebase map: `.planning/codebase/ARCHITECTURE.md` +
`.planning/codebase/CONCERNS.md`.
- Known debt informing this milestone (from CONCERNS.md): federation tombstone-write errors
swallowed; reconciler has no flap observability and no failed-unit self-healing; generated
AppArmor profiles are never applied; multinode test harness curl calls lack timeouts;
SPEC validation is narrower than ADR-009's mandates (non-root UID, pinned tags, seccomp).
- Fleet is live and OTA-updated; all destructive verification happens on designated test
nodes per the deploy roster — never uninvited on in-use nodes.
## Constraints
- **Security**: Rootless Podman only; manifest-declared secrets (0600, never logged);
mandatory container security defaults enforced at manifest level (ADR-009)
- **Data safety**: Migrations never destroy data — preserve `/var/lib/archipelago/<app>`,
secrets, credentials, ports, adoption container names; always a rollback path
- **Verification**: Real-node verification before any tag; lifecycle gate runs ON the node,
not via RPC; mesh changes need real-RF E2E tests; re-run the gate after orchestrator changes
- **Process**: Commit + push every unit of work (`git push gitea-ai main`); stage by explicit
path; deploy to the dev pair before any OTA; never commit secrets
- **Tech stack**: Rust (Tokio/Hyper, JSON-RPC 2.0) backend; Vue 3 + Pinia frontend;
Quadlet/systemd-user container units; Ed25519-signed release artifacts
## Key Decisions
<decisions>
All ten ADRs below are **locked** (Status: Accepted; ingest source `docs/adr/*.md`). They are
non-negotiable inputs to planning and cannot be overridden without a new ADR.
| ID | Decision | Scope |
|----|----------|-------|
| ADR-001 | Podman over Docker — rootless, daemonless, systemd-native; `archy-net` for inter-container DNS | Container runtime |
| ADR-002 | `did:key` (Ed25519) node identity — self-contained, offline-capable; gaps mitigated via federation trust lists | Identity |
| ADR-003 | Nostr relays (NIP-78, kind 30078) for node + app discovery — multi-relay query, 15-min cache, trust scoring, Tor-compatible | Discovery |
| ADR-004 | Tor hidden services for inter-node RPC/control plane — bulk data via registries, not Tor | Federation transport |
| ADR-005 | ChaCha20-Poly1305 + Argon2id (64MB, 3 iter) for backup encryption | Backups |
| ADR-006 | Nostr relays for marketplace discovery — DID-signed manifests, trust tiers (Verified/Community/Unverified), signature verification before install | Marketplace |
| ADR-007 | Bilateral DID federation trust via single-use invite codes; Trusted/Observer/Untrusted levels | Federation trust |
| ADR-008 | Dual keys from one master seed — Ed25519 canonical identity, secp256k1 for Nostr/Bitcoin/Lightning, linked via NIP-05 | Keys |
| ADR-009 | Manifest-level container security enforcement — readonly_root, no_new_privileges, non-root UID, drop-ALL caps, pinned tags, seccomp; overrides explicit + audited | Container security |
| ADR-011 | DWN deprioritized — keep custom `dwn_store.rs`, stop branding as Web5, invest in Nostr + Tor federation instead | Peer data sync |
(ADR-010 does not exist in the repo — numbering gap, noted in `.planning/INGEST-CONFLICTS.md`.)
</decisions>
Milestone-level decisions:
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Milestone version = 1.8.0-alpha | Decided 2026-07-08 per tracker | — Pending ship |
| Workstream D (DHT) deferred to v2 | Design-only, tracker-marked backlog; not needed for north-star metric | — Pending |
| App manifest canonical schema = `core/container/src/manifest.rs` | SPEC self-declares code wins over doc | ✓ Good |
| Phase-3 Quadlet flip gated on multinode gate reporting clean | Prior uncommitted-flip confusion; flip fresh as a 2-line change when gate is clean | — Pending |
---
*Last updated: 2026-07-29 after intel ingest (10 ADRs + 1 SPEC) + codebase mapping*

132
.planning/REQUIREMENTS.md Normal file
View File

@ -0,0 +1,132 @@
# Requirements: Archipelago (v1.8.0 — Developer-Ready App Platform)
**Defined:** 2026-07-29
**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
No PRDs existed in the ingest set; these requirements are derived from the master plan's
declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase/CONCERNS.md`,
`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from
`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built.
## v1 Requirements
### Federation & Mesh Hardening (FED)
- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op)
- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible
- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason
- [ ] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy
### UI Performance (PERF)
- [ ] **PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed
- [ ] **PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session
- [ ] **PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box
### Multinode Verification (MNODE)
- [ ] **MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy
- [ ] **MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs)
- [ ] **MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles
### Lifecycle Perfection (LIFE)
- [ ] **LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm)
- [ ] **LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping
- [ ] **LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output
- [ ] **LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy
- [ ] **LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar
### Registry-Distributed Manifests (REG)
- [ ] **REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback
- [ ] **REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync
### Security Enforcement (SEC)
- [ ] **SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides
- [ ] **SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps
### Developer Tooling (DEV)
- [ ] **DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules)
- [ ] **DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces
- [ ] **DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`)
- [ ] **DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs
### Decentralized Marketplace (MKT)
- [ ] **MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling
- [ ] **MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring
- [ ] **MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed
- [ ] **MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees
## v2 Requirements
Deferred to a future milestone. Tracked but not in the current roadmap.
### Distribution Backbone (DIST)
- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog)
### Fleet & Hardening (FLEET)
- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`)
- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage
- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/`
- **FLEET-04**: Dynamic per-app resource rebalancing (cgroup-stats feedback loop)
## Out of Scope
| Feature | Reason |
|---------|--------|
| Rootful/privileged containers, Docker | Invariant — ADR-001/ADR-009 |
| Per-app Rust installers / host provisioning | The anti-pattern workstream A deleted |
| Centralized gatekept app store | ADR-006 chose decentralized Nostr marketplace |
| Web5 DWN spec compliance | ADR-011 — deprioritized after TBD shutdown |
| Custom live voice-call protocol | Deprioritized 2026-07-01 per user; no scope decided |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| FED-01 | Phase 1 | Pending |
| FED-02 | Phase 1 | Pending |
| FED-03 | Phase 1 | Pending |
| FED-04 | Phase 1 | Pending |
| FED-05 | Phase 1 | Pending |
| PERF-01 | Phase 2 | Pending |
| PERF-02 | Phase 2 | Pending |
| PERF-03 | Phase 2 | Pending |
| MNODE-01 | Phase 3 | Pending |
| MNODE-02 | Phase 3 | Pending |
| MNODE-03 | Phase 3 | Pending |
| LIFE-01 | Phase 4 | Pending |
| LIFE-02 | Phase 4 | Pending |
| LIFE-03 | Phase 4 | Pending |
| LIFE-04 | Phase 4 | Pending |
| LIFE-05 | Phase 4 | Pending |
| REG-01 | Phase 5 | Pending |
| REG-02 | Phase 5 | Pending |
| SEC-01 | Phase 6 | Pending |
| SEC-02 | Phase 6 | Pending |
| DEV-01 | Phase 7 | Pending |
| DEV-02 | Phase 7 | Pending |
| DEV-03 | Phase 7 | Pending |
| DEV-04 | Phase 7 | Pending |
| MKT-01 | Phase 8 | Pending |
| MKT-02 | Phase 8 | Pending |
| MKT-03 | Phase 8 | Pending |
| MKT-04 | Phase 8 | Pending |
**Coverage:**
- v1 requirements: 28 total
- Mapped to phases: 28
- Unmapped: 0
---
*Requirements defined: 2026-07-29*
*Last updated: 2026-07-29 — added FED (federation/mesh hardening) and PERF (UI performance) requirement groups; phases renumbered after inserting them as Phases 12*

140
.planning/ROADMAP.md Normal file
View File

@ -0,0 +1,140 @@
# Roadmap: Archipelago — v1.8.0 Developer-Ready App Platform
## Overview
Brownfield milestone starting from a green single-node production gate (5/5 on .228,
2026-06-23). The journey: make federation and mesh rock-solid (node removal, sync,
messaging parity), fix the UI slowness users feel on every tab switch, prove the platform
across the fleet (multinode pass), make the container lifecycle bulletproof (Quadlet
default, self-healing, honest progress, no ghosts), flip manifest distribution from OTA
disk files to the signed registry, harden manifest security enforcement to the full
ADR-009 bar, ship the `archy app` developer CLI, and land the decentralized Nostr
marketplace — ending at the north star: a third-party developer publishes an app via the
signed/decentralized registry and a user installs it on their node.
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
- [ ] **Phase 1: Federation & Mesh Hardening** - Deep review of federation/fleet + mesh code; node removal sticks, sync converges, mesh messaging behaves identically on demo and real nodes
- [ ] **Phase 2: UI Performance** - Tab switches and secondary screens render fast; worst transitions measured and fixed
- [ ] **Phase 3: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks
- [ ] **Phase 4: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress
- [ ] **Phase 5: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution
- [ ] **Phase 6: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied
- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide
- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end
## Phase Details
### Phase 1: Federation & Mesh Hardening
**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs
**Depends on**: Nothing (first phase)
**Requirements**: FED-01, FED-02, FED-03, FED-04, FED-05
**Success Criteria** (what must be TRUE):
1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason
2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping
3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator
4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal)
5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy
**Plans**: TBD
**UI hint**: yes
### Phase 2: UI Performance
**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts
**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1)
**Requirements**: PERF-01, PERF-02, PERF-03
**Success Criteria** (what must be TRUE):
1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land
2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session
3. Secondary screens open without a blocking full reload; repeat visits are instant
4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device
**Plans**: TBD
**UI hint**: yes
### Phase 3: Multinode Verification Pass
**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion
**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide)
**Requirements**: MNODE-01, MNODE-02, MNODE-03
**Success Criteria** (what must be TRUE):
1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node
2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang
3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed
4. The on-air mesh suite passes between two radio-equipped nodes over real RF
**Plans**: TBD
### Phase 4: Lifecycle Perfection & Quadlet Default
**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery
**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean)
**Requirements**: LIFE-01, LIFE-02, LIFE-03, LIFE-04, LIFE-05
**Success Criteria** (what must be TRUE):
1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild
2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention
3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible
4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier
5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes
**Plans**: TBD
**UI hint**: yes
### Phase 5: Registry-Distributed Manifests
**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change
**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel)
**Requirements**: REG-01, REG-02
**Success Criteria** (what must be TRUE):
1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps)
2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest
3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet
**Plans**: TBD
### Phase 6: Manifest Security Enforcement
**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime
**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through)
**Requirements**: SEC-01, SEC-02
**Success Criteria** (what must be TRUE):
1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation
2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail
3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored
4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides)
**Plans**: TBD
### Phase 7: Developer Tooling CLI
**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals
**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set)
**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04
**Success Criteria** (what must be TRUE):
1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install
2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node
3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI
4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required
**Plans**: TBD
### Phase 8: Decentralized Marketplace
**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes
**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6)
**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04
**Success Criteria** (what must be TRUE):
1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling
2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring
3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed
4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end
**Plans**: TBD
**UI hint**: yes
## Progress
**Execution Order:**
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
(Phases 1 and 2 are independent and may be worked in parallel.)
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Federation & Mesh Hardening | 0/TBD | Not started | - |
| 2. UI Performance | 0/TBD | Not started | - |
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
| 5. Registry-Distributed Manifests | 0/TBD | Not started | - |
| 6. Manifest Security Enforcement | 0/TBD | Not started | - |
| 7. Developer Tooling CLI | 0/TBD | Not started | - |
| 8. Decentralized Marketplace | 0/TBD | Not started | - |

85
.planning/STATE.md Normal file
View File

@ -0,0 +1,85 @@
---
gsd_state_version: '1.0'
status: planning
progress:
total_phases: 8
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-07-29)
**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
**Current focus:** Phase 1 — Federation & Mesh Hardening
## Current Position
Phase: 1 of 8 (Federation & Mesh Hardening)
Plan: 0 of TBD in current phase
Status: Ready to plan
Last activity: 2026-07-29 — Inserted Phase 1 (Federation & Mesh Hardening) and Phase 2 (UI Performance) per user priority; prior phases renumbered 38
Progress: [░░░░░░░░░░] 0%
## Performance Metrics
**Velocity:**
- Total plans completed: 0
- Average duration: —
- Total execution time: —
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| - | - | - | - |
## Accumulated Context
### Roadmap Evolution
- Phase 1 added (2026-07-29): Federation & Mesh Hardening — user-directed top priority (node removal/sync issues, mesh attachment parity incl. demo); prior phases shifted down
- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down
- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy
### Decisions
Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block + milestone decisions table). Recent decisions affecting current work:
- Milestone version = 1.8.0-alpha (decided 2026-07-08)
- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted)
- Workstream D (DHT distribution) deferred to v2 — design-only backlog
- Canonical manifest schema = `core/container/src/manifest.rs` (code wins over spec doc)
### Pending Todos
None yet.
### Blockers/Concerns
- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind
- [Phase 3] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running
- [Phase 5] Fleet registry flip awaits explicit user authorization + timing call
- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands
- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests)
## Deferred Items
| Category | Item | Status | Deferred At |
|----------|------|--------|-------------|
| Distribution | DIST-01 DHT/iroh backbone (workstream D) | v2 | 2026-07-29 |
| Fleet | FLEET-01 Bitcoin multi-version fleet OTA (user-gated) | v2 | 2026-07-29 |
| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 |
| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 |
## Session Continuity
Last session: 2026-07-29
Stopped at: Roadmap + state initialized from ingest; next step is `/gsd-plan-phase 1`
Resume file: None

View File

@ -0,0 +1,333 @@
<!-- refreshed: 2026-07-29 -->
# Architecture
**Analysis Date:** 2026-07-29
## System Overview
```text
┌────────────────────────────────────────────────────────────────┐
│ Frontend Layer (Vue 3) │
`neode-ui/src` (TypeScript + SPA) │
│ Routes → Views → Components → Composables → RPC Client │
└────────────────┬─────────────────────────────────────────────┘
│ WebSocket + HTTP(S)
│ JSON-RPC 2.0 protocol
┌────────────────────────────────────────────────────────────────┐
│ HTTP Server Layer (Hyper) │
`core/archipelago/src/server.rs`
│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │
└────────────────┬─────────────────────────────────────────────┘
┌──────────┴──────────┬──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────────┐
│ WebSocket │ RPC │ │ Content │
│ Handler │ Handler │ │ Proxy │
│ (state sync) │ (methods)│ │ (app URIs) │
└─────────┘ └──────────┘ └────────────┘
│ │
└──────────┬───────┘
┌─────────────────────────────────────┐
│ Service Layer (Async Tasks) │
`core/archipelago/src/api/rpc/*`
│ │
│ • auth, identity, secrets │
│ • container orchestration │
│ • bitcoin, lightning, wallet │
│ • mesh, federation, FIPS │
│ • content, backup, settings │
└─────────────┬───────────────────────┘
┌───────────┼───────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐
│Container│ │ State │ │BlobStore
│Orch. │ │Manager │ │ │ │Identity │
│(Podman) │ │(Broadcast
│ │ │ channels)│ │ ContentClient Manager │
└─────────┘ └──────────┘ └────────┘ └──────────┘
│ │ │ │
└───────────┼───────────┼─────────┘
┌─────────────────────────────────────┐
│ Persistent Storage Layer │
│ │
│ • Data directory files (YAML/JSON) │
│ • SQLite (session store) │
│ • Blob store (content-addressed) │
│ • Podman container state │
│ • Secret vaults (encrypted) │
└─────────────────────────────────────┘
```
## Component Responsibilities
| Component | Responsibility | File |
|-----------|----------------|------|
| **Server** | HTTP listener, connection multiplexing, TLS/encryption | `core/archipelago/src/server.rs` |
| **ApiHandler** | HTTP request routing, authentication, response formatting | `core/archipelago/src/api/handler/mod.rs` |
| **RpcHandler** | JSON-RPC 2.0 dispatch, method registration, rate limiting | `core/archipelago/src/api/rpc/mod.rs` |
| **ContainerOrchestrator** | Podman lifecycle, manifest reconciliation, adoption | `core/archipelago/src/container/prod_orchestrator.rs` |
| **StateManager** | Central state broadcast channel, revision tracking | `core/archipelago/src/state.rs` |
| **AuthManager** | User credentials, session validation, password hashing | `core/archipelago/src/auth.rs` |
| **Identity Manager** | Node Ed25519 keys, seed derivation, Tor address | `core/archipelago/src/identity_manager.rs` |
| **BootReconciler** | Periodic manifest sync loop, adoption, remediation | `core/archipelago/src/container/boot_reconciler.rs` |
| **Frontend Router** | Vue Router, page navigation, deep linking | `neode-ui/src/router/index.ts` |
| **Frontend Stores** | Pinia state (apps, settings, user, mesh) | `neode-ui/src/stores/` |
| **Frontend Components** | UI elements, modals, cards, layout primitives | `neode-ui/src/components/` |
## Pattern Overview
**Overall:** Multi-tier async architecture with centralized request dispatch and broadcast state synchronization.
**Key Characteristics:**
- **Async-first (Tokio)** - All I/O operations are non-blocking; task spawning for background work
- **RPC-driven API** - Frontend communicates via JSON-RPC 2.0 (not REST); single `/api/v0` WebSocket + HTTP endpoint
- **State as broadcast** - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients
- **Manifest-driven containers** - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions)
- **Plugin architecture** - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets)
## Layers
**HTTP/Transport Layer:**
- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket
- Location: `core/archipelago/src/server.rs`
- Contains: Hyper listener, TCP accept loop, connection state tracking
- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl)
- Used by: All external clients (web UI, companion app, API consumers)
**Request Routing & Auth Layer:**
- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login
- Location: `core/archipelago/src/api/` (handler + rpc submodules)
- Contains: Route matching, middleware chain, cookie extraction, error formatting
- Depends on: Server, StateManager, SessionStore
- Used by: All request paths; gates API access
**RPC Dispatch Layer:**
- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses
- Location: `core/archipelago/src/api/rpc/mod.rs` + subdirectories (auth.rs, container.rs, bitcoin.rs, etc.)
- Contains: Method table, parameter validation, response formatting, rate limit checks
- Depends on: All service modules
- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools
**Service Layer:**
- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery
- Location: `core/archipelago/src/api/rpc/*` (one RPC module per domain), plus `core/archipelago/src/` (background tasks)
- Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.)
- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS)
- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks)
**State Management Layer:**
- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots
- Location: `core/archipelago/src/state.rs` (StateManager + data_model.rs)
- Contains: RwLock<DataModel>, broadcast channel, revision counter
- Depends on: DataModel (serde-serializable struct tree)
- Used by: All services that mutate state (container ops, auth, settings)
**Container Orchestration Layer:**
- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption
- Location: `core/archipelago/src/container/prod_orchestrator.rs` (1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.)
- Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking
- Depends on: Podman CLI + socket, config parser, image registries, local filesystem
- Used by: RPC container.* methods, BootReconciler loop, crash recovery
**Frontend Layer (Vue 3):**
- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input
- Location: `neode-ui/src/`
- Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router
- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps)
- Used by: Browser clients (desktop, mobile, companion app via WebView)
## Data Flow
### Primary Request Path (User Action → Backend → State Sync)
1. **Frontend user interaction** (click button, type input) → Vue component event handler
- Location: `neode-ui/src/views/*.vue` or `neode-ui/src/components/*.vue`
2. **Composable dispatches RPC** (e.g., `useContainerInstall()` calls `rpc.container.install()`)
- Location: `neode-ui/src/composables/` (custom or imported from `api/rpc-client.ts`)
3. **RPC client serializes → HTTP/WebSocket POST to /api/v0**
- Location: `neode-ui/src/api/rpc-client.ts`
- Payload: `{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }`
4. **HTTP Server receives, routes to ApiHandler**
- Location: `core/archipelago/src/server.rs` (listener) → `core/archipelago/src/api/handler/mod.rs` (dispatch)
5. **ApiHandler checks auth**, extracts body, calls RpcHandler
- Location: `core/archipelago/src/api/handler/mod.rs:handle_request()`
6. **RpcHandler dispatches by method name** to specific RPC module
- Location: `core/archipelago/src/api/rpc/mod.rs:call()` → routing to `core/archipelago/src/api/rpc/container.rs:install()`
7. **Service method executes** (e.g., `container.rs:install()` calls orchestrator, updates state)
- Location: `core/archipelago/src/api/rpc/container.rs` (calls methods on ContainerOrchestrator)
8. **StateManager.update_data()** broadcasts the new state to all WebSocket subscribers
- Location: `core/archipelago/src/state.rs:update_data()` → broadcast channel
- All connected WebSocket clients receive `{ rev: N, data: {...} }` update
9. **Frontend receives state update**, updates Pinia stores, re-renders UI
- Location: `neode-ui/src/stores/` (Pinia stores mutate) → Vue reactivity chain → DOM update
**State Management:**
- All reads from `StateManager` go through `get_snapshot()` which acquires read-lock
- All writes go through `update_data()` which acquires write-lock + increments revision
- Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest)
- WebSocket clients re-sync on reconnect via `get_snapshot()` call (full state transfer)
### Secondary Flow: Scheduled Reconciliation (Convergence Loop)
1. **BootReconciler spawned at startup** in `main.rs`
- Location: `core/archipelago/src/main.rs` (line ~338-348)
2. **Reconciler runs every `RECONCILER_DEFAULT_INTERVAL`** (~30s typical)
- Location: `core/archipelago/src/container/boot_reconciler.rs:run_forever()`
3. **Compares desired manifests (disk + registry catalog) vs actual Podman state**
- Looks for: containers missing, containers orphaned, image updates, secret changes
4. **Applies remediation** (create, delete, restart containers)
- Calls: orchestrator.reconcile_*() methods
5. **Logs changes, broadcasts state update if anything changed**
- Frontend receives update, shows user the reconciled app state
This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges.
## Key Abstractions
**ContainerOrchestrator trait:**
- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist
- Examples: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/container/dev_orchestrator.rs`
- Pattern: Trait-based strategy; RpcHandler holds `Arc<dyn ContainerOrchestrator>`, switches at runtime
- Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade
**Manifest (YAML-based declarative app):**
- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file
- Examples: `/opt/archipelago/apps/*/manifest.yml` (on-disk) or registry-delivered catalogs
- Pattern: Custom extensions over OCI/Docker Compose (e.g., `interfaces.main.ui`, `generated_secrets`)
- Parsed into: `container::manifest::Manifest` struct, consumed by orchestrator
**RPC Method Modules:**
- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.)
- Examples: `core/archipelago/src/api/rpc/auth.rs`, `core/archipelago/src/api/rpc/bitcoin.rs`
- Pattern: Each module exports `pub async fn method_name(handler, params) -> Result<Response>`
- Registration: Hardcoded dispatch in `RpcHandler::call()` (no reflection; methods are explicit)
**BlobStore (Content-Addressed):**
- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access
- Examples: Used by mesh.send-content, federation attachments, backup archives
- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash
- Located: `core/archipelago/src/blobs.rs` + `core/archipelago/src/content_server.rs`
**StateManager + DataModel:**
- Purpose: Single source of truth for UI state; broadcast updates to all clients
- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency
- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately
- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug
**Session Store:**
- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping)
- Examples: SQLite-backed or in-memory store
- Pattern: Session token issued at login, validated on each request, expires after TTL
- Used by: ApiHandler auth check, rate limiter (per IP + per user)
## Entry Points
**Backend Daemon (Binary):**
- Location: `core/archipelago/src/main.rs`
- Triggers: `systemd start archipelago.service` or manual `./archipelago` on development node
- Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks
- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server
**Frontend SPA:**
- Location: `neode-ui/src/main.ts`
- Triggers: Browser loads `/index.html` (served by HTTP server from `/opt/archipelago/web-ui/`)
- Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend
- Key setup: Mount app → router ready → fetch initial state → subscribe to updates
**RPC Endpoints (HTTP + WebSocket):**
- Location: `core/archipelago/src/api/` (handler routes requests here)
- Endpoint: `/api/v0` (JSON-RPC 2.0 POST or WebSocket upgrade)
- Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.)
- Example: `POST /api/v0` with body `{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}`
**Background Tasks (Spawned at startup):**
- BootReconciler: Periodic manifest reconciliation loop
- Health Monitor: Periodic app health checks + restart
- Update Scheduler: Periodic app update checks
- Mesh Service: P2P mesh listener + sender (federation, LoRa)
- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers
- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber
- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks)
## Architectural Constraints
- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking)
- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design)
- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge
- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks)
- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory)
- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies)
- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler
## Anti-Patterns
### Circular RPC Dispatches
**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login)
**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug
**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount).
### Synchronous blocking in RPC handlers
**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop
**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users
**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples.
### Hardcoding paths in app RPC modules
**What happens:** `bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir`
**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found"
**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern.
### Frontend state outside Pinia stores
**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data
**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall
**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`.
### Not handling WebSocket reconnection
**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state
**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens
**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop.
## Error Handling
**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages.
**Patterns:**
- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes)
- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec
- Service layer: Use `anyhow::Result<T>` + `?` operator for early exit; convert to `RpcError` at handler boundary
- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app)
**Critical paths:**
- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames)
- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon)
- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries)
- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting")
---
*Architecture analysis: 2026-07-29*

View File

@ -0,0 +1,195 @@
# Codebase Concerns
**Analysis Date:** 2026-07-29
## Tech Debt
**Federation node removal tombstone gap:**
- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it.
- Files: `core/archipelago/src/federation/storage.rs:180-197`, `core/archipelago/src/api/rpc/federation/handlers.rs:272-300`
- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections.
- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles.
**Container reconciler observability gap:**
- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes.
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconciler loop), `core/archipelago/src/health_monitor.rs`
- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop.
- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output.
**Failed systemd unit self-healing gap:**
- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts.
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconcile loop)
- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator.
- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed <unit>` + `start`; add backoff to avoid busy-loop on persistent failures.
**Bitcoin RPC credentials not retrieved from config/secrets:**
- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store.
- Files: `core/container/src/bitcoin_simulator.rs:155-165`
- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail.
- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation.
**Container security policies not wired in:**
- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers.
- Files: `core/security/src/container_policies.rs:63-75`
- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints.
- Fix approach: Pass `--security-opt apparmor=<profile>` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly.
**Dynamic resource adjustment not implemented:**
- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift.
- Files: `core/performance/src/resource_manager.rs:86-88`
- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically.
- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+).
## Known Bugs
**Multinode RPC robustness gap:**
- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback.
- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`)
- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever.
- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery.
- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify.
## Security Considerations
**Secrets environment variable exposure risk:**
- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc/<pid>/environ` and potentially logged.
- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/container/src/manifest.rs`, `core/archipelago/src/api/rpc/package/config.rs`
- Current mitigation: Secrets are declared as `generated_secrets` in manifests and materialized 0600/rootless; the orchestrator avoids logging values.
- Recommendations: Audit all env-var passing to containers; consider switching high-sensitivity secrets (bitcoin RPC, LND macaroons) to file-based secrets mounted read-only; add audit logging for secret access.
**Federation DID validation incomplete:**
- Risk: Federation peer DIDs are added via the RPC without cryptographic verification of ownership. A compromised peer could advertise arbitrary DIDs.
- Files: `core/archipelago/src/api/rpc/federation/handlers.rs` (add-node path), `core/archipelago/src/federation/storage.rs`
- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers.
- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers.
**AppArmor profiles overly permissive:**
- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs.
- Files: `core/security/src/container_policies.rs:46-54`
- Current mitigation: None (profiles not applied).
- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod.
## Performance Bottlenecks
**Container thrashing during reconcile:**
- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts.
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine)
- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units.
- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup.
**Reconciler churn on boot:**
- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability.
- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/bootstrap.rs`
- Cause: Multi-pass reconciliation + no incremental diff detection.
- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path.
**Bitcoin IBD on .198 stalled (disk I/O):**
- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~35).
- Files: `tests/multinode-testing-plan.md` (documented issue)
- Cause: Undersized/slow disk; concurrent workload.
- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue.
## Fragile Areas
**Uninstall + reinstall lifecycle:**
- Files: `core/archipelago/src/api/rpc/package/install.rs`, `core/archipelago/src/container/quadlet.rs:disable_remove()`, `neode-ui/src/components/AppCard.vue`
- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in.
- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll.
- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`.
**Production orchestrator state machine:**
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (6291 lines)
- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved.
- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution.
- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate.
**Mesh radio configuration + boot race:**
- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats`
- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware.
- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues.
- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate).
**Lightning payment state machine:**
- Files: `core/archipelago/src/api/rpc/lnd/wallet.rs:payinvoice()`
- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop.
- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release.
- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate.
## Scaling Limits
**Uninstall progress bar truthfulness:**
- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity).
- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration.
- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app).
**Federation node list deduplication on disk bloat:**
- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation.
- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible.
- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load).
**Lifecycle gate iteration count:**
- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~812 hours on .228.
- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual.
- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention).
## Dependencies at Risk
**Reticulum transport daemon process group:**
- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions.
- Impact: Stale reticulum processes accumulating over time; resource leaks on node.
- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment.
**Podman socket mount security model:**
- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied).
- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer)
- Impact: A compromised app with podman socket access can start/stop/delete any container on the node.
- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired.
**Bitcoin version multi-version branch not fleet-wide:**
- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version.
- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation.
- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing.
## Missing Critical Features
**Developer tooling CLI suite:**
- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches.
- Blocks: External marketplace (workstream C); external developer onboarding.
- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5.
**Manifest-distributed registry flip:**
- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed.
- Blocks: Cannot confidently add/bump apps without re-signing the catalog.
- Status: Code ready; flip awaits authorization + timing call from user.
**Phase-3 Quadlet default-flip:**
- Problem: Orchestrator still uses legacy cgroup-based container management; Phase-3 `use_quadlet_backends` switch exists but is opt-in only.
- Blocks: Resolves container thrashing; unlocks independent app restarts; unblocks lifecycle perfection (workstream F).
- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes.
## Test Coverage Gaps
**~30 apps with zero app-specific assertions:**
- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running."
- Files: `tests/lifecycle/bats/all-apps-matrix.bats`, `tests/lifecycle/bats/all-apps-lifecycle.bats` (generic baseline coverage)
- Risk: App-specific bugs (API down, data corruption, dependency failure) go unnoticed until user encounters them.
- Priority: Medium — baseline coverage is a real safety net; app-specific assertions are a "nice to harden" backlog item, not a gate blocker.
- Approach: Add per-app health RPC endpoints or HTTP probes; wire into the gate as opt-in per-app test suites.
**Progress UI assertions incomplete:**
- What's not tested: Install + uninstall must report monotonic, truthful progress. No stage/percentage assertions in the gate.
- Files: `neode-ui/src/components/AppCard.vue`, `core/archipelago/src/api/rpc/package/install.rs` (backend progress events)
- Risk: Silent hangs or fake progress bars are invisible to the gate.
- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F.
- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in).
**All-apps matrix in cascade gate:**
- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps.
- Files: `tests/lifecycle/bats/cascade-uninstall.bats` (single-app variant)
- Risk: Multi-container app uninstall bugs (e.g., orphan postgres container) go undetected.
- Priority: High — part of workstream F definition of done.
- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall.
---
*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).*

View File

@ -0,0 +1,159 @@
# Coding Conventions
**Analysis Date:** 2026-07-29
## Naming Patterns
**Files:**
- TypeScript/Vue: PascalCase for components (e.g., `ToggleSwitch.vue`, `SendBitcoinModal.vue`), camelCase for composables and stores (e.g., `useFileType.ts`, `controller.ts`)
- Rust: snake_case for modules and files (e.g., `bitcoin_rpc.rs`, `storage_crypto.rs`)
- Test files: co-located with source in `__tests__/` subdirectories with `.test.ts` or `.spec.ts` suffix for Vitest, `.bats` for shell tests
- Constants in TypeScript use UPPER_SNAKE_CASE within modules (e.g., `IMAGE_EXTS`, `CATEGORY_COLORS` in `useFileType.ts`)
**Functions:**
- TypeScript/Vue: camelCase for all functions (e.g., `getFileCategory`, `formatSize`, `useFileType`)
- Composables: `use` prefix for Vue composables (e.g., `useFileType`, `useToast`, `useMessageToast`) — exported as named exports or default exports
- Store functions (Pinia): defined with snake_case action names, exported from `defineStore` factory
- Rust: snake_case for all functions and methods (e.g., `doesnt_reallocate`, following Rust conventions)
**Variables:**
- TypeScript: camelCase for local variables and reactive refs (e.g., `modelValue`, `isActive`, `gamepadCount`)
- Refs (Vue 3): prefix not required, but convention is lowercase start (e.g., `const ext = ref('jpg')`)
- Computed properties: camelCase, explicit `.value` suffix in templates when needed
- Parameters: camelCase, typed explicitly in TypeScript (e.g., `password: string`, `isDir: Ref<boolean>`)
**Types:**
- TypeScript: PascalCase for type aliases and interfaces (e.g., `RPCOptions`, `FileCategory`, `CatalogVersionInfo`)
- Union types: PascalCase (e.g., `PendingState = 'pending' | 'sent' | 'approved'`)
- Component props: typed with `defineProps<{ ... }>()` syntax in `<script setup>`
- Rust: PascalCase for structs and enums, snake_case for fields within them
## Code Style
**Formatting:**
- No global Prettier config; code style follows project patterns incrementally
- Vue components: single-file components (`.vue`) with `<template>`, `<script setup>`, optional `<style scoped>`
- TypeScript: indentation is 2 spaces (visible in `vitest.config.ts`, Vue components, test files)
- Line width: no strict enforcement observed; pragmatic wrapping around 80100 characters
- Arrow functions preferred for short callbacks: `(x) => x * 2`
- Template strings for multi-line formatting
**Linting:**
- No `.eslintrc` detected at repo root or `neode-ui/` level
- Rust: Clippy allowances declared at crate level in `main.rs` (`#![allow(...)]`) to suppress stylistic lints and focus CI on correctness issues
- Examples of suppressed Clippy lints: `too_many_arguments`, `type_complexity`, `enum_variant_names`, `unused_io_amount`
## Import Organization
**Order:**
1. Vue framework imports (`import { computed, ref, type Ref } from 'vue'`)
2. Library imports (`import { defineStore } from 'pinia'`, `import { format } from 'date-fns'`)
3. Local module imports (`import { useFileType } from '../useFileType'`, `import { rpcClient } from '../api/rpc-client'`)
4. Types/interfaces (inline in import statements via `type` keyword when needed)
5. No blank lines required between groups in practice
**Path Aliases:**
- TypeScript: `@` alias maps to `src/` (configured in `vitest.config.ts` and `tsconfig.json`)
- Usage: `import { displayVersion } from '@/utils/version'`
- Rust: crate-relative paths (`use crate::module::submodule`) and external crate paths
## Error Handling
**Patterns:**
- TypeScript: explicit try-catch with error type narrowing (e.g., `if (error instanceof Error) { ... }`)
- RPC client (`rpc-client.ts`): catches fetch errors, AbortError, and HTTP errors; distinguishes retryable (502, 503) from permanent (401, 403)
- Rust: `anyhow::Result<T>` for fallible operations; `?` operator for error propagation; `.context("message")` for adding context
- Backend error responses: JSON-RPC format with `error: { code, message, data? }` structure; UI catches and displays via toast system
- Network errors: automatic retry with exponential backoff (600ms × (attempt + 1) with jitter); configurable `maxRetries` per call
## Logging
**Framework:** console object for frontend, `tracing` crate for Rust backend
**Patterns:**
- Frontend: `console.warn`, `console.error` used selectively (e.g., `[RPC]` prefixed logs in `rpc-client.ts` for session/CSRF events)
- Rust: `tracing::info!`, `tracing::warn!` for structured logging; `println!` avoided in production code
- No log levels enforced or documented; pragmatic use based on severity
## Comments
**When to Comment:**
- Explain non-obvious retry logic, timeout decisions, CSRF handling (see `rpc-client.ts` lines 138178 for example)
- Clarify why a workaround exists (e.g., "Already on the login page: redirecting = a full reload")
- Document integration points with backend RPC methods and their expected response shapes
- Avoid redundant comments restating what the code obviously does
**JSDoc/TSDoc:**
- Function parameter types documented inline via TypeScript type annotations (e.g., `ext: Ref<string>`)
- Minimal use of explicit JSDoc blocks; type signature is the primary documentation
- Comments above exports explain purpose in one sentence (e.g., "// RPC Client for connecting to Archipelago backend")
- Optional fields in interfaces documented via property-level comments (e.g., `/** Abort the call from the outside … */`)
## Function Design
**Size:** Functions are typically 550 lines; error-handling paths in `callInner<T>` (`rpc-client.ts`) stretch to 120 lines but remain single-responsibility (retry logic + error classification)
**Parameters:**
- Prefer object parameters for 3+ arguments (e.g., `RPCOptions` object over separate `method, params, timeout`)
- Vue composables accept `Ref<T>` types to maintain reactivity (e.g., `useFileType(ext: Ref<string>, isDir: Ref<boolean>)`)
- Store action functions accept only necessary parameters; broader state via closure
**Return Values:**
- Composables return object with properties (computed values + reactive refs): `{ category, isImage, isAudio, ... }`
- Store actions return `void` or the modified state
- Utilities return simple values or objects (e.g., `formatSize` returns string, `formatDate` returns string)
- Async functions return `Promise<T>` with explicit type parameters (e.g., `async call<T>(options): Promise<T>`)
## Module Design
**Exports:**
- Composables export a single named function and helper functions: `export function useFileType(...)`, `export function getFileCategory(...)`
- Stores export the Pinia store factory: `export const useControllerStore = defineStore(...)`
- RPC client exports as singleton instance: `export const rpcClient = new RPCClient()`
- Utilities export multiple helpers from the same file (e.g., `formatSize`, `formatDate` from same module)
**Barrel Files:**
- Not observed as a primary pattern; each file self-documents its exports
- Imports use direct paths (e.g., `from '../composables/useFileType'`) rather than barrel `index.ts`
- Test files import specific utilities directly to minimize test setup complexity
## Type Safety
**Vue 3 with TypeScript:**
- Components use `<script setup lang="ts">` with `defineProps<{ ... }>()` and `defineEmits<{ ... }>()`
- Props explicitly typed as interfaces/objects with required/optional fields marked
- Events typed as call signatures (e.g., `'update:modelValue': [value: boolean]`)
- Reactive variables typed at declaration: `const isActive = ref<boolean>(false)`, or via inference when obvious
**Rust:**
- Explicit type annotations on public APIs; inference acceptable inside functions
- Generic parameters used to encode interface contracts (e.g., `struct DataUrl<'a>`)
- Pattern matching to handle enums and `Option<T>` / `Result<T, E>` types safely
## Component Architecture (Vue)
**File structure:**
- Single-file components with template → script → (optional) style
- Props come first, emits second, internal state/computed/methods follow
- One component per file (naming matches the file name)
- Slots used minimally; prefer explicit prop configuration over slot forwarding
**Reactivity:**
- `ref()` for primitive/object state; `computed()` for derived values
- `watch()` used for side effects on ref changes (not extensively shown in samples but implied)
- Pinia stores used for global state (authentication, app list, mesh status, etc.)
## Constants and Enums
**Pattern:** Constants are module-level `const` with UPPER_SNAKE_CASE names and immutable type annotations:
```typescript
const IMAGE_EXTS = new Set(['jpg', 'jpeg', ...])
const CATEGORY_COLORS: Record<FileCategory, string> = { ... }
```
**Enums:** Type aliases preferred over TypeScript `enum` keyword (e.g., `type FileCategory = 'folder' | 'image' | ...`)
---
*Convention analysis: 2026-07-29*

View File

@ -0,0 +1,235 @@
# External Integrations
**Analysis Date:** 2026-07-29
## APIs & External Services
**Bitcoin Protocol:**
- Bitcoin Core RPC endpoint - Primary blockchain interaction
- SDK/Client: `bitcoin` crate (v0.32.5), `reqwest` HTTP client
- Endpoint: `http://127.0.0.1:8332/` (configurable)
- Used for: Transaction broadcasting, UTXO validation, network sync status
- Auth: Basic HTTP auth (hardcoded RPC credentials in containers)
**Lightning Network:**
- Lightning Network Daemon (LND) - Layer 2 payments
- SDK/Client: Native REST API (`reqwest` + `serde_json`)
- REST Endpoint: `http://localhost:8080/` (container network)
- gRPC Endpoint: `http://localhost:10009/` (not currently used by backend)
- P2P Port: 9735
- Used for: Channel management, payment invoicing, routing
- Auth: Macaroon-based authentication (stored in `lnd-data:/root/.lnd`)
- Proxied: `/proxy/lnd/` → backend RPC auth + CORS handling
**Nostr Protocol:**
- Nostr Relays - Node discovery and encrypted messaging
- SDK/Client: `nostr-sdk` crate (v0.44, with NIP-04 and NIP-44 support)
- Usage: Optional, opt-in via `NOSTR_DISCOVERY_ENABLED` config
- Relays: Configurable via comma-separated `NOSTR_RELAYS` env var
- Transport: SOCKS5 Tor proxy optional via `NOSTR_TOR_PROXY` config
- Features: Ed25519 node identity publishing, encrypted peer handshake
- Related files: `core/archipelago/src/nostr_relays.rs`, `nostr_discovery.rs`, `nostr_handshake.rs`
**Mesh Networking:**
- Reticulum Protocol (RNS v1.3.5) - Local mesh radio coordination
- Daemon: `reticulum-daemon/` (Python daemon, RNS 1.3.5 + LXMF 1.0.1)
- Interface: Supervised as managed container
- LoRa Radio: Serial2 communication over USB (Meshtastic-compatible radios)
- P2P Discovery: mDNS (multicast DNS via `mdns-sd` crate)
- Mesh Ports: IPv6 dual-stack listeners (port mirroring to containers)
- Related files: `core/archipelago/src/mesh/`, `core/archipelago/src/mesh_ports.rs`
**Tor (Optional):**
- Tor SOCKS5 Proxy - Anonymous network routing
- Endpoint: `socks5h://127.0.0.1:9050` (default, configurable)
- Used for: Nostr relay connections (when routed through Tor)
- Client: `reqwest` with SOCKS feature enabled
- Config key: `nostr_tor_proxy`
**Fedimint:**
- Federated Custody Chaumian Mint - Alternative payment layer
- SDK/Client: JSON-RPC API (`reqwest` + `serde_json`)
- Endpoint: `http://localhost:8174/` (container network)
- P2P Port: 8173
- UI Port: 8175 (guardian management)
- Used for: Custody alternatives, blind signatures
- Related files: `core/archipelago/src/api/rpc/fedimint.rs`
**Decentralized Web Node (DWN):**
- DWN Health Check - Decentralized identity messaging
- Endpoint: `http://127.0.0.1:3100/health`
- Purpose: Node provisioning verification
- Related files: `core/archipelago/src/constants.rs`
## Data Storage
**Databases:**
- **Application Databases (optional, app-managed):**
- PostgreSQL: Immich, IndeedHub, Penpot, Endurain, Nextcloud
- MySQL/MariaDB: Mempool, Nextcloud
- Redis/Valkey: Immich, IndeedHub, Penpot (caching/sessions)
- SQLite: Optional local state (Cargo.toml comments out `sqlx`)
Connection: Via container network (not directly accessible from backend)
- **Key-Value Stores:**
- In-memory cache: Tokio sync primitives (Arc<DashMap>, Mutex)
- Persistent app state: User credentials, device tokens, manifest cache stored in `$DATA_DIR`
**File Storage:**
- Local filesystem only
- User data directory: `$ARCHIPELAGO_DATA_DIR` (default `/var/lib/archipelago/`)
- Subdirectories: `apps/`, `secrets/`, `backups/`, `content/`, `catalog/`
- App-specific: `/var/lib/archipelago/<app>/` (mounted into containers)
- Content sharing: Peer-to-peer via HTTP Range requests (see `content_server.rs`)
**Caching:**
- No external cache service
- In-app caching: Tokio-spawned tasks, Arc<DashMap> for concurrent access
- Browser caching: Workbox service worker (5-min API cache, 30-day asset cache, 1-year font cache)
## Authentication & Identity
**Auth Provider:**
- Custom JWT-based (node-local)
- Implementation: Ed25519 signing (key in `credentials/` directory)
- Session tokens: Stored browser-side via cookies (CSRF token middleware)
- Related files: `core/archipelago/src/auth.rs`, `core/archipelago/src/identity_manager.rs`
**BIP-39 Mnemonic Seed:**
- Seed generation: `bip39` crate (v2.1.0)
- HD key derivation: `bitcoin` crate (v0.32.5) with BIP-32
- Signing: Ed25519 for identity, ECDSA for Bitcoin transactions
- Related files: `core/archipelago/src/seed.rs`
**Identity (Decentralized):**
- Nostr npub (from Ed25519 keys)
- DID (Decentralized Identifier) via did:dht (BitTorrent DHT)
- Related files: `core/archipelago/src/identity.rs`, `core/archipelago/src/nostr_discovery.rs`
**2FA:**
- TOTP (Time-based One-Time Password)
- Library: `totp-rs` (v5.7, with otpauth and gen_secret)
- QR generation: `qrcode` crate (v0.14)
- Encrypted storage: Argon2-derived key + ChaCha20-Poly1305
- Related files: `core/archipelago/src/totp.rs`
## Monitoring & Observability
**Error Tracking:**
- Not integrated (logging only)
**Logs:**
- Approach: Structured logging via `tracing` crate
- Output: stdout (configurable level via `RUST_LOG` or config file)
- Subscriber: `tracing-subscriber` with `env-filter`
- Related files: `core/archipelago/src/monitoring.rs`, `core/archipelago/src/health_monitor.rs`
**Health Monitoring:**
- Health checks: Container liveness probes (Docker/Podman healthcheck)
- System metrics: Disk space, memory, container startup tiers
- Related files: `core/archipelago/src/health_monitor.rs`
## CI/CD & Deployment
**Hosting:**
- Bare metal (Debian Linux) with Podman rootless containers
- Fleet nodes: .198, .228, .116, x250-dev, Framework PT (various test/dev targets)
**CI Pipeline:**
- Git-based CI: Gitea (self-hosted at `.160:3000` and `.168:3000`)
- Push accounts: `gitea-ai` (for protected main branch)
- Build pipeline: Local `cargo build`/`npm run build` (not centralized CI/CD service)
- Release: Manual versioning + signed OTA manifests
- Docker builds: Local `docker-compose` or Dockerfile.web/backend
**Deployment:**
- Container orchestration: Podman with Quadlet systemd units (Phase 3+)
- Legacy fallback: Direct `podman create + systemctl start`
- OTA (Over-The-Air): Signed manifest-driven updates (v1.7+)
- Sideload: Binary + tarball to `/usr/local/bin/archipelago` and `/opt/archipelago/`
## Environment Configuration
**Required env vars:**
- `ARCHIPELAGO_DATA_DIR` - Local state directory (default: platform-specific)
- `ARCHIPELAGO_LOG_LEVEL` - Logging verbosity (default: `info`)
- `CONTAINER_RUNTIME` - `podman` or `docker` (auto-detect by default)
- `NOSTR_DISCOVERY_ENABLED` - Enable node publishing to Nostr relays (false by default)
- `NOSTR_RELAYS` - Comma-separated relay URLs (if discovery enabled)
- `NOSTR_TOR_PROXY` - SOCKS5 proxy address (optional, routes Nostr through Tor)
- `VITE_AIUI_URL` - AIUI chat interface URL (frontend, optional)
- `BACKEND_URL` - Backend target for dev server (frontend, default: `http://localhost:5959`)
**Secrets location:**
- At-rest: `$DATA_DIR/credentials/` (user.json with encrypted TOTP, session keys)
- In-container: Mounted as read-only volumes, never logged
- No `.env` file in production (config-driven)
## Webhooks & Callbacks
**Incoming:**
- Marketplace callbacks - App catalog updates from registry
- Peer discovery webhooks (via Nostr relays)
- Related files: `core/archipelago/src/webhooks.rs`
**Outgoing:**
- Device token push notifications (not yet implemented)
- App install/uninstall event notifications (framework-pt integration)
- Related files: `core/archipelago/src/device_tokens.rs`
## Container-Based Services (Orchestrated by Archipelago)
**Bitcoin Stack:**
- Bitcoin Core (lncm/bitcoind:v27.0 or knots variant)
- ElectrumX (via separate image)
- Electrs (alternative to ElectrumX)
**Lightning/Payments:**
- LND (lightninglabs/lnd:v0.17.4-beta+)
- BTCPay Server (btcpayserver:1.13.5+)
- Fedimint (fedimint/fedimintd:v0.10.0+)
**Media & Content:**
- Immich (self-hosted photo/media library)
- Nextcloud (cloud storage)
- OnlyOffice (document server)
- Penpot (design tool)
- SearXNG (search engine)
- FileBrowser (file management UI)
**Infrastructure:**
- nginx (reverse proxy, CORS, rate limiting)
- Home Assistant (home automation hub)
- Grafana (metrics dashboard)
- ThunderHub (Lightning node UI)
- Mempool Explorer (blockchain monitor)
- Endurain (fitness tracking)
- Morphos (file converter)
- IndeedHub (job board)
- Pine (voice assistant)
## Integration Points (API Contracts)
**Backend ↔ Frontend (gRPC-style RPC):**
- Endpoint: `/rpc/v1/*` (HTTP/REST)
- Related files: `core/archipelago/src/api/rpc/` (all RPC handlers)
- Major modules: `auth.rs`, `bitcoin.rs`, `lnd/`, `container.rs`, `marketplace.rs`, `mesh/`
**Frontend ↔ App Iframes:**
- Endpoint: `/app/<app-name>/*` (proxied to container)
- Sandbox: Cross-origin iframe isolation
**Mesh Node ↔ Reticulum:**
- Serial: USB LoRa radio (Meshtastic-compatible)
- Protocol: Binary Meshcore format
- Related files: `core/archipelago/src/mesh/`
**Catalog ↔ App Registry:**
- Endpoint: `/api/app-catalog` (cached from registry)
- Format: Signed YAML manifest + SHA256 verification
- Related files: `core/archipelago/src/marketplace.rs`, `app_catalog/`
---
*Integration audit: 2026-07-29*

176
.planning/codebase/STACK.md Normal file
View File

@ -0,0 +1,176 @@
# Technology Stack
**Analysis Date:** 2026-07-29
## Languages
**Primary:**
- Rust 2021 edition - Backend server (Archipelago daemon, container orchestrator)
- TypeScript 5.9 - Frontend (Vue components, application code)
- Vue 3 (TypeScript) - UI framework and reactive components
- Python 3.13 - Reticulum mesh daemon (RNS/LXMF protocols)
**Secondary:**
- JavaScript - Build scripts, mock backend (`neode-ui/mock-backend.js`), test utilities
- YAML - Configuration (docker-compose, app manifests)
- Shell - Build scripts, deployment helpers
## Runtime
**Environment:**
- Rust (1.70+) - Native compiled binaries
- Node.js 22 (Alpine-based containers) - Frontend dev/build, mock backend
- Python 3.13 - Reticulum daemon runtime
- Tokio async runtime (v1, full features) - Async server runtime
**Package Manager:**
- npm (v10+) - JavaScript dependencies
- Cargo (v1.70+) - Rust dependencies
- pip - Python dependencies (Reticulum stack)
- Lockfiles: `neode-ui/package-lock.json`, `core/Cargo.lock`
## Frameworks
**Core:**
- Tokio (v1) - Async Rust runtime, full features (networking, signals, sync primitives)
- Vue 3 (v3.5) - Progressive web framework with TypeScript support
- Hyper (v0.14) - HTTP/1 server framework with WebSocket support
- Reticulum (v1.3.5) - Mesh networking protocol stack
- LXMF (v1.0.1) - Lightweight message format protocol
**HTTP & WebSocket:**
- Hyper v0.14 (HTTP/1, full features) - Core HTTP server
- Hyper-util v0.1 - HTTP utilities
- Tower v0.5 - Middleware and service composing
- Tower-http v0.6 - CORS, tracing middleware
- Hyper-ws-listener v0.3 - WebSocket upgrade handler
- Tokio-tungstenite v0.20 - WebSocket client/server implementation
- Reqwest v0.11 - HTTP client (with rustls-tls, SOCKS proxy support, JSON)
**Frontend Build:**
- Vite v7.2 - Build tool and dev server
- Vue-tsc v3.1 - TypeScript compilation for Vue
- Tailwind CSS v3.4 - Utility-first CSS framework
- Autoprefixer v10.4 - CSS vendor prefixes
- PostCSS v8.5 - CSS processing
**Testing:**
- Vitest v3.1 - Unit test runner (Vite-native)
- Playwright v1.58 - E2E testing (browser automation)
- @vue/test-utils v2.4 - Vue component testing
- JSDOM v25 - DOM implementation for tests
- Tokio-test v0.4 - Async Rust test utilities
**PWA:**
- Vite-plugin-pwa v1.2 - Progressive Web App support
- Workbox integration - Service worker caching strategies
## Key Dependencies
**Critical:**
- bitcoin v0.32.5 - Bitcoin library (with rand-std feature for BIP-39/BIP-32)
- bip39 v2.1.0 - Mnemonic seed generation
- nostr-sdk v0.44 - Nostr protocol (NIP-04, NIP-44 encrypted messaging)
- mainline v2 - BitTorrent DHT (did:dht decentralized identity)
- reticulum v1.3.5 - Mesh networking protocol
- lxmf v1.0.1 - Lightweight message format
**Cryptography:**
- ed25519-dalek v2.2 - Ed25519 digital signatures (with rand_core)
- curve25519-dalek v4.1 - X25519 elliptic curve (key agreement)
- blake3 v1 - BLAKE3 hash function
- bcrypt v0.15 - Password hashing
- sha2 v0.10 - SHA-256 hashing
- hmac v0.12 - HMAC authentication
- argon2 v0.5 - Argon2 password hashing
- chacha20poly1305 v0.10 - AEAD encryption
- zeroize v1.8 - Secure memory wiping
**Authentication & Identity:**
- uuid v1.0 - UUID generation (v4)
- totp-rs v5.7 - TOTP 2FA (with otpauth, gen_secret)
- qrcode v0.14 - QR code generation (server-side)
**Data Serialization:**
- serde v1.0 - Serialization framework (with derive)
- serde_json v1.0 - JSON codec
- serde_yaml v0.9 - YAML codec
- ciborium v0.2 - CBOR encoding/decoding
- serde_bytes v0.11 - Efficient byte serialization
- toml v0.8 - TOML config parsing
**Networking & Mesh:**
- mdns-sd v0.18 - mDNS service discovery
- serial2-tokio v0.1 - Serial port communication (LoRa radios over USB)
- socket2 v0.5 - Low-level socket options (IPv6_V6ONLY for dual-stack)
- libc v0.2 - Process group signaling
**Compression & Archives:**
- tar v0.4 - TAR archive creation
- flate2 v1.0 - gzip compression
- zip v2.0 - ZIP archive handling (LoRa firmware flashing)
- reed-solomon-erasure v6.0 - Erasure coding (Phase 2 mesh transport)
- hkdf v0.12 - HKDF key derivation (Phase 3 encrypted mesh)
**Utilities:**
- anyhow v1.0 - Error handling
- thiserror v1.0 - Error types with derive macros
- tracing v0.1 - Structured logging
- tracing-subscriber v0.3 - Log filtering and formatting
- regex v1.10 - Pattern matching
- chrono v0.4 - Date/time handling
- hex v0.4 - Hex encoding/decoding
- bs58 v0.5 - Base58 encoding (Bitcoin addresses)
- base64 v0.21 - Base64 encoding
- zbase32 v0.1 - Z-base-32 encoding (DHT)
- data-encoding v2.6 - Multiple encoding schemes
- bytes v1 - Efficient byte buffer
- futures-util v0.3 - Async utilities
- http-body-util v0.1 - HTTP body utilities
- http-body v1.0 - HTTP body abstractions
- indexmap v2.0 - Ordered maps
- async-trait v0.1 - Async trait methods
- sd-notify v0.4 - Systemd watchdog notification
**Infrastructure (Optional):**
- iroh v1 (optional, feature-gated) - QUIC-based peer swarm engine (Phase 2)
- iroh-blobs v0.103 (optional, feature-gated) - Content addressable storage provider
## Configuration
**Environment:**
- Config file: `config/archipelago.toml` or `$DATA_DIR/config.yml`
- Runtime options: Docker/Podman selection, dev/prod modes, FIPS anchor selection, Nostr discovery
- Key env vars: `ARCHIPELAGO_DATA_DIR`, `ARCHIPELAGO_LOG_LEVEL`, `CONTAINER_RUNTIME`, `NOSTR_DISCOVERY_ENABLED`, `NOSTR_RELAYS`, `NOSTR_TOR_PROXY`
**Build:**
- Cargo workspace at `core/` (5 members: archipelago, container, openwrt, performance, security)
- Release profile: opt-level 3
- Dev profile: opt-level 0
- Test profile: opt-level 3
- Cross-compilation: aarch64-unknown-linux-gnu via `.cargo/config.toml`
**Frontend Build:**
- Vite config: `neode-ui/vite.config.ts` (development port 8100, production build to `../web/dist/neode-ui`)
- TypeScript config: `neode-ui/tsconfig.json`
- Module path alias: `@``src/`
## Platform Requirements
**Development:**
- Rust 1.70+ with Cargo
- Node.js 22+ with npm
- Python 3.13+ (for Reticulum daemon)
- Docker or Podman (for local app testing)
- rustup target: `aarch64-unknown-linux-gnu` (for ARM64 cross-compilation)
- gcc-aarch64-linux-gnu (for cross-compilation toolchain on Linux hosts)
**Production:**
- Deployment target: Debian/Alpine Linux (rootless Podman)
- Binary output: `/usr/local/bin/archipelago` (sideloaded or via Quadlet systemd units)
- Frontend served: nginx with Vite-built SPA (PWA manifest, service worker, CORS proxies)
- Database support: Optional (SQLx with SQLite driver available but commented out)
---
*Stack analysis: 2026-07-29*

View File

@ -0,0 +1,434 @@
# Codebase Structure
**Analysis Date:** 2026-07-29
## Directory Layout
```
archipelago-repo/
├── core/ # Rust workspace root (Cargo.toml at workspace level)
│ ├── archipelago/ # Main daemon binary (backend)
│ │ ├── src/
│ │ │ ├── main.rs # Entry point, startup, background tasks
│ │ │ ├── server.rs # HTTP server (Hyper), listener, connection multiplexing
│ │ │ ├── state.rs # StateManager + data_model.rs (central state)
│ │ │ ├── auth.rs # User auth, password hashing, session management
│ │ │ ├── identity.rs # Node identity, Ed25519 keys, Tor address
│ │ │ ├── config.rs # Config loading, data directory setup
│ │ │ ├── api/ # HTTP API layer
│ │ │ │ ├── handler/ # HTTP request dispatch, WebSocket, content proxy
│ │ │ │ └── rpc/ # JSON-RPC 2.0 methods (~40 domain modules)
│ │ │ │ ├── auth.rs # auth.login, auth.setup, etc.
│ │ │ │ ├── container.rs # container.install, .list, .start, etc.
│ │ │ │ ├── bitcoin.rs # bitcoin.status, bitcoin.send, etc.
│ │ │ │ ├── mesh.rs # mesh.* (peer discovery, LoRa, federation)
│ │ │ │ ├── wallet.rs # wallet.* (lightning, Bitcoin)
│ │ │ │ └── [20+ other domains]
│ │ │ ├── container/ # Container orchestration (Podman)
│ │ │ │ ├── prod_orchestrator.rs # Main Podman lifecycle (>250KB)
│ │ │ │ ├── boot_reconciler.rs # Periodic manifest sync loop
│ │ │ │ ├── docker_packages.rs # Image registry, image verification
│ │ │ │ ├── quadlet.rs # Systemd Quadlet generation
│ │ │ │ ├── secrets.rs # Secret injection (0600 files)
│ │ │ │ ├── lnd.rs # Lightning Network Daemon container setup
│ │ │ │ ├── app_catalog.rs # Signed app catalog, manifest overlay
│ │ │ │ └── [data managers, registry, image policy]
│ │ │ ├── bootstrap.rs # Post-startup tasks (systemd units, audio stack, gamepad)
│ │ │ ├── crash_recovery.rs # Container recovery after crash, PID marker
│ │ │ ├── health_monitor.rs # Periodic app health checks, restart
│ │ │ ├── mesh.rs # Mesh P2P listener, sender, LoRa radio control
│ │ │ ├── federation.rs # Federation (DNS-SD, HTTP API)
│ │ │ ├── fips/ # FIPS anchor (Tor bridge to peer)
│ │ │ ├── bitcoin_rpc.rs # Bitcoin Core RPC client calls
│ │ │ ├── bitcoin_status.rs # Bitcoin sync status polling
│ │ │ ├── content_server.rs # Content-addressed blob server (CAP tokens)
│ │ │ ├── blobs.rs # BlobStore (hash→file mapping, encryption)
│ │ │ ├── wallet.rs # Lightning + Bitcoin wallet logic
│ │ │ ├── identity_manager.rs # Seed derivation, key rotation
│ │ │ ├── marketplace.rs # Marketplace transaction logic
│ │ │ ├── transport.rs # Transport selection (Mesh/FIPS/Tor routing)
│ │ │ ├── update.rs # OTA update apply, verification, rollback
│ │ │ ├── session.rs # Session store (SQLite or in-memory)
│ │ │ ├── rate_limit.rs # Rate limiter (per-IP, per-endpoint, per-user)
│ │ │ ├── monitoring.rs # Metrics collection (app count, memory, etc.)
│ │ │ ├── data_model.rs # State struct tree (serde Serialize/Deserialize)
│ │ │ ├── constants.rs # Global constants (version, defaults)
│ │ │ ├── peer*.rs, webhook*.rs, nostr*.rs, vpn.rs, etc.
│ │ │ └── seed.rs # Seed storage, backup QR generation
│ │ ├── Cargo.toml # Dependencies
│ │ └── tests/ # Unit/integration tests
│ │
│ ├── container/ # Container management library (OCI types, Podman)
│ │ ├── src/
│ │ │ ├── manifest.rs # Manifest struct (YAML parsing)
│ │ │ ├── runtime.rs # Podman CLI calls (create, start, stop, logs)
│ │ │ ├── podman_client.rs # Podman socket API client
│ │ │ ├── image_verify.rs # Image signature verification (Cosign)
│ │ │ └── port_manager.rs # Port allocation, conflict detection
│ │ └── Cargo.toml
│ │
│ ├── security/ # Secrets management library
│ │ ├── src/
│ │ │ ├── secrets_manager.rs # Secret encryption/decryption (ChaCha20)
│ │ │ └── vault.rs # Vault storage, rotation
│ │ └── Cargo.toml
│ │
│ ├── performance/ # Performance monitoring library
│ ├── openwrt/ # OpenWrt device integration
│ ├── Cargo.toml # Workspace manifest (members: archipelago, container, security, etc.)
│ └── Cargo.lock # Locked dependency versions
├── neode-ui/ # Frontend (Vue 3, TypeScript)
│ ├── src/
│ │ ├── main.ts # Vue app entry point, Router setup, WebSocket init
│ │ ├── App.vue # Root component (layout, nav)
│ │ ├── router/
│ │ │ └── index.ts # Vue Router config (routes, guards)
│ │ ├── views/ # Page-level components (one per route)
│ │ │ ├── Home.vue # Dashboard
│ │ │ ├── Apps.vue # App browser + installer
│ │ │ ├── AppDetails.vue # Single app detail + logs
│ │ │ ├── AppSession.vue # Iframe container for app content
│ │ │ ├── Cloud.vue # File browser (WebDAV/DWN)
│ │ │ ├── Mesh.vue # Mesh map, contacts, messages
│ │ │ ├── Wallet.vue # Lightning + Bitcoin addresses/sends
│ │ │ ├── Marketplace.vue # Paid apps, content marketplace
│ │ │ ├── Server.vue # Node status, settings, restart
│ │ │ ├── Federation.vue # Federation peers, federation apps
│ │ │ ├── Onboarding*/ # Multi-step setup flow
│ │ │ └── [15+ other pages]
│ │ ├── components/ # Reusable UI components
│ │ │ ├── AppCard.vue # App listing card
│ │ │ ├── AppInstaller.vue # Install modal
│ │ │ ├── Modal.vue # Generic modal (teleported)
│ │ │ ├── Toast.vue # Notification toast
│ │ │ ├── MeshGraph.vue # Mesh topology graph (D3)
│ │ │ ├── MapView.vue # Mesh map (Leaflet)
│ │ │ ├── QrScanner.vue # QR code input
│ │ │ └── [30+ other components]
│ │ ├── composables/ # Logic hooks (Vue composition API)
│ │ │ ├── useAuth.ts # Login/logout logic
│ │ │ ├── useAppStore.ts # Access app Pinia store
│ │ │ ├── useRpc.ts # Make RPC calls
│ │ │ ├── useWebSocket.ts # WebSocket connection management
│ │ │ ├── useOnboarding.ts # Onboarding flow state
│ │ │ ├── useControllerNav.ts # Gamepad controller navigation
│ │ │ └── [20+ other composables]
│ │ ├── stores/ # Pinia state management (reactive stores)
│ │ │ ├── appStore.ts # Apps list, install state
│ │ │ ├── walletStore.ts # Lightning/Bitcoin addresses, balance
│ │ │ ├── meshStore.ts # Mesh peers, messages
│ │ │ ├── settingsStore.ts # User settings, theme, language
│ │ │ ├── userStore.ts # Current user identity
│ │ │ └── [other stores]
│ │ ├── api/
│ │ │ └── rpc-client.ts # RPC client library (request, WebSocket, reconnect)
│ │ ├── services/ # Business logic (not Vue-dependent)
│ │ │ ├── qrScanner.ts # QR scanner initialization
│ │ │ └── [other services]
│ │ ├── utils/ # Utility functions
│ │ │ ├── format.ts # Date, number, currency formatting
│ │ │ ├── validate.ts # Input validation (emails, addresses)
│ │ │ ├── crypto.ts # Client-side crypto (BIP39, etc.)
│ │ │ └── [helpers]
│ │ ├── types/ # TypeScript type definitions
│ │ │ ├── index.ts # Export all types
│ │ │ └── [domain-specific types]
│ │ ├── i18n.ts # Internationalization config
│ │ ├── locales/ # Translation files
│ │ │ ├── en.json # English
│ │ │ ├── es.json # Spanish
│ │ │ └── [other languages]
│ │ ├── assets/ # Static assets
│ │ │ └── icon/ # App icons, favicons
│ │ ├── style.css # Global CSS (Tailwind + custom)
│ │ ├── data/ # Static data (country lists, etc.)
│ │ └── e2e/ # Playwright E2E tests
│ │ ├── intro-experience.spec.ts
│ │ └── app-launch.spec.ts
│ │
│ ├── public/ # Static web root
│ │ ├── index.html # HTML entry point
│ │ ├── favicon.ico # Browser tab icon
│ │ ├── manifest.json # PWA manifest
│ │ └── catalog.json # App catalog (copied from app-catalog/catalog.json)
│ │
│ ├── package.json # Frontend dependencies + build scripts
│ ├── tsconfig.json # TypeScript config
│ ├── vite.config.ts # Vite build config
│ ├── vitest.config.ts # Vitest test runner config
│ ├── tailwind.config.js # Tailwind CSS config
│ ├── mock-backend.js # Dev mock server (for `npm run dev:mock`)
│ └── [other build configs]
├── apps/ # Containerized applications (app manifests + build scripts)
│ ├── bitcoin-core/ # Bitcoin Core container
│ │ ├── manifest.yml # Archipelago manifest (interface, ports, secrets, health)
│ │ ├── Dockerfile # OCI image definition
│ │ ├── bitcoin.conf.template # Config template (secrets injected at runtime)
│ │ └── start.sh # Container entrypoint
│ │
│ ├── lightning-stack/ # Lightning Network stack (LND)
│ ├── lnd/ # LND daemon
│ ├── immich/ # Photo backup app
│ ├── nextcloud/ # Cloud storage
│ ├── electrumx/ # Bitcoin block explorer index
│ ├── router/ # Mesh router app
│ ├── pine/ # Voice assistant (whisper + piper + nginx)
│ ├── vaultwarden/ # Password manager
│ ├── [40+ other apps]
│ ├── QUICKSTART.md # App development guide
│ ├── PORTS.md # Port allocation reference
│ └── build.sh # App build script (all apps)
├── web/ # Built frontend output
│ └── dist/
│ └── neode-ui/ # `npm run build` output (served by nginx)
│ ├── index.html
│ ├── [JS bundles]
│ └── [static assets]
├── tests/ # Test suite
│ ├── lifecycle/
│ │ ├── run-gate.sh # Single-node production gate (5 iterations)
│ │ └── TESTING.md # Test plan documentation
│ ├── e2e/ # End-to-end tests (Playwright)
│ └── [other test directories]
├── docs/ # Documentation (user & developer guides)
│ ├── PRODUCTION-MASTER-PLAN.md # North star: manifest-driven, registry-based, decentralized
│ ├── UNIFIED-TASK-TRACKER.md # Open tasks (fastest-first)
│ ├── APP-PACKAGING-MIGRATION-PLAN.md
│ ├── registry-manifest-design.md
│ ├── multinode-testing-plan.md
│ ├── release-workflow.md # OTA + ISO release process
│ ├── app-development.md # Guide for app developers
│ ├── api-rpc-reference.md # JSON-RPC 2.0 method documentation
│ └── [20+ other docs]
├── image-recipe/ # ISO/image build scripts
│ ├── build-debian-iso.sh # Builds bootable Debian ISO
│ ├── include/ # Root filesystem overlays
│ │ └── opt/archipelago/ # Pre-baked config, scripts, systemd units
│ └── [other image components]
├── scripts/ # Utility scripts
│ ├── deploy-to-target.sh # Sideload binary to test node (Tailscale SSH + rsync)
│ ├── resilience/ # Resilience test scripts
│ └── [other scripts]
├── demo/ # Demo deployment (pre-configured node)
│ ├── demo-deploy.yml # Docker Compose for vps2 demo
│ └── [demo-specific scripts]
├── docker/ # Docker/Podman config
│ └── [docker-compose fragments, Dockerfiles]
├── .planning/ # Codebase analysis documents (this is you)
│ └── codebase/
│ ├── ARCHITECTURE.md
│ ├── STRUCTURE.md
│ ├── CONVENTIONS.md
│ ├── TESTING.md
│ ├── STACK.md
│ ├── INTEGRATIONS.md
│ └── CONCERNS.md
├── .claude/ # Claude Code configuration
│ └── skills/ # GSD skills (if any project-specific)
├── .github/ # GitHub Actions CI/CD
├── .gitea/ # Gitea CI/CD (local Gitea runner)
├── .git/ # Git repository
├── .gitignore # Git ignore patterns
├── CLAUDE.md # Project instructions (commit rules, invariants, testing)
├── Cargo.lock # Locked Rust dependency versions
├── CHANGELOG.md # Release notes
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
└── README.md # Project overview
```
## Directory Purposes
**core/**
- Purpose: Rust backend source and dependencies
- Contains: Main daemon binary, container orchestration, RPC handlers, business logic
- Key files: `archipelago/src/main.rs` (entry point), `archipelago/Cargo.toml` (deps)
**neode-ui/**
- Purpose: Vue 3 frontend SPA source
- Contains: Views, components, stores, translations, tests, build config
- Key files: `src/main.ts` (entry point), `vite.config.ts` (build), `package.json` (deps)
**apps/**
- Purpose: Containerized application definitions
- Contains: Manifests (YAML), Dockerfiles, configs for ~50 apps (Bitcoin, LND, Immich, etc.)
- Key files: `*/manifest.yml` (app definition), `*/Dockerfile` (image build)
**web/dist/neode-ui/**
- Purpose: Production frontend output (built by `npm run build`)
- Contains: Bundled JS, HTML, static assets
- Key files: `index.html` (entry), JS chunks (hashed filenames)
- Note: Served by nginx at `/` on node
**tests/**
- Purpose: Test suite (unit, integration, E2E)
- Contains: Lifecycle gate (production exit criterion), E2E specs, test utilities
- Key files: `lifecycle/run-gate.sh` (the production gate), `e2e/*.spec.ts` (user flows)
**docs/**
- Purpose: User and developer documentation
- Contains: Architecture, design decisions, release process, task tracking
- Key files: `PRODUCTION-MASTER-PLAN.md` (north star), `UNIFIED-TASK-TRACKER.md` (open tasks)
**image-recipe/**
- Purpose: ISO/image build scripts
- Contains: Debian ISO recipe, root filesystem overlays, bootloader config
- Key files: `build-debian-iso.sh` (main build), `include/opt/archipelago/` (pre-baked system config)
**scripts/**
- Purpose: Utility and deployment scripts
- Contains: Sideload/deploy to test nodes, resilience testing, CI glue
- Key files: `deploy-to-target.sh` (ship binary to remote node)
## Key File Locations
**Entry Points:**
- Backend daemon: `core/archipelago/src/main.rs` (spawns HTTP server, tasks, orchestrator)
- Frontend app: `neode-ui/src/main.ts` (Vue app, router, WebSocket init)
- App manifest: `apps/*/manifest.yml` (Archipelago-specific; controls container, secrets, UI)
- Production gate: `tests/lifecycle/run-gate.sh` (exit criterion for releases)
**Configuration:**
- Backend config: `core/archipelago/src/config.rs` (data dir, bind port, dev mode flag)
- Frontend config: `neode-ui/vite.config.ts` (build settings, env vars)
- App registries: `/var/lib/archipelago/registries.json` (user-configured Gitea mirrors)
- Secrets location: `/var/lib/archipelago/secrets/` (encrypted ChaCha20 files)
**Core Logic:**
- Container orchestration: `core/archipelago/src/container/prod_orchestrator.rs` (300KB+ main logic)
- RPC dispatch: `core/archipelago/src/api/rpc/mod.rs` (method routing, ~200 RPCs)
- State management: `core/archipelago/src/state.rs` (StateManager) + `core/archipelago/src/data_model.rs` (struct def)
- Frontend stores: `neode-ui/src/stores/` (Pinia stores, reactive state)
**Testing:**
- Rust unit tests: Inline in `core/` modules (use `#[test]` and `#[tokio::test]`)
- Vitest unit tests: `neode-ui/src/**/__tests__/*.test.ts`
- E2E tests: `neode-ui/e2e/*.spec.ts` (Playwright)
- Integration tests: `tests/` (shell scripts, node command testing)
## Naming Conventions
**Files:**
- Rust modules: `snake_case.rs` (e.g., `health_monitor.rs`, `crash_recovery.rs`)
- Vue components: `PascalCase.vue` (e.g., `AppCard.vue`, `MeshGraph.vue`)
- Composables: `use[Name].ts` (e.g., `useAuth.ts`, `useRpc.ts`)
- Stores: `[domain]Store.ts` (e.g., `appStore.ts`, `walletStore.ts`)
- Tests: `[name].test.ts` or `[name].spec.ts` (e.g., `rpc-client.test.ts`, `app-launch.spec.ts`)
- Scripts: lowercase with hyphens (e.g., `deploy-to-target.sh`, `run-gate.sh`)
**Directories:**
- Rust workspace members: lowercase (e.g., `archipelago`, `container`, `security`)
- Feature directories: PascalCase or lowercase depending on context
- `neode-ui/src/views/` — page components (mostly PascalCase)
- `neode-ui/src/composables/` — logic hooks (lowercase files with `use` prefix)
- `core/archipelago/src/api/rpc/` — RPC modules by domain (lowercase: `bitcoin.rs`, `mesh.rs`)
**Functions/Methods:**
- Async functions: `async fn method_name() -> Result<T>` (no special suffix)
- Event handlers: `on[Event]` in Vue (e.g., `@click="onInstall"` calls `onInstall()`)
- Computed properties: `computed(() => ...)` (no special name)
- Public RPC methods: `pub async fn [domain]_[action](...)` (e.g., `container_install`, `bitcoin_send`)
**Variables & Constants:**
- Constants: `UPPER_SNAKE_CASE` (e.g., `RECONCILER_DEFAULT_INTERVAL`, `MAX_FILE_SIZE`)
- State variables: `camelCase` (e.g., `appList`, `isLoading`)
- Type aliases: `PascalCase` (e.g., `AppId`, `MeshPeer`)
**Exports & Modules:**
- Re-exports barrel files: `mod.rs` exporting `pub use child::*;`
- Private internals: `mod private;` (not `pub mod`)
- Path aliases (neode-ui): `@/` = `src/`, `@components/` = `src/components/`
## Where to Add New Code
**New RPC Method (Backend):**
1. Determine domain (auth, container, bitcoin, mesh, wallet, etc.)
2. Add async fn to `core/archipelago/src/api/rpc/[domain].rs`
3. Function signature: `pub async fn [action](handler: &RpcHandler, params: [ParamType]) -> Result<[ResponseType]>`
4. Register in `core/archipelago/src/api/rpc/mod.rs` dispatcher (line ~350+, search for `match method_name`)
5. Test: Unit test in same file with `#[tokio::test]`, or E2E in `tests/`
**New Frontend View (Page):**
1. Create `neode-ui/src/views/[ViewName].vue` (PascalCase)
2. Import Router in `neode-ui/src/router/index.ts`, add route
3. Add navigation link in `neode-ui/src/components/Nav.vue` (if public-facing)
4. State: Use or create Pinia store in `neode-ui/src/stores/`
5. Test: Add E2E test in `neode-ui/e2e/` if user-facing flow
**New Container App:**
1. Create directory `apps/[app-name]/`
2. Write `manifest.yml` (copy structure from existing app; define interfaces.main.ui, health check, secrets)
3. Write `Dockerfile` (base image, deps, entrypoint)
4. Add to `app-catalog/catalog.json` with entry (id, version, url to manifest)
5. Test: `archipelago container.install { manifest_url: "..." }` on dev node
**New Component (Frontend):**
1. Create `neode-ui/src/components/[ComponentName].vue`
2. If reusable logic, extract to `neode-ui/src/composables/use[Logic].ts`
3. If shared state, use Pinia store (don't create component-local state)
4. Example: `components/AppCard.vue` displays one app (reused in Apps.vue listing)
**New Utility Function:**
- Backend service logic: Add to `core/archipelago/src/[domain].rs` or new file if it's cross-cutting
- Frontend helper: Add to `neode-ui/src/utils/` (e.g., `format.ts`, `validate.ts`)
- Example: Lightning address validation → `neode-ui/src/utils/validate.ts:validateLightningAddress()`
**New Test:**
- Rust unit test: Inline in source file (`#[test]` or `#[tokio::test]`)
- Frontend unit test: `neode-ui/src/composables/__tests__/use[Logic].test.ts`
- E2E test: `neode-ui/e2e/[feature].spec.ts` (Playwright)
- Integration test: `tests/[feature].sh` (shell script running node commands)
## Special Directories
**core/target/**
- Purpose: Rust build artifacts (generated)
- Generated: Yes (by `cargo build`)
- Committed: No (in .gitignore)
**neode-ui/node_modules/**
- Purpose: npm dependencies
- Generated: Yes (by `npm install` or `pnpm install`)
- Committed: No (in .gitignore)
**web/dist/**
- Purpose: Built frontend output (generated)
- Generated: Yes (by `npm run build`)
- Committed: No (in .gitignore) — distributed via OTA/ISO
**/var/lib/archipelago/** (at runtime on node)
- Purpose: Data directory (user data, settings, secrets, backups)
- Generated: Yes (created by daemon on first boot)
- Committed: No (runtime data; contains user secrets)
- Subdirs:
- `identity/` — Node Ed25519 keys
- `secrets/` — Encrypted secret vaults (ChaCha20)
- `apps/` — App manifests (disk copies or registry overlays)
- `backups/` — Encrypted backup archives
- `registries.json` — User-configured Gitea mirrors
- etc.
**/opt/archipelago/** (at runtime on node)
- Purpose: System-level Archipelago files (read-only on ISO, writable post-install)
- Contains:
- `web-ui/` — Built frontend (nginx root)
- `bin/archipelago` — Daemon binary
- `docker/` — Docker Compose or Quadlet files (orchestration)
- `scripts/` — System maintenance scripts
- Note: OTA updates overwrite `web-ui/` + `bin/` atomically
---
*Structure analysis: 2026-07-29*

View File

@ -0,0 +1,449 @@
# Testing Patterns
**Analysis Date:** 2026-07-29
## Test Framework
**Frontend:**
- Runner: Vitest 3.1.1
- Config: `neode-ui/vitest.config.ts`
- Environment: jsdom (DOM testing in Node.js)
- Globals: enabled (`globals: true`) — `describe`, `it`, `expect` available without imports
- Assertion library: built-in Vitest assertions (compatible with Jest)
**Backend (Rust):**
- Framework: built-in `#[test]` attribute and `cargo test`
- Command: `cd core && cargo test --workspace --bins`
**E2E (Browser):**
- Framework: Playwright 1.58.2
- Config: implicit (tests in `neode-ui/e2e/` directory)
**Shell Integration Tests:**
- Framework: Bats (Bash Automated Testing System)
- Location: `tests/lifecycle/bats/`
- Config files: `tests/lifecycle/lib/rpc.bash` (RPC wrapper helpers)
**Run Commands:**
```bash
# Unit tests (Vitest)
npm run test # Run all tests once
npm run test:watch # Watch mode, re-run on file changes
# Rust tests
cd core && cargo test --workspace --bins
# Specific Vitest suite
npm run test -- src/composables/__tests__/useFileType.test.ts
# Shell lifecycle tests (from repo root)
ARCHY_PASSWORD=password123 tests/lifecycle/run.sh # Read-only tests
ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 tests/lifecycle/run.sh # Include destructive
# Release gate (5× iterations, must run ON the target node)
ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ITERATIONS=5 \
tests/lifecycle/run-gate.sh
```
## Test File Organization
**Location:**
- Frontend: co-located with source in `__tests__/` subdirectories
- Example: `src/composables/useFileType.ts``src/composables/__tests__/useFileType.test.ts`
- Example: `src/api/rpc-client.ts``src/api/__tests__/rpc-client.test.ts`
- E2E: separate `e2e/` directory at root of frontend
- Shell: `tests/lifecycle/bats/` directory
**Naming:**
- Vitest: `*.test.ts` or `*.spec.ts` suffix (`.test.ts` preferred)
- Playwright: `*.spec.ts` suffix
- Bats: `*.bats` suffix
- Rust unit: same file with `#[test]` functions at the bottom or in submodules
**Structure:**
```
neode-ui/
├── src/
│ ├── composables/
│ │ ├── useFileType.ts
│ │ └── __tests__/
│ │ ├── useFileType.test.ts
│ │ ├── useNavSounds.test.ts
│ │ └── ... (other composable tests)
│ ├── api/
│ │ ├── rpc-client.ts
│ │ └── __tests__/
│ │ └── rpc-client.test.ts
│ └── stores/
│ ├── controller.ts
│ └── (no tests found for stores in exploration)
├── e2e/
│ ├── app-launch.spec.ts
│ ├── intro-experience.spec.ts
│ └── visual-regression.spec.ts
```
## Test Structure
**Vitest Suite Organization:**
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
describe('getFileCategory', () => {
it('returns folder for directories', () => {
expect(getFileCategory('', true)).toBe('folder')
expect(getFileCategory('jpg', true)).toBe('folder')
})
it('identifies image extensions', () => {
expect(getFileCategory('jpg', false)).toBe('image')
expect(getFileCategory('png', false)).toBe('image')
})
})
describe('useFileType', () => {
it('returns correct category and computed values for an image', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
expect(result.isImage.value).toBe(true)
})
it('reacts to ref changes', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
ext.value = 'mp3'
expect(result.category.value).toBe('audio')
})
})
```
**Patterns:**
- `describe()` blocks group related tests by function or component
- `it()` blocks test a single behavior (flat structure, no nesting of describe blocks observed)
- `beforeEach()` / `afterEach()` hooks for setup/teardown per test
- `beforeAll()` / `afterAll()` hooks for suite-level setup (e.g., login in bats tests)
- Assertions use `expect(actual).toBe(expected)` or `expect(actual).toEqual(object)`
**Playwright E2E Structure:**
```typescript
import { expect, test, type Page } from '@playwright/test'
async function login(page: Page) {
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
})
// ... fill form, submit
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
await login(page)
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
const appCard = page.locator('[data-controller-container]', {
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
}).first()
await appCard.waitFor({ timeout: 30_000 })
await expect(appCard.locator('button')).toBeVisible()
})
```
**Bats Shell Test Structure:**
```bash
#!/usr/bin/env bats
# tests/lifecycle/bats/bitcoin-knots.bats
load '../lib/rpc.bash'
setup_file() {
: "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}"
export ARCHY_FORCE_LOGIN=1
rpc_login
unset ARCHY_FORCE_LOGIN
}
teardown_file() {
rpc_logout_local
}
@test "container-list includes bitcoin-knots" {
run rpc_result container-list
[ "$status" -eq 0 ]
echo "$output" | jq -e '.[] | select(.name == "bitcoin-knots")' >/dev/null
}
@test "container-status returns a valid status object" {
run rpc_call container-status '{"app_id":"bitcoin-knots"}'
[ "$status" -eq 0 ]
}
```
## Mocking
**Framework (Vitest):** `vi` from Vitest; global stub support
**Patterns:**
```typescript
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing
const { rpcClient } = await import('../rpc-client')
// In tests:
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
mockFetch.mockRejectedValueOnce(new Error('fetch failed'))
// Assertions on mock calls:
expect(mockFetch).toHaveBeenCalledOnce()
const [url, init] = mockFetch.mock.calls[0]!
expect(url).toBe('/rpc/v1')
expect(init.method).toBe('POST')
```
**Vue Test Utils:**
- Component mounting: `mount(Component, { global: { mocks: { $ver: displayVersion } } })`
- Props tested by passing to mount options
- Events tested by listening to emitted events
**What to Mock:**
- External HTTP requests (fetch, axios)
- Timers (for timeout logic; `vi.useFakeTimers()`)
- Global objects (localStorage, console, window.location)
**What NOT to Mock:**
- Vue reactivity (ref, computed) — these are core to component behavior
- RPC client methods in component tests — prefer integration-style testing
- Built-in assertions (expect) — always available
- Pinia stores in unit tests of composables that use them — store directly if needed
## Fixtures and Factories
**Test Data:**
```typescript
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
// ... other Response properties
}
}
// Usage:
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
mockFetch.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
```
**Location:**
- Fixtures (test data, helper functions) defined inline in test files or in small helper modules
- No central fixture factory observed; each test file is self-contained
- Shell test helpers in `tests/lifecycle/lib/rpc.bash` (RPC wrapper for bats)
## Coverage
**Requirements:**
- Frontend: 80% branch coverage (set in `vitest.config.ts` thresholds)
- Rust: no explicit threshold; pragmatic testing of public APIs
- Shell: coverage tracked per app in `tests/lifecycle/TESTING.md` (lifecycle matrix)
**View Coverage:**
```bash
# Generate coverage report
npm run test -- --coverage
# Output formats: text, text-summary, html
# Config in vitest.config.ts: reporter: ['text', 'text-summary']
```
**Coverage Scope (Frontend):**
- Included: `src/api/*.ts`, `src/stores/*.ts`, `src/composables/*.ts`, `src/utils/*.ts`, `src/services/*.ts`, `src/router/*.ts`
- Excluded: test files (`src/**/__tests__/**`), type definitions (`*.d.ts`), entry point (`src/main.ts`)
## Test Types
**Unit Tests (Vitest):**
- Scope: individual functions, composables, utility modules
- Approach: fast, isolated, mock external dependencies
- Example: `useFileType.test.ts` tests `getFileCategory`, `useFileType`, `formatSize`, `formatDate` independently
- Latency: ~5s for full suite; individual tests <1s
**Integration Tests (Vitest + RPCClient):**
- Scope: RPC client with mocked fetch, authentication flows, retry logic
- Approach: more complex setup, test interactions between layers
- Example: `rpc-client.test.ts` tests 70+ scenarios (login, TOTP, federation, package operations)
- Latency: ~30s for full suite
**E2E Tests (Playwright):**
- Scope: real browser, real app instance, user journeys (login → navigate → interact)
- Approach: full app stack running; no mocks of UI layer
- Example: `app-launch.spec.ts` tests app card discovery and launch via button click
- Latency: 30120s per test depending on app startup time
**Lifecycle Tests (Bats):**
- Scope: container operations (install, start, stop, restart, uninstall) on a live node
- Approach: RPC calls to backend, shell commands for verification, destructive operations tier-gated
- Tiers:
- L0 unit: Rust unit tests (cargo test)
- L1 RPC: JSON-RPC API responses (bats + rpc.bash)
- L2 UI: HTTP probe of app URLs (bats + ui-probes.bash)
- L3 lifecycle survival: container restart/reboot survival (bats, gated)
- Latency: 30120s per suite depending on tier and container startup
## Common Patterns
**Async Testing (Vitest):**
```typescript
it('makes a successful RPC call and returns the result', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
const result = await rpcClient.call<{ did: string }>({
method: 'node.did',
params: {},
})
expect(result).toEqual({ did: 'did:key:z123' })
expect(mockFetch).toHaveBeenCalledOnce()
})
```
- `async` keyword on test function
- `await` for async operations
- No explicit promise handling; expect called after `await` completes
- Timeouts set via test config or `{ timeout: N }` in individual tests
**Error Testing (Vitest):**
```typescript
it('throws after max retries on persistent 502', async () => {
mockFetch.mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway')
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('throws immediately on non-retryable HTTP errors', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Session expired')
expect(mockFetch).toHaveBeenCalledOnce()
})
```
- `expect(...).rejects.toThrow(message)` for expected rejections
- Mock returns set per-call (`mockResolvedValueOnce`, `mockResolvedValue`)
- Retry logic verified via call count assertions (`toHaveBeenCalledTimes`)
**Timer Mocking (Vitest):**
```typescript
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('retries on 502 Bad Gateway and eventually succeeds', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('ok')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
```
- Fake timers enable testing of timeout/retry delays without blocking real time
- `shouldAdvanceTime: true` auto-advances clock for non-blocking tests
- Clean up with `vi.useRealTimers()` after each test
**Vue Component Testing (Vue Test Utils):**
```typescript
it('returns correct values for audio', () => {
const ext = ref('mp3')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-orange-400')
})
```
- Refs created with `ref()` passed as test inputs
- Computed values accessed via `.value`
- No mount overhead for pure composable logic
**Playwright Browser Testing:**
```typescript
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
await login(page)
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
const appCard = page.locator('[data-controller-container]', {
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
}).first()
await appCard.waitFor({ timeout: 30_000 })
await expect(appCard.locator('button')).toBeVisible()
})
```
- Locators used to find elements (CSS selector, role, text)
- Wait timeouts on slow networks (30s for app startup)
- `waitUntil: 'domcontentloaded'` or `'networkidle'` for page load
- Screenshots and video recording available via config
## Test Configuration Details
**Vitest Config (`vitest.config.ts`):**
- Environment: jsdom
- Globals: enabled (no imports needed)
- Setup file: `vitest.setup.ts` (mocks global Vue config like `$ver`)
- Coverage provider: v8
- Coverage threshold: 80% branches
- Excluded from coverage: tests, types, main.ts
**Playwright Config (implicit, environment variables used):**
- Base URL: derived from `VITE_*` env vars in dev
- Timeouts: per-test overrides via `{ timeout: N }`
- Retry: 0 (no automatic retries; explicit in tests via polling)
- Config environment variables: `ARCHY_PASSWORD`, `ARCHY_APP_ID`, `ARCHY_EXPECTED_LAUNCH_URL`
**Shell Test Config (environment variables):**
- `ARCHY_PASSWORD`: login password (required)
- `ARCHY_ALLOW_DESTRUCTIVE`: enable stop/start/restart/uninstall tests
- `ARCHY_ALLOW_CASCADE_DESTRUCTIVE`: enable uninstall/reinstall on throwaway app
- `ARCHY_ITERATIONS`: loop count for release gate (5× for production readiness)
- `ARCHY_FORCE_LOGIN`: fresh RPC token per test file
---
*Testing analysis: 2026-07-29*

View File

@ -0,0 +1,66 @@
# Synthesis Summary
Ingest mode: new (fresh bootstrap; no existing PROJECT.md/REQUIREMENTS.md/ROADMAP.md)
Synthesized: 2026-07-29
Precedence applied: ADR > SPEC > PRD > DOC (no per-doc overrides)
## Doc counts by type
- ADR: 10 (all locked, Status: Accepted, confidence: high)
- SPEC: 1 (confidence: high)
- PRD: 0
- DOC: 0
- UNKNOWN: 0
- Total: 11
## Decisions locked (10)
All in `intel/decisions.md`:
- ADR-001 Podman over Docker — docs/adr/001-podman-over-docker.md
- ADR-002 did:key (Ed25519) node identity — docs/adr/002-did-key-method.md
- ADR-003 Nostr relays for node + app discovery — docs/adr/003-nostr-for-discovery.md
- ADR-004 Tor hidden services for inter-node RPC/control plane — docs/adr/004-tor-for-peer-communication.md
- ADR-005 ChaCha20-Poly1305 + Argon2id backup encryption — docs/adr/005-chacha20-backup-encryption.md
- ADR-006 Nostr relays for marketplace discovery (trust tiers) — docs/adr/006-nostr-marketplace-discovery.md
- ADR-007 Bilateral DID federation trust via single-use invite codes — docs/adr/007-did-federation-trust.md
- ADR-008 Dual keys (Ed25519 + secp256k1) from one master seed — docs/adr/008-dual-key-strategy.md
- ADR-009 Manifest-level container security enforcement — docs/adr/009-manifest-container-security.md
- ADR-011 DWN deprioritization (Nostr + Tor federation instead) — docs/adr/011-dwn-deprioritization.md
## Requirements extracted (0)
No PRDs in ingest set. `intel/requirements.md` records the absence.
## Constraints (7 entries)
From docs/app-manifest-spec.md, in `intel/constraints.md`:
- schema: 4 (top-level `app:` block, ContainerConfig, SecurityPolicy + validation, Volumes)
- api-contract: 1 (lifecycle hooks)
- protocol: 2 (Quadlet installation/reconciler semantics, distribution channels: signed catalog + Nostr marketplace)
- nfr: 0
## Context topics (0)
No DOC-type documents. `intel/context.md` records the absence.
## Conflicts
- Blockers: 0
- Competing variants: 0
- Auto-resolved: 0
- Informational notes: 4 (ADR-003/006 consistent overlap; SPEC validation narrower than ADR-009 mandates — silence, not contradiction; ADR-010 numbering gap; acyclic cross-ref graph)
Detail: `.planning/INGEST-CONFLICTS.md`
## Cycle detection
Cross-ref graph acyclic (max depth well under cap). All 11 docs synthesized; no docs excluded.
## Files
- Decisions: `.planning/intel/decisions.md`
- Requirements: `.planning/intel/requirements.md`
- Constraints: `.planning/intel/constraints.md`
- Context: `.planning/intel/context.md`
- Conflict report: `.planning/INGEST-CONFLICTS.md`
- Raw classifications: `.planning/intel/classifications/*.json`

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/001-podman-over-docker.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-001: Podman Over Docker",
"summary": "Chose Podman over Docker as the container runtime for rootless, daemonless operation with native systemd integration.",
"scope": ["Podman", "Docker", "container runtime", "rootless containers", "systemd integration", "archy-net network"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/002-did-key-method.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-002: DID Key Method for Node Identity",
"summary": "Chose did:key (Ed25519) as the primary DID method for node identity; self-contained and offline-capable, with federation trust lists mitigating rotation/revocation gaps.",
"scope": ["did:key", "node identity", "DID methods", "Ed25519 keys", "peer authentication", "federation trust lists", "verifiable credentials"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,22 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/003-nostr-for-discovery.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-003: Nostr Relays for Node and App Discovery",
"summary": "Chose Nostr relays (NIP-78, kind 30078) for decentralized node discovery and marketplace app manifest distribution.",
"scope": [
"Nostr relays",
"node discovery",
"app discovery",
"marketplace app manifests",
"NIP-78",
"NIP-33 replaceable events",
"trust scoring",
"relay caching"
],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/004-tor-for-peer-communication.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-004: Tor Hidden Services for Peer Communication",
"summary": "Chose Tor hidden services (.onion) for all inter-node RPC/control-plane communication; bulk data pulled from registries instead.",
"scope": ["Tor hidden services", "inter-node communication", "federation sync", "archy-tor container", "RPC/control plane", "NAT traversal"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/005-chacha20-backup-encryption.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-005: ChaCha20-Poly1305 for Backup Encryption",
"summary": "Chose ChaCha20-Poly1305 AEAD with Argon2id key derivation for encrypting backups at rest, over AES-256-GCM and XChaCha20-Poly1305.",
"scope": ["backup encryption", "ChaCha20-Poly1305", "Argon2id key derivation", "AEAD", "nonce handling"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/006-nostr-marketplace-discovery.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-006: Nostr Relays for Marketplace Discovery",
"summary": "Chose Nostr relays (NIP-78, kind 30078 events) for decentralized app manifest discovery instead of a centralized marketplace server.",
"scope": ["Nostr relays", "app manifest discovery", "marketplace", "trust scoring", "trust tiers", "manifest signature verification"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/007-did-federation-trust.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-007: DID-Based Federation Trust",
"summary": "Chose bilateral DID-based verification with single-use invite codes over Tor for establishing federation trust between nodes, with Trusted/Observer/Untrusted levels.",
"scope": ["federation", "DID verification", "invite codes", "trust levels", "Tor hidden services", "Ed25519 keys"],
"cross_refs": ["ADR-003"],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/008-dual-key-strategy.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-008: Dual Key Strategy (Ed25519 + Secp256k1)",
"summary": "Maintain two key pairs per node identity: Ed25519 for DID/Web5 operations, secp256k1 for Nostr/Bitcoin/Lightning, both derived from one master seed.",
"scope": ["node identity", "Ed25519", "secp256k1", "DID documents", "verifiable credentials", "federation authentication", "backup encryption", "Nostr event publishing", "node discovery", "Lightning Network", "key derivation", "master seed"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/009-manifest-container-security.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-009: Manifest-Level Container Security Enforcement",
"summary": "Enforce mandatory container security defaults (readonly root, no-new-privileges, non-root UID, dropped capabilities, pinned tags) at the manifest level during container creation.",
"scope": ["container security", "app manifests", "manifest validation", "podman container creation", "security defaults", "capability restrictions", "seccomp", "core/container module"],
"cross_refs": ["docs/app-manifest-spec.md", "core/container/src/", "core/security/src/"],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,13 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/adr/011-dwn-deprioritization.md",
"type": "ADR",
"confidence": "high",
"manifest_override": false,
"title": "ADR-011: DWN Deprioritization",
"summary": "Deprioritizes Web5 DWN spec compliance after TBD's shutdown; keeps existing custom DWN store code and prioritizes Nostr plus Tor federation for peer sync.",
"scope": ["DWN (Decentralized Web Node)", "Web5", "dwn_store.rs", "Nostr", "federation", "peer discovery", "peer data sync"],
"cross_refs": [],
"locked": true,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,29 @@
{
"source_path": "/home/archipelago/Projects/archy/docs/app-manifest-spec.md",
"type": "SPEC",
"confidence": "high",
"manifest_override": false,
"title": "App Manifest Specification",
"summary": "Defines the declarative manifest.yml schema for apps: top-level fields, container config, security policy, volumes, hooks, installation semantics, and signed-catalog/marketplace distribution.",
"scope": [
"app manifest.yml schema",
"ContainerConfig (image/build, networks, derived_env, secret_env, generated_secrets, generated_certs)",
"SecurityPolicy (capabilities allow-list, network_policy, readonly_root)",
"volumes and bind-mount confinement",
"lifecycle hooks (post_install, pre_start)",
"health checks and interfaces",
"Quadlet installation and reconciler semantics",
"signed app catalog distribution",
"decentralized marketplace distribution"
],
"cross_refs": [
"app-developer-guide.md",
"manifest-hooks-design.md",
"marketplace-protocol.md",
"core/container/src/manifest.rs",
"api/rpc/package/stacks.rs"
],
"locked": false,
"precedence": null,
"notes": ""
}

View File

@ -0,0 +1,38 @@
# Constraints (from SPECs)
Extracted from 1 classified SPEC: `docs/app-manifest-spec.md` (accurate as of 2026-07-08). The SPEC self-declares that the canonical schema is the Rust parser in `core/container/src/manifest.rs` — if doc and code disagree, the code wins.
## App manifest top-level schema (`app:` block)
- source: docs/app-manifest-spec.md
- type: schema
- content: Every app is a directory `apps/<id>/` with a `manifest.yml` containing a single top-level `app:` block. Apps are purely declarative — the orchestrator owns the entire lifecycle; no per-app installer code. Required fields: `id` (lowercase alphanumeric + `-`/`_`, must match directory name), `name`, `version`. Optional fields: `description`, `container` (ContainerConfig), `dependencies` (storage/app_id+version/bare string), `resources` (cpu_limit, memory_limit, disk_limit), `security` (SecurityPolicy), `ports` (host/container/protocol), `volumes`, `files` (GeneratedFile: path/content/overwrite; path must sit under a declared bind mount), `environment` (static KEY=value), `health_check` (type/endpoint/path/interval/timeout/retries; `http` is what the monitor exercises), `devices` (must start with `/dev/`), `interfaces` (launch surfaces keyed by name), `hooks` (LifecycleHooks). Unknown keys are absorbed into an `extensions` map (serde flatten) as transitional metadata — not typed schema, not validated.
## ContainerConfig schema
- source: docs/app-manifest-spec.md
- type: schema
- content: Exactly one of `image` or `build` must be present (image XOR build). Fields: `image` (registry reference), `image_signature` (optional), `pull_policy` (default `if-not-present`), `build` ({context, dockerfile default "Dockerfile", tag, build_args}), `network` (literal podman `--network` value; omitted = rootless default isolated network), `network_aliases` (extra DNS names on the network), `entrypoint`, `custom_args`, `derived_env` ({key, template} rendered against host facts at apply time; allowed placeholders only: {{HOST_IP}}, {{HOST_MDNS}}, {{DISK_GB}} plus dependency-resolved facts — never hard-code host specifics), `secret_env` ({key, secret_file} read from /var/lib/archipelago/secrets/<secret_file>, injected as a podman secret so it never appears in `podman inspect` or unit files; secret_file must be a bare filename, no `/` or `..`), `generated_secrets` ({name, kind} materialised by the orchestrator on first use, 0600, rootless service user, idempotent + self-healing; kind ∈ hex16|hex32|base64|bcrypt; bcrypt writes <name>=hash and <name>.pw=plaintext), `generated_certs` ({crt, key, common_name?, sans?} self-signed TLS materialised before create), `data_uid` ("UID:GID" applied to the app's bind-mounted data dir before create).
## SecurityPolicy schema and validation rules
- source: docs/app-manifest-spec.md
- type: schema
- content: Security block defaults: `readonly_root: true`, `no_new_privileges: true`, `capabilities: []` (cap-drop ALL, add back only listed), `network_policy: isolated` (isolated | bridge | host), `apparmor_profile: null` (optional). Validation enforced at `AppManifest::validate()`: capabilities must come from the reviewed allow-list (CHOWN, DAC_OVERRIDE, FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN); `network_policy` must be exactly isolated/bridge/host; no `container:`/`ns:` network modes; devices must be `/dev/*`; bind-mount sources confined to `/var/lib/archipelago` (reviewed exceptions: rootless podman socket and dbus); `derived_env` templates limited to the placeholder allow-list; `secret_env`/`generated_secrets` names must be bare filenames; hook steps validated against the hook allow-list. (Note: the SPEC's documented validation list does not mention ADR-009's non-root UID, pinned-image-tag, or seccomp mandates — see INGEST-CONFLICTS.md INFO entry.)
## Volumes schema
- source: docs/app-manifest-spec.md
- type: schema
- content: Volume entries: `type` ∈ bind | volume | tmpfs; bind entries take `source` (confined to /var/lib/archipelago per validation), `target`, `options` from an allow-list (rw, ro, z, Z, shared, …); tmpfs entries take `target` and `tmpfs_options` (e.g. "rw,noexec,nosuid,size=256m").
## Lifecycle hooks contract
- source: docs/app-manifest-spec.md
- type: api-contract
- content: Hooks are declarative, allow-listed operations that run against the app's own container — never the host (design: manifest-hooks-design.md). `post_install` runs once after install with the container running; supported steps: `copy_from_host` (src relative to an allow-listed root — data dir / web-ui; no absolute paths, no '..') and `exec` (podman exec inside the container). `pre_start` is reserved in the schema; its executor is not yet wired.
## Installation semantics (Quadlet + reconciler)
- source: docs/app-manifest-spec.md
- type: protocol
- content: The orchestrator compiles the manifest into a rootless Podman Quadlet unit under `user.slice` — the container survives backend restarts and reboots. A level-triggered reconciler converges drift every 30 seconds. Multi-container apps are sets of per-member manifests installed together via the stack orchestrator (`api/rpc/package/stacks.rs`) on an app-local network.
## Manifest distribution channels
- source: docs/app-manifest-spec.md
- type: protocol
- content: Manifests ship two ways. (1) Signed catalog (primary): `releases/app-catalog.json` embeds the full manifest per app with an Ed25519 detached signature verified against the pinned release-root anchor; nodes overlay catalog manifests over disk files — catalog wins for image-only apps; `apps/<id>/manifest.yml` on disk remains the fallback and is still required for build-source apps. (2) Decentralized marketplace: Nostr NIP-78 discovery with DID-signed manifests (marketplace-protocol.md); the marketplace uses its own flatter manifest schema, not this one. Tooling: validate with `scripts/validate-app-manifest.sh`, regenerate catalog with `scripts/generate-app-catalog.py`, drift-checked in CI by `scripts/check-app-catalog-drift.py`.

View File

@ -0,0 +1,5 @@
# Context (from DOCs)
No DOC-type documents were present in the ingest set (10 ADRs + 1 SPEC). No context notes extracted.
This file intentionally records absence rather than repurposing ADR/SPEC content as context.

View File

@ -0,0 +1,65 @@
# Decisions (from ADRs)
Extracted from 10 classified ADRs. All are `locked: true` (Status: Accepted) and cannot be auto-overridden by any lower-precedence source.
## ADR-001: Podman Over Docker
- source: docs/adr/001-podman-over-docker.md
- status: locked (Accepted)
- decision: Use Podman as the container runtime instead of Docker. Rootless by default, daemonless, Docker-compatible, native systemd integration, OCI-compliant. Use `archy-net` custom network for inter-container DNS.
- scope: container runtime, rootless containers, systemd integration, archy-net network
## ADR-002: DID Key Method for Node Identity
- source: docs/adr/002-did-key-method.md
- status: locked (Accepted)
- decision: Use `did:key` (Ed25519) as the primary DID method for node identity. Self-contained, offline-capable, local resolution. Known gaps (no rotation, no service endpoints, no revocation) mitigated via federation trust lists and separately-stored service endpoints; future migration to did:peer/did:web possible if rotation is needed.
- scope: node identity, DID methods, Ed25519 keys, peer authentication, federation trust lists, verifiable credentials
## ADR-003: Nostr Relays for Node and App Discovery
- source: docs/adr/003-nostr-for-discovery.md
- status: locked (Accepted)
- decision: Use Nostr relays (NIP-78, kind 30078) for both node discovery and marketplace app manifests. Query multiple relays in parallel with dedupe; local cache with 15-minute TTL; trust scoring (DID verification, relay consensus, federation trust); hashtag filtering (`archipelago-marketplace`); NIP-33 replaceable events for updates; Tor-compatible via SOCKS proxy.
- scope: node discovery, app discovery, marketplace app manifests, NIP-78, NIP-33 replaceable events, trust scoring, relay caching
## ADR-004: Tor Hidden Services for Peer Communication
- source: docs/adr/004-tor-for-peer-communication.md
- status: locked (Accepted)
- decision: Use Tor hidden services (.onion addresses) for all inter-node communication. Scoped to RPC/control plane only — bulk data (container images) pulled from registries. Retry with backoff; `archy-tor` container runs automatically with host networking; federation sync interval (5 min) tolerates occasional failures.
- scope: inter-node communication, federation sync, archy-tor container, RPC/control plane, NAT traversal
## ADR-005: ChaCha20-Poly1305 for Backup Encryption
- source: docs/adr/005-chacha20-backup-encryption.md
- status: locked (Accepted)
- decision: Use ChaCha20-Poly1305 (AEAD) with Argon2id key derivation for backup encryption at rest, chosen over AES-256-GCM and XChaCha20-Poly1305. Random nonce per backup stored alongside ciphertext; Argon2id with 64MB memory cost and 3 iterations for password-to-key derivation.
- scope: backup encryption, AEAD, Argon2id key derivation, nonce handling
## ADR-006: Nostr Relays for Marketplace Discovery
- source: docs/adr/006-nostr-marketplace-discovery.md
- status: locked (Accepted)
- decision: Use Nostr relays (NIP-78, kind 30078 events) for decentralized app manifest discovery instead of a centralized marketplace server. Developers publish signed manifests to public relays; nodes query multiple relays; trust scoring via cross-relay verification count, DID-linked developer reputation, optional community endorsements. Trust tiers: Verified (known developer, 3+ relays, DID-verified), Community (2+ relays, valid manifest, unsigned/new developer), Unverified (single relay, new developer). Local relay-response caching; built-in curated list for essential apps; manifest signature verification before installation.
- scope: marketplace, app manifest discovery, trust scoring, trust tiers, manifest signature verification
## ADR-007: DID-Based Federation Trust
- source: docs/adr/007-did-federation-trust.md
- status: locked (Accepted)
- decision: Use bilateral DID-based verification with single-use invite codes for federation trust establishment. Invite code carries DID, .onion address, and shared secret; exchanged out-of-band; both nodes verify DIDs via signed challenges over Tor; ongoing communication is DID-authenticated over Tor hidden services. Trust levels: Trusted (full access), Observer (read-only), Untrusted (blocked). Discovery (ADR-003) finds nodes; federation trusts them.
- scope: federation, DID verification, invite codes, trust levels, Tor hidden services, Ed25519 keys
- cross-refs: ADR-003
## ADR-008: Dual Key Strategy (Ed25519 + Secp256k1)
- source: docs/adr/008-dual-key-strategy.md
- status: locked (Accepted)
- decision: Maintain two key pairs per node identity, both derived from one master seed: Ed25519 as canonical identity (DID documents, verifiable credentials, federation auth, backup encryption via X25519 DH) and secp256k1 for Nostr/Bitcoin/Lightning (event publishing, node discovery, Lightning channel auth). Secp256k1 key linked to the DID via Nostr profile (NIP-05). Backup captures the master seed; DID document includes both verification methods.
- scope: node identity, key derivation, master seed, Ed25519, secp256k1, DID documents, federation authentication, backup encryption, Nostr event publishing, node discovery, Lightning Network
## ADR-009: Manifest-Level Container Security Enforcement
- source: docs/adr/009-manifest-container-security.md
- status: locked (Accepted)
- decision: Enforce mandatory container security defaults at the manifest level, applied automatically during container creation. Non-negotiable defaults: `readonly_root: true`, `no_new_privileges: true`, non-root user (UID > 1000), drop ALL capabilities (add back only required), pinned image tags (no `latest`), default seccomp profile. `core/container/` validates manifests (parse → validate → reject violations → apply security context at `podman create`). Optional overrides (`readonly_root: false`, extra capabilities like NET_ADMIN) require explicit listing and documented justification, with audit trail.
- scope: container security, app manifests, manifest validation, podman container creation, security defaults, capability restrictions, seccomp
- cross-refs: docs/app-manifest-spec.md, core/container/src/, core/security/src/
## ADR-011: DWN Deprioritization
- source: docs/adr/011-dwn-deprioritization.md
- status: locked (Accepted)
- decision: Deprioritize Web5 DWN spec compliance following TBD's November 2024 shutdown. Keep existing custom DWN store code (`core/archipelago/src/network/dwn_store.rs`) for peer file catalogs and federation state; stop calling it "Web5 DWN" in user-facing text; do not invest in DWN spec compliance; prioritize Nostr + Tor federation for peer discovery and data exchange; re-evaluate only if DIF produces a viable Rust SDK or the spec regains maintainers.
- scope: DWN, Web5, dwn_store.rs, Nostr, federation, peer discovery, peer data sync

View File

@ -0,0 +1,5 @@
# Requirements (from PRDs)
No PRD documents were present in the ingest set (10 ADRs + 1 SPEC). No requirements extracted.
Downstream note: requirements for the roadmap must be derived elsewhere (e.g. from user input or a future PRD ingest); this file intentionally records absence rather than inferring requirements from ADR/SPEC content.

View File

@ -0,0 +1,19 @@
# Onboarding Summary
## Project State
- PROJECT.md: present
- REQUIREMENTS.md: present
- ROADMAP.md: present
- STATE.md: present
## Codebase Context
- Brownfield repo: yes
- Map readiness: complete
- Codebase map: .planning/codebase/ (complete codebase map)
- Fast map available: yes
## Docs Context
- Existing ADR/PRD/SPEC/RFC candidates: 11
## Recommended Next Step
- /gsd-manager

View File

@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
**aborts** if any is missing.
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
6. The first-launch companion modal and Android "Share this app" QR point at
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
repo artifact is built, mirror that exact APK to the VPS2-served path before
calling the release done.
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
skips the clean build and the signature enforcement and is exactly how a broken
@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
## Verify the published download after shipping
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
what you built and signed:
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
built and signed:
```bash
SERVED=neode-ui/public/packages/archipelago-companion.apk
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
curl -sS -o /tmp/live.apk "$URL"
shasum -a 256 "$SERVED" /tmp/live.apk # must match
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
curl -sS -o /tmp/live-qr.apk "$QR_URL"
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
```

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 20
versionName = "0.5.0"
versionCode = 45
versionName = "0.5.25"
vectorDrawables {
useSupportLibrary = true

Binary file not shown.

View File

@ -23,6 +23,18 @@
android:usesCleartextTraffic="true"
tools:targetApi="35">
<!-- Party-screen "Share this app": exposes the copied APK from
cache/share/ to the system share sheet, nothing else. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -21,6 +21,10 @@ data class ServerEntry(
val name: String = "",
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
val meshIp: String = "",
/** Node's FIPS npub the durable identity. When present it, not the
* address, is what identifies the entry: FIPS peers on npubs, IPs are
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
val npub: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
@ -45,9 +49,15 @@ data class ServerEntry(
fun toMeshUrl(): String? =
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
// name/meshIp are trailing fields so entries saved before they existed
// (4/5 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
// name/meshIp/npub are trailing fields so entries saved before they
// existed (4/5/6 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
/** Same node as [other]? npub identity wins; address/port/scheme is the
* fallback for LAN-only entries that never advertised FIPS. */
fun sameNode(other: ServerEntry): Boolean =
(npub.isNotBlank() && npub == other.npub) ||
(address == other.address && port == other.port && useHttps == other.useHttps)
companion object {
fun deserialize(raw: String): ServerEntry? {
@ -60,6 +70,7 @@ data class ServerEntry(
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
meshIp = parts.getOrElse(5) { "" },
npub = parts.getOrElse(6) { "" },
)
}
}
@ -73,6 +84,7 @@ class ServerPreferences(private val context: Context) {
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val activeNpubKey = stringPreferencesKey("active_npub")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
@ -86,6 +98,7 @@ class ServerPreferences(private val context: Context) {
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
npub = prefs[activeNpubKey] ?: "",
)
}
@ -111,6 +124,7 @@ class ServerPreferences(private val context: Context) {
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
prefs[activeMeshIpKey] = server.meshIp
prefs[activeNpubKey] = server.npub
}
addSavedServer(server)
}
@ -123,6 +137,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
prefs.remove(activeMeshIpKey)
prefs.remove(activeNpubKey)
}
}
@ -134,65 +149,66 @@ class ServerPreferences(private val context: Context) {
}
/**
* Replace a saved server in place. Matches the existing entry by connection
* identity (address/port/scheme) so edits that change the name or password
* or that touch a legacy 4-field entry still update the right record. If the
* edited server is also the active one, the active record is kept in sync.
* Replace a saved server in place. Matches the existing entry by node
* identity npub first, address/port/scheme as the LAN-only fallback
* (ServerEntry.sameNode) so edits that change the name, password or even
* every address still update the right record. An edit form that doesn't
* carry the npub keeps the stored one. If the edited server is also the
* active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == original.address &&
e.port == original.port &&
e.useHttps == original.useHttps
ServerEntry.deserialize(raw)?.sameNode(original) == true
}.toSet()
prefs[savedServersKey] = filtered + updated.serialize()
prefs[savedServersKey] = filtered + toStore.serialize()
val isActive = prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
val activeNpub = prefs[activeNpubKey] ?: ""
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
(
prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
)
if (isActive) {
prefs[activeAddressKey] = updated.address
prefs[activeHttpsKey] = updated.useHttps
prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name
prefs[activeMeshIpKey] = updated.meshIp
prefs[activeAddressKey] = toStore.address
prefs[activeHttpsKey] = toStore.useHttps
prefs[activePortKey] = toStore.port
prefs[activePasswordKey] = toStore.password
prefs[activeNameKey] = toStore.name
prefs[activeMeshIpKey] = toStore.meshIp
prefs[activeNpubKey] = toStore.npub
}
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
* Add a server, or update the entry for the same node npub first,
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode)
* used by QR pairing so re-scanning a node never duplicates it, even after
* the LAN renumbered and every address changed (npub-first contract in
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
* stored value (a real node's QR never carries the password). Returns the
* merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
.firstOrNull { it.sameNode(server) }
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
meshIp = server.meshIp.ifBlank { existing.meshIp },
npub = server.npub.ifBlank { existing.npub },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(merged) == true
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
@ -202,15 +218,11 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
// Match by node identity (npub, else address/port/scheme) rather
// than the exact serialized string, so a rename — or a legacy
// short-format entry — still removes the right record.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(server) == true
}.toSet()
}
}

View File

@ -78,6 +78,9 @@ object ServerQrParser {
password = credential,
name = uri.getQueryParameter("name") ?: "",
meshIp = fips?.ula ?: "",
// npub is the durable identity — saved-server upserts match on
// it, so re-scanning after a LAN renumber updates in place.
npub = fips?.npub ?: "",
),
fips = fips,
)
@ -85,8 +88,18 @@ object ServerQrParser {
private fun parseFips(uri: Uri): FipsPairInfo? {
val npub = uri.getQueryParameter("fnpub")?.trim()
val host = uri.getQueryParameter("fhost")?.trim()
var host = uri.getQueryParameter("fhost")?.trim()
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
// A .fips fhost is a dead dial hint: Android's system DNS can't
// resolve .fips, so every handshake send fails and first connect
// falls back to slow anchor discovery. If the QR's `url` host is a
// real address, dial that instead (QRs minted while the node UI was
// browsed over the mesh carry npub….fips here).
if (host.endsWith(".fips")) {
val urlHost = uri.getQueryParameter("url")
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
}
return FipsPairInfo(
npub = npub,
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),

View File

@ -5,15 +5,24 @@ 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
import com.archipelago.app.MainActivity
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
@ -27,6 +36,21 @@ import kotlinx.coroutines.launch
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) {
@ -45,13 +69,27 @@ class ArchyVpnService : VpnService() {
}
val prefs = FipsPreferences(this)
val identity = FipsManager.ensureIdentity(prefs)
val peersJson = prefs.peersJson()
val peersJson = prefs.combinedPeersJson()
if (identity == null || peersJson == "[]") {
Log.w(TAG, "mesh not configured — stopping")
shutdown()
return
}
// Party mode: fixed inbound UDP bind so a nearby phone can dial us
// directly over a shared LAN/hotspot (no internet required).
val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0
// A fresh app open re-triggers the service. Tearing a HEALTHY mesh
// down to rebuild it costs ~8s of anchor+session bring-up on every
// launch (observed live: stop 00:34:38 → session back 00:34:49) and
// is what made "freshly loading the app" slow. Keep a running node;
// restart only when it's dead or a pairing changed the peer set.
if (FipsNative.isRunning() && !FipsManager.peersDirty) {
Log.i(TAG, "mesh already running — keeping warm sessions")
startSessionWarmer()
return
}
FipsManager.peersDirty = false
// Re-establishing while running would strand the old fd; restart clean.
if (FipsNative.isRunning()) FipsNative.stop()
@ -61,6 +99,21 @@ class ArchyVpnService : VpnService() {
.setMtu(1280)
.addAddress(identity.address, 128)
.addRoute("fd00::", 8)
// The TUN is IPv6-only. Android blocks every address family
// the VPN has no address for — without this, bringing the
// mesh up cut ALL of the phone's IPv4 internet.
.allowFamily(android.system.OsConstants.AF_INET)
// And let apps that bind their own network skip the TUN
// entirely — this is mesh reachability, not a privacy VPN.
.allowBypass()
.apply {
// Android 10+ treats VPN networks as METERED by default,
// which flips the whole phone into data-saver behaviour
// (background sync off, "metered" warnings) while the
// mesh is up. It inherits the underlying network's real
// metered state instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false)
}
.establish()
} catch (e: Exception) {
Log.e(TAG, "VPN establish failed", e)
@ -72,18 +125,160 @@ class ArchyVpnService : VpnService() {
}
val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd)
Log.i(TAG, "mesh start: $result")
if (result.contains("\"error\"")) shutdown()
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
if (result.contains("\"error\"")) {
shutdown()
} else {
startSessionWarmer()
registerNetworkHandoff()
// Phone-to-phone chat/beam + the phone's own mesh-served page.
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
// Mutual pairing: when a phone that scanned OUR QR announces
// itself, store it as a party peer and re-run the mesh config so
// this side gets the peer + chat entry without scanning back.
FlareServer.onHello = { peer ->
scope.launch {
prefs.upsertPartyPeer(peer)
FipsManager.requestMeshRestart(this@ArchyVpnService)
}
}
}
}
/**
* Pre-warm + keep-warm mesh sessions to every known node ULA.
*
* Discovery + first session through the public tree can take 15s+
* (HANDOFF-2026-07-23 node diagnosis) paying that cost here, the
* moment the tunnel is up, means the connect probe and WebView hit an
* established session instead of timing out on a cold one. The periodic
* touch afterwards keeps the session from idling out. Failed connects
* are expected and cheap; the attempt itself is what drives discovery.
*/
private fun startSessionWarmer() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
val targets = try {
prefs.savedServers.first()
.mapNotNull { it.meshIp.ifBlank { null } }
.map { it to 80 } +
// Party phones answer on the flare port, not :80.
fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
} catch (_: Exception) {
emptyList()
}.distinct()
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
// Probe all targets CONCURRENTLY with a short timeout — the
// old sequential 20s-per-target loop let one cold node starve
// every other target for the whole aggressive window.
targets.map { (ula, port) ->
launch {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(
java.net.InetAddress.getByName(ula),
port,
),
5_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the attempt still drove
// session establishment; try again next round.
}
}
}.forEach { it.join() }
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
delay(if (round < 12) 5_000 else 60_000)
}
}
}
/**
* Track the phone's default network and hand the mesh over to it as the
* phone roams (Wi-Fi 5G, and later BLE). Two actions per change:
* 1. setUnderlyingNetworks(new) the tunnel's packets follow the live
* network instead of dying on the one it launched with.
* 2. re-home the mesh kick the session warmer so discovery + sessions
* rebuild on the new path immediately; the node's own fast-reconnect
* (1s) redials peers over the new route.
* onAvailable also fires for the FIRST network, which is how the initial
* underlying network gets set.
*/
private fun registerNetworkHandoff() {
if (networkCallback != null) return
val cm = getSystemService(ConnectivityManager::class.java) ?: return
connectivityManager = cm
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handoffTo(network)
}
override fun onLost(network: Network) {
// The lost network was our underlying one — clear the pin so
// the system falls back to whatever default remains; the next
// onAvailable re-pins explicitly.
if (network == currentUnderlying) {
currentUnderlying = null
runCatching { setUnderlyingNetworks(null) }
}
}
}
networkCallback = cb
// requestNetwork tracks the BEST network of the request; when the
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
// one. (registerDefaultNetworkCallback would also work; requestNetwork
// lets us extend to BLE-capable transports later.)
runCatching { cm.requestNetwork(request, cb) }
}
private fun handoffTo(network: Network) {
val changed = network != currentUnderlying
currentUnderlying = network
// Always re-assert; cheap and covers capability changes on the same
// Network object.
runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (changed && FipsNative.isRunning()) {
Log.i(TAG, "network handoff → re-homing mesh on new default network")
// Fresh warmer pass drives immediate rediscovery/session rebuild
// on the new path instead of waiting out dead-link timeouts.
startSessionWarmer()
}
}
private fun unregisterNetworkHandoff() {
val cm = connectivityManager
val cb = networkCallback
if (cm != null && cb != null) {
runCatching { cm.unregisterNetworkCallback(cb) }
}
networkCallback = null
connectivityManager = null
currentUnderlying = null
}
private fun shutdown() {
warmerJob?.cancel()
unregisterNetworkHandoff()
FlareServer.stop()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
unregisterNetworkHandoff()
FipsNative.stop()
scope.cancel()
super.onDestroy()

View File

@ -21,6 +21,12 @@ object FipsManager {
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow<Boolean> = _consentNeeded
/** True after a pairing changed the peer set while the node was running
* tells the service a restart is genuinely needed (the ONLY case; a
* routine app open must keep the warm mesh, not rebuild it). */
@Volatile
var peersDirty: Boolean = false
fun consentHandled() {
_consentNeeded.value = false
}
@ -34,7 +40,16 @@ object FipsManager {
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
_consentNeeded.value = true
peersDirty = true
// Restart the mesh with the new peer RIGHT NOW when consent already
// exists — relying on the consentNeeded collector left a running
// mesh on the OLD peer list whenever the collector wasn't active
// (fresh pairings looked dead until a full app restart).
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
@ -64,6 +79,22 @@ object FipsManager {
context.startForegroundService(intent)
}
/**
* Re-run the mesh with current prefs (party listen toggled, peer added).
* Marks the peer set dirty so startMesh genuinely restarts the node
* otherwise the keep-warm fast path would skip the new config.
* First-timers go through the consent flow.
*/
fun requestMeshRestart(context: Context) {
if (!FipsNative.available) return
peersDirty = true
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)

View File

@ -21,7 +21,13 @@ object FipsNative {
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
external fun start(secret: String, peersJson: String, tunFd: Int): String
/**
* [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
* that port so a nearby phone can dial us directly (party mode); the node
* stays leaf-only either way.
*/
external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String

View File

@ -3,15 +3,48 @@ package com.archipelago.app.fips
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
// both paths — direct LAN p2p to the node AND a public rendezvous for
// away-from-home — even when the scanned node is old enough that its QR
// carries no fanchors. Keep in lockstep with
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
internal const val ARCHY_ANCHOR_NPUB =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
/**
* Public FIPS network anchors (join.fips.network the dual-transport TCP
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
* fips_network_anchors()). Baked into every pairing at trailing priority so
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
* still joins the public tree and routes to the node through it.
*/
internal val PUBLIC_FIPS_ANCHORS = listOf(
Triple(
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
"23.182.128.74:443",
"tcp",
),
Triple(
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
"217.77.8.91:443",
"tcp",
),
)
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
@ -24,6 +57,12 @@ class FipsPreferences(private val context: Context) {
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
/** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
private val partyPeersKey = stringPreferencesKey("fips_party_peers")
/** Party mode: accept a direct inbound mesh link (UDP 2121). */
private val partyListenKey = booleanPreferencesKey("fips_party_listen")
/** Name shown in this phone's party QR and outgoing flares. */
private val partyNameKey = stringPreferencesKey("fips_party_name")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
@ -50,6 +89,157 @@ class FipsPreferences(private val context: Context) {
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
suspend fun partyListen(): Boolean =
context.fipsDataStore.data.first()[partyListenKey] ?: false
val partyListenFlow: Flow<Boolean>
get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
suspend fun setPartyListen(enabled: Boolean) {
context.fipsDataStore.edit { it[partyListenKey] = enabled }
}
suspend fun partyName(): String =
context.fipsDataStore.data.first()[partyNameKey]
?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
suspend fun setPartyName(name: String) {
context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
}
val partyPeersFlow: Flow<List<PartyPeer>>
get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
suspend fun partyPeers(): List<PartyPeer> =
parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
/** Matched by npub, so re-scanning updates the direct-dial address in place. */
suspend fun upsertPartyPeer(peer: PartyPeer) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
prefs[partyPeersKey] = toJson(kept + peer)
}
}
suspend fun removePartyPeer(npub: String) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
prefs[partyPeersKey] = toJson(kept)
}
}
/**
* Node peers + direct-dial party peers, in the fips PeerConfig JSON the
* Rust node deserializes. Party peers get the best priority: on a shared
* LAN/hotspot the direct link beats every anchor path, and while off-LAN
* the failed dial is cheap (auto-reconnect keeps retrying, which is
* exactly what makes the link snap up the moment both phones share WiFi).
* Party peers without an underlay address are mesh-routed and need no
* entry here at all.
*/
suspend fun combinedPeersJson(): String {
val merged = JSONArray(peersJson())
val party = partyPeers()
for (peer in party) {
if (peer.ip.isBlank() || peer.port <= 0) continue
merged.put(JSONObject().apply {
put("npub", peer.npub)
put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", "udp")
put("addr", "${peer.ip}:${peer.port}")
put("priority", 5)
}))
})
}
// A party-only phone (never paired with a node) still needs a public
// rendezvous to reach its peers ACROSS the internet — without it, two
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
// this anchor in; do the same when there are party peers.
if (party.isNotEmpty() &&
(0 until merged.length()).none {
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
}
) {
merged.put(JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "archipelago-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
})
}
// Backfill anchor peers for entries paired before newer QR/app
// releases added them. `upsertNodePeer` persists these on re-scan, but
// startup must also self-heal old DataStore state so updating the APK is
// enough to get off-LAN redundancy.
if (merged.length() > 0) {
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
val (npub, addr, transport) = anchor
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
}
}
return merged.toString()
}
private fun addAnchorIfMissing(
peers: JSONArray,
npub: String,
alias: String,
addr: String,
transport: String,
priority: Int,
) {
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
peers.put(JSONObject().apply {
put("npub", npub)
put("alias", alias)
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", transport)
put("addr", addr)
put("priority", priority)
}))
})
}
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val npub = o.optString("npub")
val ula = o.optString("ula")
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
PartyPeer(
npub = npub,
ula = ula,
name = o.optString("name").ifBlank { "Phone" },
ip = o.optString("ip"),
port = o.optInt("port"),
)
}
} catch (_: Exception) {
emptyList()
}
private fun toJson(peers: List<PartyPeer>): String {
val arr = JSONArray()
for (p in peers) {
arr.put(JSONObject().apply {
put("npub", p.npub)
put("ula", p.ula)
put("name", p.name)
put("ip", p.ip)
put("port", p.port)
})
}
return arr.toString()
}
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
@ -61,16 +251,19 @@ class FipsPreferences(private val context: Context) {
val incoming = mutableListOf<JSONObject>()
incoming += JSONObject().apply {
put("npub", info.npub)
put("alias", alias.ifBlank { "Archipelago" })
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
val addresses = JSONArray()
if (info.udpPort > 0) {
// .fips hosts are unresolvable on Android (no system .fips DNS):
// storing one gives the mesh a dial target that fails every
// handshake and stalls first connect on anchor discovery.
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "udp")
put("addr", "${info.host}:${info.udpPort}")
put("priority", 10)
})
}
if (info.tcpPort > 0) {
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "tcp")
put("addr", "${info.host}:${info.tcpPort}")
@ -81,9 +274,10 @@ class FipsPreferences(private val context: Context) {
}
for (anchor in info.anchors) {
if (anchor.npub == info.npub) continue
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
incoming += JSONObject().apply {
put("npub", anchor.npub)
put("alias", "Mesh anchor")
put("alias", "mesh-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", anchor.transport)
put("addr", anchor.addr)
@ -91,6 +285,36 @@ class FipsPreferences(private val context: Context) {
}))
}
}
// Guarantee the public anchor: without it, a QR from an older node
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
if (info.npub != ARCHY_ANCHOR_NPUB &&
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "archipelago-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
}
}
// And the public FIPS network anchors at trailing priority, so one
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
val (npub, addr, transport) = anchor
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
incoming += JSONObject().apply {
put("npub", npub)
put("alias", "fips-network-anchor-${i + 1}")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", transport)
put("addr", addr)
put("priority", 50 + i * 10)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
@ -104,3 +328,14 @@ class FipsPreferences(private val context: Context) {
}
}
}
/**
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
* that isn't a valid DNS label ("Framework PT" the space) gets rejected and
* silently drops the peer from name resolution. Slug it instead of losing it.
*/
internal fun hostSafeAlias(alias: String): String =
alias.lowercase()
.replace(Regex("[^a-z0-9.-]+"), "-")
.trim('-', '.')
.ifBlank { "archipelago" }

View File

@ -0,0 +1,398 @@
package com.archipelago.app.fips
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** One chat/photo message in a party conversation, keyed by the peer's npub. */
data class FlareMessage(
val id: String,
val peerNpub: String,
val fromMe: Boolean,
val name: String,
val text: String = "",
val photoPath: String = "",
val ts: Long,
val status: Status = Status.RECEIVED,
) {
enum class Status { SENDING, SENT, FAILED, RECEIVED }
}
/** In-memory conversation store (demo scope — nothing persists across restarts). */
object FlareStore {
private val _messages = MutableStateFlow<List<FlareMessage>>(emptyList())
val messages: StateFlow<List<FlareMessage>> = _messages
fun add(message: FlareMessage) {
_messages.value = _messages.value + message
}
fun setStatus(id: String, status: FlareMessage.Status) {
_messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
}
}
/**
* Minimal HTTP listener bound ONLY on this phone's mesh ULA plain HTTP is
* fine there because FIPS is the encryption + peer-identity layer (same
* stance as the node's ULA-only peer listener). This is what makes the phone
* a *server* on the mesh: another phone (or `curl -6` from any mesh node)
* reaches it by npub-derived address with no port forwarding, DNS, or CA.
*
* FIPS authenticates the node, not the request (project doctrine), so inputs
* are still validated at this boundary: size caps, JSON shape, no
* client-controlled paths.
*/
object FlareServer {
private const val TAG = "FlareServer"
private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
private const val MAX_TEXT_CHARS = 4_000
private const val MAX_HEADER_BYTES = 16 * 1024
private var socket: ServerSocket? = null
private var pool: ExecutorService? = null
@Volatile private var identityName = "Phone"
@Volatile private var identityNpub = ""
@Volatile private var photoDir: File? = null
/** Invoked when a peer announces itself (POST /hello) pairing used to
* be one-way: only the SCANNING phone learned the other side, so the
* scanned phone had no peer, no chat entry, no way in. The VPN service
* wires this to upsert the peer + restart the mesh config. */
@Volatile var onHello: ((PartyPeer) -> Unit)? = null
@Synchronized
fun start(context: Context, ula: String, myNpub: String, myName: String) {
stop()
identityNpub = myNpub
identityName = myName
photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
val pool = Executors.newCachedThreadPool().also { this.pool = it }
pool.execute {
try {
val server = ServerSocket().apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
}
socket = server
Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
while (!server.isClosed) {
val client = try {
server.accept()
} catch (_: Exception) {
break
}
pool.execute { handle(client) }
}
} catch (e: Exception) {
Log.e(TAG, "flare server died: $e")
}
}
}
@Synchronized
fun stop() {
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
pool?.shutdownNow()
pool = null
}
private fun handle(client: Socket) {
client.use { sock ->
sock.soTimeout = 30_000
try {
val input = BufferedInputStream(sock.getInputStream())
val requestLine = readLine(input) ?: return
val parts = requestLine.trim().split(" ")
if (parts.size < 2) return respond(sock, 400, json("bad_request"))
val (method, path) = parts[0] to parts[1]
var contentLength = 0
var from = ""
var fromName = ""
var headerBytes = requestLine.length
while (true) {
val line = readLine(input) ?: return
if (line.isEmpty()) break
headerBytes += line.length
if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
val idx = line.indexOf(':')
if (idx <= 0) continue
val key = line.substring(0, idx).trim().lowercase()
val value = line.substring(idx + 1).trim()
when (key) {
"content-length" -> contentLength = value.toIntOrNull() ?: 0
"x-from" -> from = value.take(80)
"x-name" -> fromName = value.take(80)
}
}
when {
method == "GET" && (path == "/" || path.startsWith("/?")) ->
respondHtml(sock, profilePage())
method == "POST" && path == "/hello" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveHello(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/flare" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveFlare(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/photo" -> {
if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
val body = readExactly(input, contentLength) ?: return
receivePhoto(from, fromName, body)
respond(sock, 200, """{"ok":true}""")
}
else -> respond(sock, 404, json("not_found"))
}
} catch (e: Exception) {
Log.w(TAG, "request failed: $e")
}
}
}
/** A peer that scanned OUR QR announces itself — mutual pairing. */
private fun receiveHello(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
val ula = o.optString("ula")
if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
if (from == identityNpub) return
val peer = PartyPeer(
npub = from.take(80),
ula = ula.take(64),
name = o.optString("name").take(24).ifBlank { "Phone" },
ip = o.optString("ip").take(40),
port = o.optInt("port", 0),
)
onHello?.invoke(peer)
// Seed the conversation so the chat has a visible entry on this side.
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = peer.npub,
fromMe = false,
name = peer.name,
text = "👋 ${peer.name} joined the party",
ts = System.currentTimeMillis(),
)
)
}
private fun receiveFlare(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
if (!from.startsWith("npub1")) return
val text = o.optString("text").take(MAX_TEXT_CHARS)
if (text.isBlank()) return
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = o.optString("name").take(80).ifBlank { "Phone" },
text = text,
ts = System.currentTimeMillis(),
)
)
}
private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
// Server-generated filename — the sender never controls the path.
val dir = photoDir ?: return
val file = File(dir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes)
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = fromName.ifBlank { "Phone" },
photoPath = file.absolutePath,
ts = System.currentTimeMillis(),
)
)
}
private fun profilePage(): String {
val npub = identityNpub
val name = identityName
return """
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>$name on the mesh</title>
<style>
body{background:#0a0a0a;color:#eee;font-family:monospace;
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
.card{border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:32px;
max-width:560px;background:rgba(255,255,255,.04)}
h1{color:#f7931a;margin:0 0 8px;font-size:22px}
.npub{word-break:break-all;color:#888;font-size:12px;margin:12px 0}
p{line-height:1.5}
</style></head><body><div class="card">
<h1> $name</h1>
<div class="npub">$npub</div>
<p>This page is being served <b>by a phone</b>, addressed by its
cryptographic identity over the FIPS mesh.</p>
<p>No port forwarding. No DNS. No certificate authority. No cloud.
The key <i>is</i> the address and the transport underneath can be
5G, WiFi, or a hotspot with no internet at all.</p>
</div></body></html>
""".trimIndent()
}
// ── tiny HTTP plumbing ──────────────────────────────────────────────────
/** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
private fun readLine(input: InputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\n'.code) return sb.toString().trimEnd('\r')
sb.append(b.toChar())
if (sb.length > MAX_HEADER_BYTES) return null
}
}
private fun readExactly(input: InputStream, length: Int): ByteArray? {
val buf = ByteArray(length)
var off = 0
while (off < length) {
val n = input.read(buf, off, length - off)
if (n == -1) return null
off += n
}
return buf
}
private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
private fun respond(sock: Socket, status: Int, body: String) =
writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
private fun respondHtml(sock: Socket, body: String) =
writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
val reason = when (status) {
200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
413 -> "Payload Too Large"; 431 -> "Headers Too Large"
else -> "Error"
}
val head = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: $contentType\r\n" +
"Content-Length: ${body.size}\r\n" +
"Connection: close\r\n\r\n"
sock.getOutputStream().apply {
write(head.toByteArray(Charsets.ISO_8859_1))
write(body)
flush()
}
}
}
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
object FlareClient {
// Connect timeout must outlive cold mesh-session establishment (~15s via
// the public tree per HANDOFF-2026-07-23); the attempt itself drives
// session setup, same trick as the VPN service's session warmer.
private val http = OkHttpClient.Builder()
.connectTimeout(25, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
/** Announce myself to a freshly scanned peer so pairing becomes MUTUAL
* their phone gets me as a peer + a chat entry without scanning back.
* Blocking call from Dispatchers.IO. */
fun sendHello(
peer: PartyPeer,
myNpub: String,
myName: String,
myUla: String,
myIp: String?,
myPort: Int,
): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("ula", myUla)
.put("ip", myIp ?: "")
.put("port", if (myIp != null) myPort else 0)
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/hello").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("text", text)
.put("ts", System.currentTimeMillis())
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/flare").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
http.newCall(
Request.Builder()
.url("${base(peer)}/photo")
.header("X-From", myNpub)
.header("X-Name", myName)
.post(jpeg.toRequestBody("image/jpeg".toMediaType()))
.build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
}

View File

@ -0,0 +1,102 @@
package com.archipelago.app.fips
import android.net.Uri
import java.net.Inet4Address
import java.net.NetworkInterface
/**
* Phonephone mesh pairing ("Mesh Party") QR contract:
*
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
*
* npub + ula alone are enough to chat *through* the mesh (anchors route by
* node address, no underlay info needed). ip/port are present only while the
* showing phone has its inbound UDP listener up (party mode) the scanner
* then also gets a direct-dial link that works on a shared LAN/hotspot with
* no internet at all. Same versioning stance as the node pairing QR
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
*/
data class PartyPeer(
val npub: String,
val ula: String,
val name: String,
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
val ip: String = "",
val port: Int = 0,
)
object PartyQr {
const val SCHEME_HOST = "party"
private const val SUPPORTED_MAJOR = 1
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
const val PARTY_UDP_PORT = 2121
/** Application-layer chat/beam port, bound only on the mesh ULA. */
const val FLARE_PORT = 5680
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
val b = Uri.Builder()
.scheme("archipelago")
.authority(SCHEME_HOST)
.appendQueryParameter("v", "1")
.appendQueryParameter("npub", npub)
.appendQueryParameter("ula", ula)
.appendQueryParameter("name", name)
if (!ip.isNullOrBlank() && port > 0) {
b.appendQueryParameter("ip", ip)
b.appendQueryParameter("port", port.toString())
}
return b.build().toString()
}
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
fun parse(raw: String): PartyPeer? {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return null
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
if (major != SUPPORTED_MAJOR) return null
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
return PartyPeer(
npub = npub,
ula = ula,
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
)
}
/**
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
* the hotspot-host phone advertises the address its guests can reach.
*/
fun localWifiIpv4(): String? {
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
try {
for (nif in NetworkInterface.getNetworkInterfaces()) {
if (!nif.isUp || nif.isLoopback) continue
for (addr in nif.inetAddresses) {
if (addr is Inet4Address && addr.isSiteLocalAddress) {
candidates += nif.name to addr.hostAddress.orEmpty()
}
}
}
} catch (_: Exception) {
return null
}
val hotspot = candidates.firstOrNull {
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
}
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
?.second
}
}

View File

@ -0,0 +1,77 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.screens.PixelArtLogo
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
/**
* The branded "F*CK IPs" full-screen loader shown whenever the app is
* dialing the node over the mesh (relaunch race, post-scan first connect),
* instead of an anonymous spinner. The point of the brand: what's loading
* is a connection to a cryptographic identity, not an IP.
*/
@Composable
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
Box(
Modifier
.fillMaxSize()
.background(SurfaceBlack),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// The brand's circle-container logo (as on the connect screen /
// web login): pixel-art "a" centered in a black disc.
Box(
Modifier
.size(120.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color.Black)
.border(
1.dp,
Color.White.copy(alpha = 0.14f),
androidx.compose.foundation.shape.CircleShape,
),
contentAlignment = Alignment.Center,
) {
PixelArtLogo(Modifier.size(64.dp))
}
Spacer(Modifier.height(20.dp))
Text(
text = "F*CK IPs MESH",
color = BitcoinOrange,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 4.sp,
)
Spacer(Modifier.height(8.dp))
Text(
text = message,
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
}

View File

@ -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) {

View File

@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@ -75,26 +94,29 @@ fun NESMenu(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
// Contained hub overlay: a centred glass panel (not full-screen) that
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
// inside its own bounds when content is tall. Tapping the dimmed
// backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
@ -104,17 +126,16 @@ fun NESMenu(
private fun MenuPanel(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
@ -122,14 +143,15 @@ private fun MenuPanel(
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
var https by remember { mutableStateOf(false) }
fun resetForm() {
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
nm = server.name; addr = server.address; pwd = server.password
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
showAdd = false
}
@ -137,156 +159,381 @@ private fun MenuPanel(
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
// Preserve fields the compact form doesn't expose (scheme, port).
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
// Preserve port (compact form doesn't expose it); scheme is now editable.
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
} else {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
}
resetForm()
}
var page by remember { mutableStateOf(HubPage.HUB) }
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp)
// Cap height just short of the full screen; the panel wraps short
// content and only scrolls in the rare case it outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg)
.background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Title
Text(
"Menu",
color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add / edit server
if (showAdd || editing != null) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Header: back (on sub-pages) or title, and a close on the hub.
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (page == HubPage.HUB) {
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
IconRound(Icons.Default.Close, "Close") { onDismiss() }
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
Spacer(Modifier.width(12.dp))
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
fontWeight = FontWeight.Medium,
)
Text(
"Cancel",
color = TextMuted,
fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
IconRound(Icons.Default.Close, "Close") { onDismiss() }
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
Spacer(Modifier.height(4.dp))
when (page) {
HubPage.HUB -> {
// Card page — one card per destination. Dashboard first: it's a
// peer of the others so three-finger → hub → Dashboard returns
// to the node UI, same shape as every other option.
if (onBackToWebView != null) {
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
}
if (onScanQr != null) {
// Add server by scanning the node's pairing QR
Box(
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES
}
if (FipsNative.available) {
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
}
if (onMeshParty != null) {
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
}
// Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here.
}
HubPage.NODES -> {
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
if (showAdd || editing != null) {
Column(
Modifier
.size(ROW_H)
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
)
Text(
"Cancel", color = TextMuted, fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
// HTTPS scheme toggle (available on both add and edit).
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.clickable { https = !https }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
Box(
Modifier
.width(46.dp).height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = 3.dp)
.size(20.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (https) Color.White else TextMuted),
)
}
}
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
HubPage.FIPS -> {
FipsSection(embedded = true)
}
}
}
}
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
private enum class HubPage { HUB, NODES, FIPS }
// Mode toggle
MenuItem(
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
private fun HubCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f)) {
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
}
}
}
/** Small circular icon button used in the hub header. */
@Composable
private fun IconRound(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
.clickable { onClick() },
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
}
}
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
private data class FipsInfo(
val available: Boolean,
val running: Boolean,
val npub: String,
val meshAddress: String,
val peerCount: Int,
)
/**
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing
* running state, its mesh identity (npub), its mesh address (fdULA), how
* many peers/anchors it's configured with and a one-tap Reconnect that
* re-homes the mesh (also the manual fix if a network handoff ever misses).
* Collapsed by default so the menu stays compact.
*/
@Composable
private fun FipsSection(embedded: Boolean = false) {
if (!FipsNative.available) return
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var expanded by remember { mutableStateOf(embedded) }
var info by remember { mutableStateOf<FipsInfo?>(null) }
// Load identity/state when the section opens (cheap DataStore + JSON read).
LaunchedEffect(expanded) {
if (expanded && info == null) {
val prefs = FipsPreferences(context)
val id = prefs.identity()
val peers = runCatching {
org.json.JSONArray(prefs.peersJson()).length()
}.getOrDefault(0)
info = FipsInfo(
available = true,
running = FipsNative.isRunning(),
npub = id?.npub.orEmpty(),
meshAddress = id?.address.orEmpty(),
peerCount = peers,
)
}
}
Column(Modifier.fillMaxWidth()) {
// Embedded in the hub's FIPS sub-page the header row would duplicate
// the page title, so only the standalone (collapsible) form shows it.
if (!embedded) MenuItem(
label = "FIPS Mesh",
labelColor = BitcoinOrange,
onClick = { expanded = !expanded },
)
if (expanded) {
val i = info
Column(
Modifier
.fillMaxWidth()
.padding(top = 6.dp)
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (i == null) {
Text("Loading…", color = TextMuted, fontSize = 13.sp)
} else {
FipsRow("Status", if (i.running) "Connected" else "Stopped",
valueColor = if (i.running) BitcoinOrange else TextMuted)
if (i.meshAddress.isNotBlank()) {
FipsRow("Mesh address", i.meshAddress, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
}
if (i.npub.isNotBlank()) {
FipsRow("Identity (npub)", i.npub, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
}
FipsRow("Peers & anchors", i.peerCount.toString())
Text(
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
color = TextMuted, fontSize = 11.sp,
)
MenuItem(
label = "Reconnect mesh",
labelColor = BitcoinOrange,
onClick = {
FipsManager.requestMeshRestart(context)
info = null
if (!embedded) expanded = false
},
)
}
}
}
}
}
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
@Composable
private fun FipsRow(
label: String,
value: String,
valueColor: Color = TextPrimary,
mono: Boolean = false,
onCopy: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
Text(
value,
color = valueColor,
fontSize = if (mono) 11.sp else 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
if (onCopy != null) {
Text("", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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()
}

View File

@ -12,15 +12,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.PartyScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
@ -31,6 +35,8 @@ object Routes {
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
const val MESH_PARTY = "mesh_party"
const val FLARE = "flare"
}
@Composable
@ -122,6 +128,9 @@ fun AppNavHost(
) {
composable(Routes.INTRO) {
IntroScreen(
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
onContinue = {
scope.launch {
prefs.markIntroSeen()
@ -170,15 +179,46 @@ fun AppNavHost(
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
onRemoteKeyboard = {
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
)
}
}
composable(Routes.REMOTE_INPUT) {
composable(
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
arguments = listOf(
navArgument("keyboard") {
type = NavType.BoolType
defaultValue = false
},
),
) { entry ->
RemoteInputScreen(
onBack = {
navController.popBackStack()
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
)
}
composable(Routes.MESH_PARTY) {
PartyScreen(
onBack = { navController.popBackStack() },
onOpenChat = { navController.navigate(Routes.FLARE) },
)
}
composable(Routes.FLARE) {
FlareScreen(
onBack = { navController.popBackStack() },
)
}
}

View File

@ -0,0 +1,359 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.FlareMessage
import com.archipelago.app.fips.FlareStore
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.UUID
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
/**
* Flare phonephone chat and photo beam over the FIPS mesh. Every byte is
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
* via a public anchor (5G) or a direct hotspot link is invisible up here
* which is the entire point.
*/
@Composable
fun FlareScreen(onBack: () -> Unit) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var myName by remember { mutableStateOf("Phone") }
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
var selectedNpub by remember { mutableStateOf<String?>(null) }
val allMessages by FlareStore.messages.collectAsState()
var draft by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
myName = prefs.partyName()
}
LaunchedEffect(peers) {
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
selectedNpub = peers.firstOrNull()?.npub
}
}
val peer = peers.firstOrNull { it.npub == selectedNpub }
val messages = allMessages.filter { it.peerNpub == selectedNpub }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
fun sendText() {
val target = peer ?: return
val me = identity ?: return
val text = draft.trim()
if (text.isEmpty()) return
draft = ""
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
text = text,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
scope.launch(Dispatchers.IO) {
val ok = FlareClient.sendText(target, me.npub, myName, text)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
fun sendPhoto(uri: Uri) {
val target = peer ?: return
val me = identity ?: return
scope.launch(Dispatchers.IO) {
val jpeg = compressPhoto(context, uri) ?: return@launch
// Local copy so our own bubble renders the sent photo.
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
photoPath = local.absolutePath,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
val photoPicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let { sendPhoto(it) } }
BackHandler { onBack() }
Column(
Modifier
.fillMaxSize()
.background(SurfaceDark)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
// Header
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
peer?.let {
Text(
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
color = BitcoinOrange,
fontSize = 11.sp,
)
}
}
Spacer(Modifier.size(48.dp))
}
// Peer tabs when chatting with more than one phone
if (peers.size > 1) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
peers.forEach { p ->
val active = p.npub == selectedNpub
Text(
p.name,
color = if (active) BitcoinOrange else TextMuted,
fontSize = 13.sp,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
.border(
1.dp,
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
RoundedCornerShape(10.dp),
)
.clickable { selectedNpub = p.npub }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
if (peer == null) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(messages, key = { it.id }) { msg ->
MessageBubble(msg)
}
}
// Composer
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BubbleTheirs)
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
.clickable { photoPicker.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { sendText() }),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { sendText() },
contentAlignment = Alignment.Center,
) {
Text("", color = BitcoinOrange, fontSize = 18.sp)
}
}
}
}
}
@Composable
private fun MessageBubble(msg: FlareMessage) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
) {
Column(
Modifier
.widthIn(max = 300.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
.border(
1.dp,
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
RoundedCornerShape(16.dp),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (msg.photoPath.isNotBlank()) {
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
bmp?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Beamed photo",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.FillWidth,
)
}
}
if (msg.text.isNotBlank()) {
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
}
Text(
when (msg.status) {
FlareMessage.Status.SENDING -> "sending…"
FlareMessage.Status.SENT -> "sent · E2E via mesh"
FlareMessage.Status.FAILED -> "failed — tap to retry later"
FlareMessage.Status.RECEIVED -> msg.name
},
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, bounds)
}
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, opts)
} ?: return@withContext null
val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
bitmap.recycle()
out.toByteArray()
} catch (_: Exception) {
null
}
}

View File

@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
fun IntroScreen(onContinue: () -> Unit) {
fun IntroScreen(
onContinue: () -> Unit,
// Mesh Party works with no node at all (phone↔phone) — offered right on
// the first screen so a friend who just got the app can join a party.
onMeshParty: () -> Unit = {},
) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
GlassButton(
text = stringResource(R.string.mesh_party),
onClick = onMeshParty,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}

View File

@ -0,0 +1,519 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.fips.PartyQr
import com.archipelago.app.ui.components.CameraQrPreview
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val CardBg = Color.White.copy(alpha = 0.05f)
private val CardBorder = Color.White.copy(alpha = 0.08f)
/** Public companion download (vps2 demo host serves the same APK as the
* nodes' QR link). Rendered as the "Share this app" QR. */
private const val APP_DOWNLOAD_URL =
"http://146.59.87.168:2100/packages/archipelago-companion.apk"
/**
* Mesh Party phonephone FIPS pairing. Show your QR, scan theirs, and the
* two embedded mesh nodes link up: through anchors when there's internet,
* directly over any shared WiFi/hotspot when there isn't.
*/
@Composable
fun PartyScreen(
onBack: () -> Unit,
onOpenChat: () -> Unit,
) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var name by remember { mutableStateOf("") }
var localIp by remember { mutableStateOf<String?>(null) }
var showScanner by remember { mutableStateOf(false) }
var showShareQr by remember { mutableStateOf(false) }
var scanHint by remember { mutableStateOf<String?>(null) }
// Camera permission — fresh installs (and reinstalls: uninstall wipes
// grants) land here with no CAMERA grant, and the raw preview just showed
// black. Ask the moment the scanner opens.
var hasCamera by remember {
mutableStateOf(
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
)
}
val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { hasCamera = it }
LaunchedEffect(showScanner) {
if (showScanner && !hasCamera) {
cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
}
}
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
name = prefs.partyName()
// The hotspot/WiFi address can change while this screen is open
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(3_000)
}
}
val qrPayload = identity?.let { id ->
PartyQr.build(
npub = id.npub,
ula = id.address,
name = name.ifBlank { "Phone" },
ip = if (listenOn) localIp else null,
port = PartyQr.PARTY_UDP_PORT,
)
}
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
BackHandler {
when {
showScanner -> showScanner = false
showShareQr -> showShareQr = false
else -> onBack()
}
}
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
Spacer(Modifier.size(56.dp))
}
// My QR card
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(20.dp))
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
qrBitmap?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(12.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "My mesh party QR",
modifier = Modifier.size(220.dp),
)
}
} ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
identity?.let {
Text(
it.npub.take(16) + "" + it.npub.takeLast(6),
color = TextMuted,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
OutlinedTextField(
value = name,
onValueChange = {
name = it.take(24)
scope.launch { prefs.setPartyName(name) }
},
placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
// Direct-link toggle (hotspot mode)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 12.dp)) {
Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
when {
listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
listenOn -> "Waiting for a WiFi/hotspot address…"
else -> "Off — mesh routes via anchors only"
},
color = if (listenOn) BitcoinOrange else TextMuted,
fontSize = 12.sp,
)
}
Switch(
checked = listenOn,
onCheckedChange = { on ->
scope.launch {
prefs.setPartyListen(on)
FipsManager.requestMeshRestart(context)
}
},
colors = SwitchDefaults.colors(
checkedTrackColor = BitcoinOrange,
checkedThumbColor = Color.White,
),
)
}
}
GlassButton(
text = "Scan a Phone",
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
if (peers.isNotEmpty()) {
Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
peers.forEach { peer ->
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(14.dp))
.clickable { onOpenChat() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 8.dp)) {
Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
peer.npub.take(14) + "" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
color = TextMuted,
fontSize = 11.sp,
)
}
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable {
scope.launch {
prefs.removePartyPeer(peer.npub)
FipsManager.requestMeshRestart(context)
}
}.padding(8.dp),
)
}
}
GlassButton(
text = "Open Flare Chat",
onClick = onOpenChat,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
} else {
Text(
"Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
}
Spacer(Modifier.height(12.dp))
// Hand the app itself to a friend: shares this install's own APK
// through the system sheet (Quick Share/Bluetooth), so a nearby
// phone gets the companion with zero internet — the whole party
// premise.
GlassButton(
text = "Share this app",
onClick = { showShareQr = true },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
Spacer(Modifier.height(12.dp))
}
// "Share this app" — a QR of the public download link, so the other
// phone scans it with its normal camera and installs over any
// internet. (The vps2 demo host serves the same APK the nodes do.)
AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f))
.clickable { showShareQr = false },
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
dlQr?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(14.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Companion download QR",
modifier = Modifier.size(240.dp),
)
}
}
Spacer(Modifier.height(18.dp))
Text(
"Scan with any camera to download\nthe Archipelago Companion",
color = TextPrimary,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
"…or send the APK file directly",
color = BitcoinOrange,
fontSize = 13.sp,
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
)
Spacer(Modifier.height(6.dp))
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
}
}
}
// Party QR scanner overlay
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
if (!hasCamera) {
Column(
Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Camera access is needed to scan a party QR.",
color = TextPrimary,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
GlassButton(
text = "Grant camera access",
onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else CameraQrPreview(onDecoded = { text ->
val peer = PartyQr.parse(text)
when {
peer == null -> scanHint = "Not a mesh party QR"
peer.npub == identity?.npub -> scanHint = "That's your own QR"
else -> {
showScanner = false
scanHint = null
scope.launch {
prefs.upsertPartyPeer(peer)
// Pick up the direct-dial PeerConfig (and the
// listener, if ours is on) immediately.
FipsManager.requestMeshRestart(context)
// Pairing must be MUTUAL: announce ourselves so
// the scanned phone gets us as a peer + a chat
// entry without scanning back. Retried while
// the fresh link/session comes up.
val me = identity
if (me != null) {
launch(Dispatchers.IO) {
for (attempt in 0 until 6) {
val ok = FlareClient.sendHello(
peer = peer,
myNpub = me.npub,
myName = name.ifBlank { "Phone" },
myUla = me.address,
myIp = if (listenOn) localIp else null,
myPort = PartyQr.PARTY_UDP_PORT,
)
if (ok) break
delay(3_000)
}
}
}
onOpenChat()
}
}
}
})
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
Text(
"Close",
color = TextPrimary,
fontSize = 16.sp,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.clickable { showScanner = false }
.padding(20.dp),
)
scanHint?.let {
Text(
it,
color = BitcoinOrange,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp)
.fillMaxWidth(),
)
}
}
}
}
// Scan hints fade so the camera feels live again.
LaunchedEffect(scanHint) {
if (scanHint != null) {
delay(2500)
scanHint = null
}
}
}
/** Render a QR payload as a bitmap (dark modules on white). */
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
val matrix = QRCodeWriter().encode(
payload,
BarcodeFormat.QR_CODE,
size,
size,
mapOf(EncodeHintType.MARGIN to 1),
)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
}
}
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
} catch (_: Exception) {
null
}
/** Share this install's own APK via the system share sheet a nearby friend
* gets the companion with no internet at all (Quick Share / Bluetooth). */
private fun shareCompanionApk(context: android.content.Context) {
try {
val src = java.io.File(context.applicationInfo.sourceDir)
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
val out = java.io.File(dir, "archipelago-companion.apk")
src.copyTo(out, overwrite = true)
val uri = androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", out,
)
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
android.content.Intent.createChooser(send, "Share Archipelago Companion")
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
} catch (_: Exception) {
// No share targets / copy failed — nothing sensible to do here.
}
}

View File

@ -4,8 +4,10 @@ import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -54,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(onBack: () -> Unit) {
fun RemoteInputScreen(
onBack: () -> Unit,
onMeshParty: (() -> Unit)? = null,
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
startInKeyboard: Boolean = false,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
@ -63,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) }
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit) {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
fun toggleStyle() {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
// Settings + style icons top-right in keyboard mode
Row(
Modifier.align(Alignment.TopEnd).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = { showModal = true },
)
com.archipelago.app.ui.components.StyleBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = ::toggleStyle,
)
}
}
}
}
@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
visible = showModal,
servers = savedServers,
activeServer = activeServer,
isGamepadMode = isGamepadMode,
controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
@ -245,11 +263,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
},
onRemote = { isGamepadMode = true; showModal = false },
onKeyboard = { isGamepadMode = false; showModal = false },
onBackToWebView = { showModal = false; onBack() },
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays

View File

@ -75,6 +75,7 @@ import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
@ -85,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
@ -171,10 +173,35 @@ fun ServerConnectScreen(
errorMessage = null
scope.launch {
val result = testConnection(server)
var reachable = testConnection(server)
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
// node. The scanned IP was only ever a dial hint; the node's real
// identity is its npub and its ULA is reachable from anywhere over
// the mesh. Bring the tunnel up and probe the ULA before failing.
if (!reachable && server.meshIp.isNotBlank()) {
FipsManager.autoStartIfReady(context)
val meshServer = server.copy(
address = server.meshIp,
useHttps = false,
port = "",
)
// Mesh discovery + first session can take 15s+ through the
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
// first-ever pairing the VPN consent dialog is on screen at
// the same time — so probe patiently inside a 60s budget with
// per-attempt timeouts wide enough to ride out TCP
// retransmit backoff. The VPN service pre-warms the session
// in parallel (ArchyVpnService.startSessionWarmer).
val deadline = System.currentTimeMillis() + 60_000
while (!reachable && System.currentTimeMillis() < deadline) {
reachable = testConnection(meshServer, timeoutMs = 15_000)
if (!reachable) delay(3000)
}
}
isConnecting = false
if (result) {
if (reachable) {
prefs.setActiveServer(server)
onConnected(server.toUrl())
} else {
@ -584,6 +611,14 @@ fun ServerConnectScreen(
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
// Full-screen branded loader while the first connect runs — most
// visibly right after a pairing-QR scan, when the mesh may still be
// establishing (LAN probe → tunnel up → ULA probe can take a while).
// The small inline spinner stays for context; this owns the screen.
if (isConnecting) {
MeshLoadingScreen()
}
}
}
@ -651,8 +686,10 @@ private fun sanitizeAddress(input: String): String {
.trimEnd('/')
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
private suspend fun testConnection(server: ServerEntry): Boolean {
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
* [timeoutMs] is per-phase (connect / read) mesh probes need far more
* patience than LAN ones (first session through the tree can take 15s+). */
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
return withContext(Dispatchers.IO) {
try {
val url = URL("${server.toUrl()}/rpc/v1")
@ -672,8 +709,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
}
connection.requestMethod = "POST"
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}"""

View File

@ -23,20 +23,28 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -64,19 +72,30 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@ -115,6 +134,158 @@ private fun isSameHost(url: String, base: String): Boolean {
}
}
/** Kiosk WebView retained across navigation (remote dashboard) so leaving
* the kiosk and coming back reattaches the LIVE page no reload, no
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
private object KioskWebView {
var instance: WebView? = null
var url: String? = null
// Live-composition delegates for the JS bridges (see the factory) — the
// registered interface objects call through these, so reattaching the
// retained view re-points them instead of leaving stale closures.
var onRouteOutbound: (String) -> Unit = {}
var onOpenInApp: (String) -> Unit = {}
var onQrOpen: () -> Unit = {}
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
var onQrClose: () -> Unit = {}
fun drop() {
instance?.let {
(it.parent as? ViewGroup)?.removeView(it)
it.destroy()
}
instance = null
url = null
}
}
/** Inject the safe-area CSS vars from the CURRENT window insets. Android
* WebView doesn't populate env(safe-area-inset-*); worse, on a cold start
* onPageFinished can run before the view is attached rootWindowInsets is
* null then, and injecting 0px collapsed the UI's top/bottom margins (and
* put the tab bar inside the gesture zone, killing its taps). Called from
* onPageFinished, from the window-insets listener (fires when real insets
* arrive), and on reattach. */
private fun injectSafeAreaVars(view: WebView) {
val insets = view.rootWindowInsets ?: return // listener re-fires when real
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
// Vue components sample the var into reactive state; tell them it
// changed (an authenticated session can mount before we run).
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
})();
""".trimIndent(),
null,
)
}
/** In-app browser pages (node apps + same-node links) don't consume the
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
* their content ran up under the status bar. Pad the document body down by
* the status-bar height: the padded strip shows the page's OWN background
* (padding is inside the element), so the bar keeps the page colour while
* content starts below it the pre-edge-to-edge look, without the black bar.
*
* Body padding only moves normal-flow content. fixed/sticky elements anchored
* at the viewport top (IndeeHub's floating header) stayed glued under the
* status bar, so we also push each of those down by the inset once, marked
* via data attribute and keep a throttled MutationObserver running so
* headers an SPA mounts after load get the same treatment.
* 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 SAT = $sat;
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;}';
function push(el) {
if (el.dataset.archyInset) return;
var cs = getComputedStyle(el);
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
if (isNaN(top) || top >= SAT) return;
el.style.setProperty('top', (top + SAT) + 'px', 'important');
el.dataset.archyInset = '1';
}
function sweep() {
if (!document.body) return;
// Fixed/sticky bars live shallow in the tree (portals mount on
// body); depth cap keeps the computed-style pass off big lists.
var els = document.body.querySelectorAll(
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
for (var i = 0; i < els.length; i++) push(els[i]);
}
sweep();
if (!window.__archyInsetObserver) {
var queued = false, last = 0;
window.__archyInsetObserver = new MutationObserver(function() {
if (queued) return;
queued = true;
var wait = Math.max(0, 250 - (Date.now() - last));
setTimeout(function() {
queued = false;
last = Date.now();
sweep();
}, wait);
});
window.__archyInsetObserver.observe(document.documentElement,
{ childList: true, subtree: true });
}
})();
""".trimIndent(),
null,
)
}
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
val u = android.net.Uri.parse(base)
val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
java.net.Socket().use {
it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
true
}
} catch (_: Exception) {
false
}
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
* (patient a cold session may still be establishing). If NEITHER answers,
* fall back to the mesh URL when we have one off-LAN the LAN IP is
* unreachable, and loading it just produced a confusing "can't reach
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
* instead means the load retries against the path that's actually coming up,
* and any error shows the mesh address rather than a dead LAN IP. */
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
withContext(Dispatchers.IO) {
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
meshUrl ?: lanUrl
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@ -153,6 +324,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.
@ -163,11 +338,39 @@ fun WebViewScreen(
meshFallbackUrl: String? = null,
) {
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
// loader; later navigations keep just the slim top progress bar.
var firstLoadDone by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { isLoading }.first { !it }
firstLoadDone = true
}
var loadProgress by remember { mutableIntStateOf(0) }
var triedMeshFallback by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
// Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an
// unreachable LAN IP burns a minute+ in connect retries before
// onReceivedError fires the mesh fallback — the "stuck connecting"
// stall. A raw TCP probe answers in milliseconds at home and fails in
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
var raceNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
// A retained live session exists — reattach instantly: no race, no
// reload, no re-login (remote ⇄ dashboard round trip).
if (KioskWebView.instance != null && KioskWebView.url == serverUrl) {
isLoading = false
startUrl = serverUrl
return@LaunchedEffect
}
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
// Starting on the mesh: don't bounce back to it on error (it IS it).
if (picked != serverUrl) triedMeshFallback = true
startUrl = picked
}
// Web-page camera access (wallet QR scanner). The WebView's default
// WebChromeClient silently denies getUserMedia, so grant video capture —
// asking for the app-level CAMERA permission first when needed.
@ -187,6 +390,14 @@ fun WebViewScreen(
// while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) }
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
// the ULA while app links may carry the LAN IP (and vice versa) —
// comparing against one host bounced same-node apps (Pine, Home
// Assistant, BTCPay) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) }
@ -195,6 +406,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
@ -268,9 +487,16 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.retry),
onClick = {
// Re-race LAN vs mesh — the network we're on may have
// changed since the last pick. Drop the retained view:
// an errored session must genuinely reload.
KioskWebView.drop()
webView = null
hasError = false
isLoading = true
webView?.loadUrl(serverUrl)
triedMeshFallback = false
startUrl = null
raceNonce++
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
@ -279,17 +505,33 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.disconnect),
onClick = onDisconnect,
onClick = {
KioskWebView.drop()
onDisconnect()
},
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else if (startUrl == null) {
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
// far cheaper than letting Chromium retry a dead LAN IP.
MeshLoadingScreen()
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
val initialUrl = startUrl ?: serverUrl
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
// Reattach the retained kiosk WebView (remote ⇄ dashboard
// must not reload the node UI). Everything configured
// below is idempotent, and re-running it rebinds clients,
// bridges and listeners to THIS composition's state —
// stale closures from the previous visit are replaced.
if (KioskWebView.url != serverUrl) KioskWebView.drop()
val reused = KioskWebView.instance
(reused ?: WebView(context)).apply {
(parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@ -313,33 +555,59 @@ fun WebViewScreen(
val webViewRef = this
// Re-inject the safe-area vars whenever REAL insets
// arrive — on cold start onPageFinished often beats
// window attachment and would otherwise bake in 0px.
setOnApplyWindowInsetsListener { v, insets ->
(v as? WebView)?.let { injectSafeAreaVars(it) }
v.onApplyWindowInsets(insets)
}
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
if (isSameNode(url)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
}
}
// Bridge callbacks are DELEGATED through the holder:
// the interface objects registered on a retained
// WebView survive recomposition, and re-registering
// them doesn't reliably swap the JS-visible object
// without a reload — with direct closures, a
// remote ⇄ dashboard round trip left the bridges
// writing to a dead composition's state ("apps don't
// launch"). These assignments re-point the live
// interface objects at THIS composition every attach.
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
KioskWebView.onQrOpen = {
walletScannerStatus = null
walletScannerVisible = true
}
KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err }
KioskWebView.onQrClose = { walletScannerVisible = false }
// JS bridge. The web UI calls:
// window.ArchipelagoNative.openExternal(url) — host-routed
// window.ArchipelagoNative.openInApp(url) — force in-app
// Falls back to window.open in a plain mobile browser.
addJavascriptInterface(
if (reused == null) addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { routeOutbound(url) }
webViewRef.post { KioskWebView.onRouteOutbound(url) }
}
@android.webkit.JavascriptInterface
fun openInApp(url: String) {
webViewRef.post { inAppUrl = url }
webViewRef.post { KioskWebView.onOpenInApp(url) }
}
},
"ArchipelagoNative",
@ -351,24 +619,21 @@ fun WebViewScreen(
// window.ArchipelagoQr.close() — code accepted, tear down
// Decodes flow back through window.__archyQrResult(text);
// a user cancel calls window.__archyQrCancelled().
addJavascriptInterface(
if (reused == null) addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun open() {
webViewRef.post {
walletScannerStatus = null
walletScannerVisible = true
}
webViewRef.post { KioskWebView.onQrOpen() }
}
@android.webkit.JavascriptInterface
fun setStatus(message: String, isError: Boolean) {
webViewRef.post { walletScannerStatus = message to isError }
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
}
@android.webkit.JavascriptInterface
fun close() {
webViewRef.post { walletScannerVisible = false }
webViewRef.post { KioskWebView.onQrClose() }
}
},
"ArchipelagoQr",
@ -384,34 +649,7 @@ fun WebViewScreen(
isLoading = false
if (view == null) return
// Convert physical pixels → CSS pixels
val density = view.resources.displayMetrics.density
val satPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.statusBars())
?.top ?: 0
val sabPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.navigationBars())
?.bottom ?: 0
val sat = (satPx / density).toInt()
val sab = (sabPx / density).toInt()
// Android WebView doesn't populate env(safe-area-inset-*).
// Set CSS custom properties the web UI can use as fallback:
// var(--safe-area-top, env(safe-area-inset-top, 0px))
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
})();
""".trimIndent(),
null,
)
injectSafeAreaVars(view)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages
@ -458,7 +696,7 @@ fun WebViewScreen(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@ -561,7 +799,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
@ -579,7 +818,7 @@ fun WebViewScreen(
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
onRemoteInput()
showHubMenu = true
}
}
}
@ -595,7 +834,21 @@ fun WebViewScreen(
}
webView = this
loadUrl(serverUrl)
if (reused == null) {
KioskWebView.instance = this
KioskWebView.url = serverUrl
loadUrl(initialUrl)
} else {
// Reattached views keep stale measurements until an
// input event — that was the top/bottom UI being
// wrong until a tap. Force a fresh pass, and re-sync
// the page's safe-area vars while we're at it.
post {
requestLayout()
invalidate()
injectSafeAreaVars(this)
}
}
}
},
)
@ -614,12 +867,46 @@ fun WebViewScreen(
)
}
// Branded first-load screen while the mesh session comes up.
AnimatedVisibility(
visible = isLoading && !firstLoadDone,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
Modifier.fillMaxSize().background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") }
withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") }
},
fontSize = 40.sp,
fontWeight = FontWeight.Black,
letterSpacing = 4.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
"connecting to your archipelago",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
)
Spacer(Modifier.height(28.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
InAppBrowser(
url = target,
serverUrl = serverUrl,
meshUrl = meshFallbackUrl,
onClose = { inAppUrl = null },
)
}
@ -656,6 +943,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)
}
},
)
}
}
@ -693,11 +1042,26 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
private fun InAppBrowser(
url: String,
serverUrl: String,
meshUrl: String? = null,
onClose: () -> Unit,
) {
val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
fun isSameNode(u: String): Boolean =
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
// 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) }
@ -735,7 +1099,22 @@ private fun InAppBrowser(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
// Whole-overlay touch shield: every touch not handled by a child
// (control-bar gaps, inset strips) dies here instead of falling
// through to the kiosk's tab bar behind (a near-miss on Close
// was opening the AIUI tab underneath).
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
// Bottom inset handled by the touch-shield strip below the bar.
// No TOP inset padding: the WebView draws edge-to-edge behind the
// status bar so the app's own background fills it — the padded
// version painted an opaque black bar there (user-rejected look).
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
@ -786,12 +1165,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) {
@ -809,7 +1190,7 @@ private fun InAppBrowser(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@ -823,7 +1204,7 @@ private fun InAppBrowser(
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
if (isSameNode(u)) return false
openExternalUrl(ctx, u)
return true
}
@ -924,6 +1305,21 @@ private fun InAppBrowser(
)
}
}
// Touch-shield over the gesture-nav strip: solid black AND consumes
// taps — stray touches below the control bar landed on the kiosk's
// tab bar behind this overlay (opening the AIUI chat by accident).
Box(
Modifier
.fillMaxWidth()
.windowInsetsBottomHeight(WindowInsets.navigationBars)
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
)
}
}

View File

@ -11,6 +11,7 @@
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
<string name="get_started">Get Started</string>
<string name="mesh_party">Mesh Party</string>
<string name="use_https">Use HTTPS</string>
<string name="port_label">Port (optional)</string>
<string name="saved_servers">Saved Servers</string>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
<paths>
<cache-path name="share" path="share/" />
</paths>

View File

@ -86,6 +86,7 @@ dependencies = [
"getrandom 0.2.17",
"hex",
"jni",
"libc",
"paranoid-android",
"serde_json",
"tokio",
@ -463,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fips"
version = "0.3.0-dev"
source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6"
source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f"
dependencies = [
"bech32",
"chacha20poly1305",

View File

@ -23,7 +23,10 @@ crate-type = ["lib", "cdylib"]
[dependencies]
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
# discovery re-fire on topology change (fresh 5G join: first route no longer
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
anyhow = "1.0"
serde_json = "1.0"
hex = "0.4"
@ -31,6 +34,8 @@ hex = "0.4"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
tracing = "0.1"
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
libc = "0.2"
# The JNI surface only exists on Android; host builds skip it and drive the
# mesh module directly (tests).

View File

@ -76,8 +76,9 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
out(&env, json)
}
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
mut env: JNIEnv,
@ -85,11 +86,13 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
secret: JString,
peers_json: JString,
tun_fd: jint,
listen_port: jint,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let peers = jstr(&mut env, &peers_json);
let json = match mesh::start(&secret, &peers, tun_fd) {
let listen_port = u16::try_from(listen_port).unwrap_or(0);
let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
Ok((npub, address)) => {
serde_json::json!({ "npub": npub, "address": address }).to_string()
}

View File

@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
}
/// Build the phone-side node config: leaf-only (never routes third-party
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
/// TUN enabled but attached to the VpnService fd rather than created.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
/// traffic — battery), no DNS responder, TUN enabled but attached to the
/// VpnService fd rather than created.
///
/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
/// local link (party mode); leaf_only still guarantees we never carry
/// third-party transit even while accepting an inbound link.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config {
let mut cfg = Config::default();
cfg.node.identity.nsec = Some(secret.to_string());
cfg.node.identity.persistent = false;
@ -74,13 +79,33 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
cfg.tun.enabled = true;
cfg.tun.mtu = Some(1280);
cfg.dns.enabled = false;
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
cfg.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some("0.0.0.0:0".to_string()),
bind_addr: Some(format!("0.0.0.0:{listen_port}")),
..Default::default()
});
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
cfg.transports.tcp = TransportInstances::Single(Default::default());
// Fast-connect profile — a phone opens the app and expects the node NOW.
// Stock pacing is tuned for always-on routers: a failed discovery backs
// off 30s, session resends gap out to 8-16s, dead links redial at
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
// link/session is down, so steady-state traffic is unchanged.
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
cfg.node.retry.max_retries = 30;
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
cfg.node.rate_limit.handshake_max_resends = 10;
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
cfg.node.discovery.backoff_max_secs = 30;
cfg.node.discovery.retry_interval_secs = 2;
cfg.node.discovery.max_attempts = 3;
// Lookups launched before the tree position settles are doomed; a 10s
// completion timeout made each one cost 10s before the 1s retry could
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
// the resend-within-window above still gives each attempt two shots.
cfg.node.discovery.timeout_secs = 5;
cfg.peers = peers;
cfg
}
@ -94,11 +119,24 @@ pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
/// detached — the node owns it from here). Returns (npub, ula) on success.
/// Any previously running node is stopped first.
pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
stop();
// Android hands the VpnService TUN fd over in non-blocking mode on some
// OS builds. The fips TUN reader is a dedicated blocking-read thread that
// treats EAGAIN as fatal — the loop died at startup ("TUN read error …
// Try again (os error 11)" on-device), so sessions came up but no packet
// ever entered the mesh. Force the fd into the blocking mode the reader
// is designed for.
unsafe {
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
let peers = parse_peers(peers_json)?;
let config = build_config(secret, peers);
let config = build_config(secret, peers, listen_port);
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
let npub = node.npub();
let address = node.identity().address().to_ipv6().to_string();
@ -234,7 +272,7 @@ mod tests {
#[test]
fn config_is_leaf_only_with_tun() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![]);
let cfg = build_config(&id.secret_hex, vec![], 0);
assert!(cfg.node.leaf_only);
assert!(cfg.tun.enabled);
assert_eq!(cfg.tun.mtu(), 1280);
@ -242,4 +280,16 @@ mod tests {
assert!(!cfg.transports.udp.is_empty());
assert!(!cfg.transports.tcp.is_empty());
}
#[test]
fn listen_port_sets_fixed_udp_bind() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![], 2121);
// Party mode keeps leaf_only — accepting a link is not routing transit.
assert!(cfg.node.leaf_only);
let TransportInstances::Single(udp) = &cfg.transports.udp else {
panic!("expected single UDP transport");
};
assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
}
}

View File

@ -1,7 +1,74 @@
# Changelog
## v1.7.112-alpha (2026-07-22)
## v1.7.118-alpha (2026-07-29)
- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.
- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.
- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.
- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)
## v1.7.117-alpha (2026-07-29)
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.
- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.
- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.
- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.
- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.
- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.
- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.
- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.
- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
## v1.7.116-alpha (2026-07-27)
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
## v1.7.115-alpha (2026-07-26)
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
## v1.7.114-alpha (2026-07-26)
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
## v1.7.113-alpha (2026-07-25)
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
## v1.7.112-alpha (2026-07-23)
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.

19
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,19 @@
# Code of Conduct
## Our standard
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
harassment, personal attacks, and discriminatory language are not.
## Scope
This code of conduct applies to project repositories, issue trackers, pull
requests, documentation, chat, and community spaces connected to Archipelago.
## Enforcement
Maintainers may edit, hide, or remove comments and may restrict participation
for behavior that makes collaboration unsafe or unproductive.
Report conduct concerns privately through the repository owner account or the
private contact channel listed on the project homepage.

View File

@ -1,161 +1,100 @@
# Contributing to Archipelago
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
This project is preparing for public developer contribution. The highest-value
contributions are focused fixes, tests, app manifests, documentation
improvements, and clear bug reports with reproducible evidence.
## Code of Conduct
## Development setup
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
## Getting Started
1. Fork the repository on the project's Gitea instance
2. Clone your fork: `git clone <your-fork-url>/archy.git`
3. Set up the dev environment (see `docs/developer-guide.md`)
4. Create a feature branch: `git checkout -b feature/your-feature`
## Development Setup
### Frontend (Vue.js)
### Frontend
```bash
cd neode-ui
npm install
npm start # Dev server on :8100
npm run type-check # TypeScript validation
npm run build # Production build
npm test # Run tests
npm start
npm run type-check
npm test
```
### Backend (Rust)
Build on a Linux server (Debian 13), **not** macOS:
### Backend
```bash
cargo clippy --all-targets --all-features
cargo fmt --all
cd core
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
```
### Deploy to dev server
Linux is required for host integration work involving Podman, systemd,
networking, or image builds. Frontend development works locally with the mock
backend.
## App manifests
App packages live under `apps/<app-id>/manifest.yml` and use the schema
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
before submitting:
```bash
./scripts/deploy-to-target.sh --live
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
## Code Style
App submissions must:
### Frontend (TypeScript + Vue)
- pin container image versions;
- avoid hardcoded secrets;
- use `security.no_new_privileges: true`;
- use `security.readonly_root: true` unless the manifest explains why writable
root is required;
- request only necessary Linux capabilities;
- store durable data under `/var/lib/archipelago/<app-id>/`;
- define truthful health checks and launch interfaces for user-facing UIs.
- `<script setup lang="ts">` — always Composition API
- TypeScript strict mode — no `any`, use `unknown` or proper types
- Global CSS classes in `src/style.css` — never inline Tailwind in components
- Pinia for state management — focused single-purpose stores
- Use `@/api/rpc-client.ts` for RPC calls
## Code style
### Backend (Rust)
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
- Rust: use `tracing` for structured logs.
- TypeScript: avoid `any`; use explicit types or `unknown`.
- Vue: prefer `<script setup lang="ts">`.
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
- Remove dead code rather than commenting it out.
- Add tests for new behavior and regression tests for bug fixes.
- No `unwrap()` or `expect()` in production code — use `?` operator
- `thiserror` for library errors, `anyhow` for application errors
- `tracing` for structured logging — never `println!`
- Run `cargo clippy` and `cargo fmt` before commits
## Pull requests
### General
1. Open one focused PR per behavior or documentation change.
2. Explain what changed, why it changed, and how it was verified.
3. Include screenshots for UI changes.
4. Link relevant issues or docs.
5. Keep generated catalog changes in sync with manifest changes.
- Functions under 50 lines, single responsibility
- Comment WHY not WHAT
- Remove dead code — never comment it out
- No `TODO`/`FIXME` in commits
Suggested commit format:
## Commit Format
```
type: description
```text
feat: add backup scheduling
fix: reject unsafe manifest volume
docs: clarify app deployment flow
test: cover catalog drift check
```
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
## Reporting bugs
Examples:
- `feat: add backup scheduling to settings page`
- `fix: handle WiFi connection timeout gracefully`
- `test: add unit tests for RPC client retry logic`
Include:
## Pull Request Process
- exact version or commit;
- host platform and architecture;
- steps to reproduce;
- expected and actual behavior;
- logs from the relevant component;
- screenshots for UI issues.
1. Ensure your branch is up to date with `main`
2. All checks must pass: TypeScript, build, tests, clippy
3. Include a clear description of what changed and why
4. Link any related issues
5. Request review from a maintainer
## Security
### PR Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
- [ ] No new compiler warnings
- [ ] Follows code style guidelines above
## Testing Requirements
- New features need tests
- Bug fixes need a regression test
- Frontend: Vitest + Vue Test Utils
- Backend: `#[test]` and `#[tokio::test]`
- Target: maintain or improve existing coverage
## Reporting Bugs
Use the **Bug Report** issue template. Include:
1. Steps to reproduce
2. Expected behavior
3. Actual behavior
4. System info (hardware, OS version, Archipelago version)
5. Screenshots if applicable
6. Relevant logs (`journalctl -u archipelago`)
## Feature Requests
Use the **Feature Request** issue template. Include:
1. Problem description
2. Proposed solution
3. Alternatives considered
4. Impact on existing users
## App Submissions
To submit an app for the Archipelago marketplace:
1. Create a manifest following `docs/app-manifest-spec.md`
2. Ensure the container image is published to a public registry
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
4. Open a PR adding the app to the curated list
5. Include: app description, icon, resource requirements, dependencies
### App Requirements
- Container must run as non-root (UID > 1000)
- `readonly_root: true` unless explicitly justified
- Drop all capabilities except those required
- `no-new-privileges: true`
- Pin specific image versions (no `latest` tag)
- No hardcoded secrets
## Security Disclosure
**Do NOT open public issues for security vulnerabilities.**
Email security concerns to the maintainers directly. Include:
1. Description of the vulnerability
2. Steps to reproduce
3. Potential impact
4. Suggested fix (if any)
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.
By contributing, you agree that your contribution is licensed under the
project's MIT License.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dorian and the Archipelago Project contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

73
NOTICE Normal file
View File

@ -0,0 +1,73 @@
# Archipelago — Third-Party Notices
Archipelago is licensed under the MIT License (see LICENSE).
This file lists third-party components included in this repository and its
release artifacts, with their licenses and required attributions.
## Embedded / vendored components
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
Copyright (c) 2026 Johnathan Corgan. MIT License.
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
compiled into the Android companion app (`Android/rust/archy-fips-core`).
- **QR Code Generator for JavaScript** — http://www.d-project.com/
Copyright (c) 2009 Kazuhiko Arase. MIT License.
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
(original headers preserved).
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
Binary extracted into the OS image at `/opt/archipelago/bin/`.
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
license with field-of-use restrictions: no use in systems designed to harm
human beings, and no use in AI/ML training datasets). The optional
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
Reticulum License is NOT an OSI-approved open-source license; it applies
only to that optional component, not to Archipelago itself.
## Fonts
- **Montserrat** — SIL Open Font License 1.1
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
- **Open Sans** — Apache License 2.0
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
## Artwork and icons
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
device illustrations from the Meshtastic project — https://meshtastic.org
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
(MIT, https://github.com/halfmage/pixelarticons).
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
and `service-icons/` are trademarks of their respective owners, used solely
to identify the corresponding applications. No endorsement is implied.
## Original media
All demo content (music, photos, posters in `demo/`), UI sound effects,
background images, and intro video in `neode-ui/public/assets/` are original
works created and owned by the Archipelago project author, released with the
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
Full per-crate and per-package license inventories for release binaries are
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).

229
README.md
View File

@ -1,8 +1,11 @@
# Archipelago
> Self-Sovereign Bitcoin Node OS
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
self-hosted apps, mesh communication, decentralized identity, and federation.
Apps are packaged as declarative `manifest.yml` files and run as rootless
Podman containers managed by the Rust backend.
[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@ -10,193 +13,101 @@
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
## Philosophy
## What is here
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
helpers, and performance/resource management.
- `neode-ui/` - Vue 3 + TypeScript frontend.
- `apps/` - app manifests and custom app container sources.
- `docker/` - supporting container build contexts for UI companion surfaces.
- `image-recipe/` - bootable image/ISO build inputs.
- `Android/` - Android companion app.
- `scripts/` - development, release, deployment, and validation tooling.
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
## Platform model
## Features
Archipelago is built as a developer-ready app platform, not a fixed appliance:
### Bitcoin Infrastructure
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
- **LND** and **Core Lightning** with channel management
- **ElectrumX** Electrum server for wallet connectivity
- **BTCPay Server** for accepting Bitcoin payments
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
- Apps are declared in `apps/<app-id>/manifest.yml`.
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
- App data lives under `/var/lib/archipelago/<app-id>/`.
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
injected through Podman secrets rather than static environment values.
- Release and app catalogs are signed and verified against a pinned trust
anchor.
### Self-Hosted Apps (50+)
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
Start with:
### Mesh Networking (tri-protocol)
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
- End-to-end encryption with X3DH key agreement + double-ratchet
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
- [Architecture](docs/architecture.md)
- [Developer Guide](docs/developer-guide.md)
- [App Developer Guide](docs/app-developer-guide.md)
- [App Manifest Spec](docs/app-manifest-spec.md)
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
- [Operations Runbook](docs/operations-runbook.md)
- [Troubleshooting](docs/troubleshooting.md)
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
- Multi-identity management (Personal/Business/Anonymous)
- W3C Verifiable Credentials issuance and verification
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
## Quick start
### Multi-Node Federation
- Invite-based node joining over Tor hidden services
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
- State sync and app deployment across federated nodes
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
### System Updates
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
### Security
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
## Roadmap
**Done**
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
**In progress**
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
- Quadlet default flip fleet-wide + container-flapping elimination
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
**Planned**
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
## Quick Start
### Install from ISO
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
2. Flash to USB drive with Balena Etcher or `dd`
3. Boot from USB on target hardware and follow the automated installer
4. Access the web UI at `http://<device-ip>`
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
### Supported Hardware
| Platform | Examples | Minimum |
|----------|----------|---------|
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
## Development
### Prerequisites
- macOS or Linux for frontend development
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
- Node.js 20+, Rust stable toolchain
### Frontend Development
### Frontend
```bash
cd neode-ui
npm install
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
npm run type-check # TypeScript validation
npm run build # Production build → web/dist/neode-ui/
npm start
```
### Backend Development
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
### Backend
```bash
cd core # Rust workspace root (no Cargo.toml at repo root)
cd core
cargo build
cargo test
cargo test --all-features
```
### Deploy to a Test Node
Linux is the supported backend runtime and release-build target. macOS is fine
for frontend work and many Rust compile/test loops, but host integration tests
that touch Podman, systemd, networking, or image build paths require Linux.
### App manifests
```bash
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
### Release (tarball-only)
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
Releases ship as a backend binary and a frontend tarball referenced by
`releases/manifest.json`, published to the self-hosted Gitea release server.
```bash
./scripts/create-release.sh 1.2.3
git push origin main --tags
```
## Architecture
```
Debian 13 (Trixie)
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
├── Nginx (reverse proxy, security headers, rate limiting)
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
│ │ federation, wallet, updates, marketplace
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
│ │ health monitor, signed app catalog
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
│ └── core/performance/ — resource limits
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
└── System Tor (hidden services, SOCKS5 proxy)
```
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
## Documentation
## Documentation map
| Doc | Purpose |
|-----|---------|
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
| [Apps README](apps/README.md) | Packaged app catalog overview |
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
## Contributing
1. Fork the repository
2. Create a feature branch (`feature/description`)
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
4. Submit a pull request
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
issue.
## License
[MIT License](LICENSE)
## Acknowledgments
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/)
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
are listed in [NOTICE](NOTICE) and generated license inventories in component
release artifacts.

38
SECURITY.md Normal file
View File

@ -0,0 +1,38 @@
# Security Policy
## Reporting vulnerabilities
Please do not open a public issue for a security vulnerability.
Until a dedicated security intake address is published, report privately to the
project maintainer through the repository owner account or the private contact
channel listed on the project homepage.
Include:
- affected commit, version, or release;
- affected component;
- reproduction steps;
- expected impact;
- logs, proof of concept, or packet captures when relevant;
- whether the issue is already public.
We aim to acknowledge credible reports within 48 hours and coordinate fixes
before public disclosure.
## Scope
Security-sensitive areas include:
- authentication, session handling, CSRF, and rate limiting;
- release and app-catalog signature verification;
- container manifest validation and runtime compilation;
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
- backup encryption and key derivation;
- federation, Tor, Nostr, mesh, DID, and credential flows;
- Android companion pairing and device-token handling.
## Supported versions
Archipelago is currently pre-1.0 alpha software. Security fixes target the
current `main` branch and the latest published alpha release.

View File

@ -76,7 +76,17 @@ podman run -p 18084:8080 \
## Integration Checklist
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
Adding a new app requires updates in multiple places:
- add `apps/<app-id>/manifest.yml`;
- add a Dockerfile and source directory only when the app is built locally;
- choose non-conflicting ports from [PORTS.md](./PORTS.md);
- declare `interfaces.main` for user-facing web UIs;
- declare generated secrets instead of hardcoding credentials;
- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`;
- regenerate catalogs with `python3 scripts/generate-app-catalog.py`;
- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`;
- test install, launch, stop, start, restart, uninstall, and reinstall.
## Port Assignments

View File

@ -1,33 +0,0 @@
# Archipelago Production Configuration
# This file is bundled with the macOS app
# Server Configuration
ARCHIPELAGO_HOST=127.0.0.1
ARCHIPELAGO_PORT=8100
ARCHIPELAGO_BACKEND_PORT=3030
# Data Directories (relative to ~/Library/Application Support/Archipelago)
ARCHIPELAGO_DATA_DIR=data
ARCHIPELAGO_LOG_DIR=logs
# Frontend Configuration
ARCHIPELAGO_FRONTEND_DIR=frontend
# Docker UI Configuration
ARCHIPELAGO_DOCKER_UI_DIR=docker-ui
# Security
ARCHIPELAGO_SESSION_SECRET=CHANGE_ME_ON_FIRST_RUN
# Logging
RUST_LOG=info
# Production Mode
NODE_ENV=production
ARCHIPELAGO_MODE=production
# Docker Configuration
DOCKER_HOST=unix:///var/run/docker.sock
# Disable External API Calls in Production
ARCHIPELAGO_DISABLE_EXTERNAL_APIS=true

53
core/Cargo.lock generated
View File

@ -84,6 +84,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.9.1"
@ -95,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.111-alpha"
version = "1.7.118-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@ -145,6 +154,7 @@ dependencies = [
"serde_yaml",
"serial2-tokio",
"sha2 0.10.9",
"socket2 0.5.10",
"tar",
"tempfile",
"thiserror 1.0.69",
@ -160,6 +170,7 @@ dependencies = [
"uuid",
"zbase32",
"zeroize",
"zip",
]
[[package]]
@ -1174,6 +1185,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@ -6894,8 +6916,37 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@ -0,0 +1,656 @@
# Third-Party Rust Crate Licenses — Archipelago core
Generated from `cargo metadata` (all features) on 2026-07-23. 649 external crates.
Full license texts ship with release artifacts (cargo-about; see docs/LICENSE-COMPLIANCE-AUDIT.md).
| Crate | Version | License | Source |
|---|---|---|---|
| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | https://github.com/oyvindln/adler2 |
| aead | 0.5.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| aes | 0.8.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
| aes-gcm | 0.10.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs |
| ahash | 0.7.8 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash |
| aho-corasick | 1.1.4 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick |
| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 |
| android_system_properties | 0.1.5 | MIT/Apache-2.0 | https://github.com/nical/android_system_properties |
| anyhow | 1.0.100 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow |
| arc-swap | 1.9.1 | MIT OR Apache-2.0 | https://github.com/vorner/arc-swap |
| argon2 | 0.5.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/argon2 |
| arrayref | 0.3.9 | BSD-2-Clause | https://github.com/droundy/arrayref |
| arrayvec | 0.7.6 | MIT OR Apache-2.0 | https://github.com/bluss/arrayvec |
| asn1-rs | 0.7.2 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
| async-trait | 0.1.89 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait |
| async-utility | 0.3.1 | MIT | https://github.com/yukibtc/async-utility.git |
| async-wsocket | 0.13.1 | MIT | https://github.com/yukibtc/async-wsocket.git |
| async_io_stream | 0.3.3 | Unlicense | https://github.com/najamelan/async_io_stream |
| atomic-destructor | 0.3.0 | MIT | https://github.com/yukibtc/atomic-destructor.git |
| atomic-polyfill | 1.0.3 | MIT OR Apache-2.0 | https://github.com/embassy-rs/atomic-polyfill |
| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker |
| attohttpc | 0.30.1 | MPL-2.0 | https://github.com/sbstp/attohttpc |
| autocfg | 1.5.0 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg |
| backon | 1.6.0 | Apache-2.0 | https://github.com/Xuanwo/backon |
| bao-tree | 0.16.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/bao-tree |
| base16ct | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| base32 | 0.5.1 | MIT OR Apache-2.0 | https://github.com/andreasots/base32 |
| base58ck | 0.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| base64 | 0.21.7 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
| base64ct | 1.8.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| bcrypt | 0.15.1 | MIT | https://github.com/Keats/rust-bcrypt |
| bech32 | 0.11.1 | MIT | https://github.com/rust-bitcoin/rust-bech32 |
| binary-merge | 0.1.2 | MIT OR Apache-2.0 | https://github.com/rklaehn/binary-merge |
| bip39 | 2.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bip39/ |
| bit-vec | 0.9.1 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec |
| bitcoin | 0.32.5 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin-internals | 0.2.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin-internals | 0.3.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin-io | 0.1.4 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
| bitcoin-units | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin_hashes | 0.13.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
| bitcoin_hashes | 0.14.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
| bitflags | 1.3.2 | MIT/Apache-2.0 | https://github.com/bitflags/bitflags |
| bitflags | 2.13.0 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags |
| blake2 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| blake3 | 1.8.5 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | https://github.com/BLAKE3-team/BLAKE3 |
| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| block-buffer | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| block-padding | 0.3.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| block2 | 0.6.2 | MIT | https://github.com/madsmtm/objc2 |
| blowfish | 0.9.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
| bs58 | 0.5.1 | MIT/Apache-2.0 | https://github.com/Nullus157/bs58-rs |
| bumpalo | 3.19.1 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo |
| bytemuck | 1.25.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/bytemuck |
| byteorder | 1.5.0 | Unlicense OR MIT | https://github.com/BurntSushi/byteorder |
| byteorder-lite | 0.1.0 | Unlicense OR MIT | https://github.com/image-rs/byteorder-lite |
| bytes | 1.11.0 | MIT | https://github.com/tokio-rs/bytes |
| cbc | 0.1.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
| cc | 1.2.54 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
| cesu8 | 1.1.0 | Apache-2.0/MIT | https://github.com/emk/cesu8-rs |
| cfg-if | 1.0.4 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if |
| cfg_aliases | 0.2.1 | MIT | https://github.com/katharostech/cfg_aliases |
| chacha20 | 0.10.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
| chacha20 | 0.9.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/stream-ciphers |
| chacha20poly1305 | 0.10.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 |
| chrono | 0.4.43 | MIT OR Apache-2.0 | https://github.com/chronotope/chrono |
| ciborium | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
| ciborium-io | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
| ciborium-ll | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
| cipher | 0.4.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| cmov | 0.5.4 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs |
| combine | 4.6.7 | MIT | https://github.com/Marwes/combine |
| const-oid | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| const-oid | 0.9.6 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/const-oid |
| constant_time_eq | 0.3.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
| constant_time_eq | 0.4.2 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
| convert_case | 0.10.0 | MIT | https://github.com/rutrum/convert-case |
| cordyceps | 0.3.4 | MIT | https://github.com/hawkw/mycelium |
| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| cpufeatures | 0.3.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| crc | 3.4.0 | MIT OR Apache-2.0 | https://github.com/mrhooray/crc-rs.git |
| crc-catalog | 2.4.0 | MIT OR Apache-2.0 | https://github.com/akhilles/crc-catalog.git |
| crc32fast | 1.5.0 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast |
| critical-section | 1.2.0 | MIT OR Apache-2.0 | https://github.com/rust-embedded/critical-section |
| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
| crunchy | 0.2.4 | MIT | https://github.com/eira-fransham/crunchy |
| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| crypto-common | 0.2.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| ctr | 0.9.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
| ctutils | 0.4.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| curve25519-dalek | 4.1.3 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
| curve25519-dalek | 5.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
| curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | https://github.com/dalek-cryptography/curve25519-dalek |
| darling | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
| darling_core | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
| darling_macro | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
| data-encoding | 2.11.0 | MIT | https://github.com/ia0/data-encoding |
| data-encoding-macro | 0.1.20 | MIT | https://github.com/ia0/data-encoding |
| data-encoding-macro-internal | 0.1.18 | MIT | https://github.com/ia0/data-encoding |
| der | 0.7.10 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/der |
| der | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| der-parser | 10.0.0 | MIT OR Apache-2.0 | https://github.com/rusticata/der-parser.git |
| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged |
| derive_builder | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
| derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
| derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
| derive_more | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
| derive_more-impl | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
| diatomic-waker | 0.2.3 | MIT OR Apache-2.0 | https://github.com/asynchronics/diatomic-waker |
| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| digest | 0.11.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| dispatch2 | 0.3.1 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| displaydoc | 0.2.5 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc |
| dlopen2 | 0.8.2 | MIT | https://github.com/OpenByteDev/dlopen2 |
| ed25519 | 2.2.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures/tree/master/ed25519 |
| ed25519 | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures |
| ed25519-dalek | 2.2.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
| ed25519-dalek | 3.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
| either | 1.15.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either |
| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io |
| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal |
| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs |
| enum-assoc | 1.3.0 | MIT OR Apache-2.0 | https://github.com/Eolu/enum-assoc |
| env_logger | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-cli/env_logger |
| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent |
| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno |
| fastbloom | 0.17.0 | MIT OR Apache-2.0 | https://github.com/tomtomwombat/fastbloom/ |
| fastrand | 2.3.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand |
| fiat-crypto | 0.2.9 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
| fiat-crypto | 0.3.0 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
| filetime | 0.2.27 | MIT/Apache-2.0 | https://github.com/alexcrichton/filetime |
| find-msvc-tools | 0.1.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
| flate2 | 1.1.9 | MIT OR Apache-2.0 | https://github.com/rust-lang/flate2-rs |
| flume | 0.11.1 | Apache-2.0/MIT | https://github.com/zesterer/flume |
| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv |
| foldhash | 0.1.5 | Zlib | https://github.com/orlp/foldhash |
| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash |
| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
| futures | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-buffered | 0.2.13 | MIT | https://github.com/conradludgate/futures-buffered |
| futures-channel | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-core | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-executor | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-io | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-lite | 2.6.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/futures-lite |
| futures-macro | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-sink | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-task | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-util | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| genawaiter | 0.99.1 | MIT | https://github.com/whatisaphone/genawaiter |
| genawaiter-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
| genawaiter-proc-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
| generator | 0.8.9 | MIT/Apache-2.0 | https://github.com/Xudong-Huang/generator-rs.git |
| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git |
| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
| getrandom | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
| ghash | 0.5.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
| gloo-timers | 0.3.0 | MIT OR Apache-2.0 | https://github.com/rustwasm/gloo/tree/master/crates/timers |
| h2 | 0.3.27 | MIT | https://github.com/hyperium/h2 |
| h2 | 0.4.13 | MIT | https://github.com/hyperium/h2 |
| half | 2.7.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/half-rs |
| hash32 | 0.2.1 | MIT OR Apache-2.0 | https://github.com/japaric/hash32 |
| hashbrown | 0.12.3 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| hashbrown | 0.15.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| heapless | 0.7.17 | MIT OR Apache-2.0 | https://github.com/japaric/heapless |
| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck |
| hermit-abi | 0.5.2 | MIT OR Apache-2.0 | https://github.com/hermit-os/hermit-rs |
| hex | 0.4.3 | MIT OR Apache-2.0 | https://github.com/KokaKiwi/rust-hex |
| hex-conservative | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
| hex-conservative | 0.2.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
| hex_lit | 0.1.1 | MITNFA | https://github.com/Kixunil/hex_lit |
| hickory-net | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
| hickory-proto | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
| hickory-resolver | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
| hkdf | 0.12.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/KDFs/ |
| hmac | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/MACs |
| http | 0.2.12 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
| http | 1.4.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
| http-body | 0.4.6 | MIT | https://github.com/hyperium/http-body |
| http-body | 1.0.1 | MIT | https://github.com/hyperium/http-body |
| http-body-util | 0.1.3 | MIT | https://github.com/hyperium/http-body |
| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse |
| httpdate | 1.0.3 | MIT OR Apache-2.0 | https://github.com/pyfisch/httpdate |
| humantime | 2.3.0 | MIT OR Apache-2.0 | https://github.com/chronotope/humantime |
| hybrid-array | 0.4.12 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hybrid-array |
| hyper | 0.14.32 | MIT | https://github.com/hyperium/hyper |
| hyper | 1.8.1 | MIT | https://github.com/hyperium/hyper |
| hyper-rustls | 0.24.2 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
| hyper-util | 0.1.19 | MIT | https://github.com/hyperium/hyper-util |
| hyper-ws-listener | 0.3.0 | MIT | |
| iana-time-zone | 0.1.64 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
| iana-time-zone-haiku | 0.1.2 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
| icu_collections | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_locale_core | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_normalizer | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_normalizer_data | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_properties | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_properties_data | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_provider | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena |
| ident_case | 1.0.1 | MIT/Apache-2.0 | https://github.com/TedDriggs/ident_case |
| identity-hash | 0.1.0 | Apache-2.0 OR MIT | https://github.com/offsetting/identity-hash |
| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
| idna_adapter | 1.2.1 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter |
| if-addrs | 0.15.0 | MIT OR BSD-3-Clause | https://github.com/messense/if-addrs |
| igd-next | 0.17.1 | MIT | https://github.com/dariusc93/rust-igd |
| image | 0.25.9 | MIT OR Apache-2.0 | https://github.com/image-rs/image |
| indexmap | 2.13.0 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap |
| inout | 0.1.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| inplace-vec-builder | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rklaehn/inplace-vec-builder |
| instant | 0.1.13 | BSD-3-Clause | https://github.com/sebcrozet/instant |
| ipconfig | 0.3.4 | MIT/Apache-2.0 | https://github.com/liranringel/ipconfig |
| ipnet | 2.12.0 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet |
| iri-string | 0.7.12 | MIT OR Apache-2.0 | https://github.com/lo48576/iri-string |
| iroh | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-base | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-blobs | 0.103.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-blobs |
| iroh-dns | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-io | 0.6.2 | Apache-2.0 OR MIT | https://github.com/n0-computer/iroh |
| iroh-metrics | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
| iroh-metrics-derive | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
| iroh-relay | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-tickets | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-tickets |
| iroh-util | 0.6.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-util |
| irpc | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
| irpc-derive | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
| is-terminal | 0.4.17 | MIT | https://github.com/sunfishcode/is-terminal |
| itoa | 1.0.17 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa |
| jni | 0.21.1 | MIT/Apache-2.0 | https://github.com/jni-rs/jni-rs |
| jni | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
| jni-macros | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
| jni-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
| jni-sys | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
| jni-sys-macros | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
| js-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys |
| lazy_static | 1.5.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/lazy-static.rs |
| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt |
| libc | 0.2.180 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc |
| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins |
| libredox | 0.1.14 | MIT | https://gitlab.redox-os.org/redox-os/libredox.git |
| libssh2-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
| libz-sys | 1.1.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/libz-sys |
| linux-raw-sys | 0.11.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys |
| litemap | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
| log | 0.4.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/log |
| loom | 0.7.2 | MIT | https://github.com/tokio-rs/loom |
| lru | 0.12.5 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru | 0.16.3 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru | 0.18.0 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru | 0.7.8 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab |
| mac-addr | 0.3.0 | MIT | https://github.com/shellrow/mac-addr |
| mainline | 2.0.1 | MIT | https://github.com/nuhvi/mainline |
| matchers | 0.2.0 | MIT | https://github.com/hawkw/matchers |
| mdns-sd | 0.18.2 | Apache-2.0 OR MIT | https://github.com/keepsimple1/mdns-sd |
| memchr | 2.7.6 | Unlicense OR MIT | https://github.com/BurntSushi/memchr |
| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime |
| minimal-lexical | 0.2.1 | MIT/Apache-2.0 | https://github.com/Alexhuszagh/minimal-lexical |
| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide |
| mio | 1.1.1 | MIT | https://github.com/tokio-rs/mio |
| moka | 0.12.15 | (MIT OR Apache-2.0) AND Apache-2.0 | https://github.com/moka-rs/moka |
| moxcms | 0.7.11 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/moxcms.git |
| n0-error | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
| n0-error-macros | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
| n0-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-future |
| n0-watcher | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-watcher |
| ndk-context | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rust-windowing/android-ndk-rs |
| negentropy | 0.5.0 | MIT | https://github.com/rust-nostr/negentropy.git |
| nested_enum_utils | 0.2.3 | MIT OR Apache-2.0 | https://github.com/n0-computer/nested-enum-utils |
| netdev | 0.44.0 | MIT | https://github.com/shellrow/netdev |
| netlink-packet-core | 0.8.1 | MIT | https://github.com/rust-netlink/netlink-packet-core |
| netlink-packet-route | 0.29.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
| netlink-packet-route | 0.31.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
| netlink-proto | 0.12.0 | MIT | https://github.com/rust-netlink/netlink-proto |
| netlink-sys | 0.8.8 | MIT | https://github.com/rust-netlink/netlink-sys |
| netwatch | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
| nom | 7.1.3 | MIT | https://github.com/Geal/nom |
| noq | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
| noq-proto | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
| noq-udp | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
| nostr | 0.44.2 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-database | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-gossip | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-relay-pool | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-sdk | 0.44.1 | MIT | https://github.com/rust-nostr/nostr.git |
| nu-ansi-term | 0.50.3 | MIT | https://github.com/nushell/nu-ansi-term |
| num-bigint | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint |
| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv |
| num-integer | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer |
| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits |
| num_enum | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
| num_enum_derive | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
| num_threads | 0.1.7 | MIT OR Apache-2.0 | https://github.com/jhpratt/num_threads |
| objc2 | 0.6.4 | MIT | https://github.com/madsmtm/objc2 |
| objc2-core-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-core-wlan | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-encode | 4.1.0 | MIT | https://github.com/madsmtm/objc2 |
| objc2-foundation | 0.3.2 | MIT | https://github.com/madsmtm/objc2 |
| objc2-security | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-security-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-system-configuration | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| oid-registry | 0.8.1 | MIT OR Apache-2.0 | https://github.com/rusticata/oid-registry.git |
| once_cell | 1.21.3 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell |
| opaque-debug | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe |
| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl |
| papaya | 0.2.4 | MIT | https://github.com/ibraheemdev/papaya |
| parking | 2.2.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/parking |
| parking_lot | 0.11.2 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
| parking_lot_core | 0.8.6 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
| password-hash | 0.5.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits/tree/master/password-hash |
| paste | 1.0.15 | MIT OR Apache-2.0 | https://github.com/dtolnay/paste |
| pbkdf2 | 0.12.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 |
| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git |
| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
| pharos | 0.5.3 | Unlicense | https://github.com/najamelan/pharos |
| pin-project | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
| pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
| pin-project-lite | 0.2.16 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite |
| pin-utils | 0.1.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/pin-utils |
| pkcs8 | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/pkcs8 |
| pkcs8 | 0.11.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| pkg-config | 0.3.33 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs |
| plain | 0.2.3 | MIT/Apache-2.0 | https://github.com/randomites/plain |
| plist | 1.9.0 | MIT | https://github.com/ebarnard/rust-plist/ |
| poly1305 | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
| polyval | 0.6.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
| portable-atomic | 1.13.1 | Apache-2.0 OR MIT | https://github.com/taiki-e/portable-atomic |
| portmapper | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
| positioned-io | 0.3.5 | MIT | https://github.com/vasi/positioned-io |
| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
| postcard-derive | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
| potential_utf | 0.1.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt |
| ppv-lite86 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/cryptocorrosion/cryptocorrosion |
| prefix-trie | 0.8.4 | MIT OR Apache-2.0 | https://github.com/tiborschneider/prefix-trie |
| prettyplease | 0.2.37 | MIT OR Apache-2.0 | https://github.com/dtolnay/prettyplease |
| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | https://github.com/bkchr/proc-macro-crate |
| proc-macro-error | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
| proc-macro-error-attr | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
| proc-macro-hack | 0.5.20+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro-hack |
| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 |
| pxfm | 0.1.28 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/pxfm |
| qrcode | 0.14.1 | MIT OR Apache-2.0 | https://github.com/kennytm/qrcode-rust |
| quick-xml | 0.39.4 | MIT | https://github.com/tafia/quick-xml |
| quote | 1.0.44 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote |
| r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
| rand | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand | 0.8.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand | 0.9.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_chacha | 0.3.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_chacha | 0.9.0 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core |
| rand_core | 0.6.4 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_core | 0.9.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs |
| range-collections | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rklaehn/range-collections |
| rcgen | 0.14.8 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen |
| redb | 4.1.0 | MIT OR Apache-2.0 | https://github.com/cberner/redb |
| redox_syscall | 0.2.16 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
| redox_syscall | 0.5.18 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
| redox_syscall | 0.7.3 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
| reed-solomon-erasure | 6.0.0 | MIT | https://github.com/darrenldl/reed-solomon-erasure |
| ref-cast | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
| ref-cast-impl | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
| reflink-copy | 0.1.29 | MIT/Apache-2.0 | https://github.com/cargo-bins/reflink-copy |
| regex | 1.12.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
| regex-automata | 0.4.13 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
| regex-syntax | 0.8.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
| reqwest | 0.11.27 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
| reqwest | 0.13.4 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
| resolv-conf | 0.7.6 | MIT OR Apache-2.0 | https://github.com/hickory-dns/resolv-conf |
| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring |
| rustc-hash | 2.1.2 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash |
| rustc_version | 0.4.1 | MIT OR Apache-2.0 | https://github.com/djc/rustc-version-rs |
| rusticata-macros | 4.1.0 | MIT/Apache-2.0 | https://github.com/rusticata/rusticata-macros.git |
| rustix | 1.1.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix |
| rustls | 0.21.12 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
| rustls | 0.23.36 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
| rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls-native-certs |
| rustls-pemfile | 1.0.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/pemfile |
| rustls-pki-types | 1.14.0 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types |
| rustls-platform-verifier | 0.7.0 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
| rustls-platform-verifier-android | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
| rustls-webpki | 0.101.7 | ISC | https://github.com/rustls/webpki |
| rustls-webpki | 0.103.9 | ISC | https://github.com/rustls/webpki |
| rustversion | 1.0.22 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion |
| ryu | 1.0.22 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu |
| salsa20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
| same-file | 1.0.6 | Unlicense/MIT | https://github.com/BurntSushi/same-file |
| schannel | 0.1.29 | MIT | https://github.com/steffengy/schannel-rs |
| scoped-tls | 1.0.1 | MIT/Apache-2.0 | https://github.com/alexcrichton/scoped-tls |
| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard |
| scrypt | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/scrypt |
| sct | 0.7.1 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/sct.rs |
| sd-notify | 0.4.5 | MIT OR Apache-2.0 | https://github.com/lnicola/sd-notify |
| secp256k1 | 0.29.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
| secp256k1-sys | 0.10.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
| seize | 0.5.1 | MIT | https://github.com/ibraheemdev/seize |
| self_cell | 1.2.2 | Apache-2.0 OR GPL-2.0-only | https://github.com/Voultapher/self_cell |
| semver | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver |
| send_wrapper | 0.6.0 | MIT/Apache-2.0 | https://github.com/thk1/send_wrapper |
| serde | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
| serde_bencode | 0.2.4 | MIT | https://github.com/toby/serde-bencode |
| serde_bytes | 0.11.19 | MIT OR Apache-2.0 | https://github.com/serde-rs/bytes |
| serde_core | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
| serde_derive | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
| serde_json | 1.0.149 | MIT OR Apache-2.0 | https://github.com/serde-rs/json |
| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded |
| serde_yaml | 0.9.34+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/serde-yaml |
| serdect | 0.4.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| serial2 | 0.2.34 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-rs |
| serial2-tokio | 0.1.21 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-tokio-rs |
| sha-1 | 0.10.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sha1 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sha1_smol | 1.0.1 | BSD-3-Clause | https://github.com/mitsuhiko/sha1-smol |
| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sha2 | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sharded-slab | 0.1.7 | MIT | https://github.com/hawkw/sharded-slab |
| shlex | 1.3.0 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex |
| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | https://github.com/vorner/signal-hook |
| signature | 2.2.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits/tree/master/signature |
| signature | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits |
| simd-adler32 | 0.3.8 | MIT | https://github.com/mcountryman/simd-adler32 |
| simd_cesu8 | 1.1.1 | Apache-2.0 OR MIT | https://github.com/seancroach/simd_cesu8 |
| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 |
| simple-dns | 0.11.3 | MIT | https://github.com/balliegojr/simple-dns |
| siphasher | 1.0.3 | MIT/Apache-2.0 | https://github.com/jedisct1/rust-siphash |
| slab | 0.4.11 | MIT | https://github.com/tokio-rs/slab |
| smallvec | 1.15.1 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec |
| socket-pktinfo | 0.3.2 | MIT | https://github.com/pixsper/socket-pktinfo |
| socket2 | 0.5.10 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
| socket2 | 0.6.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
| sorted-index-buffer | 0.2.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| spez | 0.1.2 | BSD-2-Clause | https://github.com/m-ou-se/spez |
| spin | 0.10.0 | MIT | https://github.com/mvdnes/spin-rs.git |
| spin | 0.9.8 | MIT | https://github.com/mvdnes/spin-rs.git |
| spki | 0.7.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/spki |
| spki | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| ssh2 | 0.9.5 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait |
| strsim | 0.11.1 | MIT | https://github.com/rapidfuzz/strsim-rs |
| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum |
| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum |
| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle |
| syn | 1.0.109 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
| syn | 2.0.114 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
| syn-mid | 0.5.4 | Apache-2.0 OR MIT | https://github.com/taiki-e/syn-mid |
| sync_wrapper | 0.1.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
| synstructure | 0.13.2 | MIT | https://github.com/mystor/synstructure |
| system-configuration | 0.5.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration | 0.6.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration-sys | 0.5.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| tagptr | 0.2.0 | MIT/Apache-2.0 | https://github.com/oliver-giersch/tagptr.git |
| tar | 0.4.44 | MIT OR Apache-2.0 | https://github.com/alexcrichton/tar-rs |
| tempfile | 3.24.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile |
| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor |
| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thiserror | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thiserror-impl | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thread_local | 1.1.9 | MIT OR Apache-2.0 | https://github.com/Amanieu/thread_local-rs |
| time | 0.3.49 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
| time-macros | 0.2.29 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
| tinystr | 0.8.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| tinyvec | 1.10.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec |
| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib | https://github.com/Soveu/tinyvec_macros |
| tokio | 1.49.0 | MIT | https://github.com/tokio-rs/tokio |
| tokio-macros | 2.6.0 | MIT | https://github.com/tokio-rs/tokio |
| tokio-rustls | 0.24.1 | MIT/Apache-2.0 | https://github.com/rustls/tokio-rustls |
| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls |
| tokio-socks | 0.5.2 | MIT | https://github.com/sticnarf/tokio-socks |
| tokio-stream | 0.1.18 | MIT | https://github.com/tokio-rs/tokio |
| tokio-test | 0.4.5 | MIT | https://github.com/tokio-rs/tokio |
| tokio-tungstenite | 0.20.1 | MIT | https://github.com/snapview/tokio-tungstenite |
| tokio-tungstenite | 0.26.2 | MIT | https://github.com/snapview/tokio-tungstenite |
| tokio-util | 0.7.18 | MIT | https://github.com/tokio-rs/tokio |
| tokio-websockets | 0.13.2 | MIT | https://github.com/Gelbpunkt/tokio-websockets/ |
| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_datetime | 1.1.1+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_edit | 0.25.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_parser | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| totp-rs | 5.7.0 | MIT | https://github.com/constantoine/totp-rs |
| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower |
| tower-http | 0.6.8 | MIT | https://github.com/tower-rs/tower-http |
| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower |
| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower |
| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing |
| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing |
| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing |
| tracing-log | 0.2.0 | MIT | https://github.com/tokio-rs/tracing |
| tracing-subscriber | 0.3.22 | MIT | https://github.com/tokio-rs/tracing |
| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock |
| tungstenite | 0.20.1 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
| tungstenite | 0.26.2 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum |
| unicode-ident | 1.0.22 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident |
| unicode-normalization | 0.1.22 | MIT/Apache-2.0 | https://github.com/unicode-rs/unicode-normalization |
| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-segmentation |
| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-xid |
| universal-hash | 0.5.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| unsafe-libyaml | 0.2.11 | MIT | https://github.com/dtolnay/unsafe-libyaml |
| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted |
| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
| urlencoding | 2.1.3 | MIT | https://github.com/kornelski/rust_urlencoding |
| utf-8 | 0.7.6 | MIT OR Apache-2.0 | https://github.com/SimonSapin/rust-utf8 |
| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter |
| uuid | 1.19.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid |
| valuable | 0.1.1 | MIT | https://github.com/tokio-rs/valuable |
| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs |
| vergen | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
| vergen-gitcl | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
| vergen-lib | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check |
| walkdir | 2.5.0 | Unlicense/MIT | https://github.com/BurntSushi/walkdir |
| want | 0.3.1 | MIT | https://github.com/seanmonstar/want |
| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi |
| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
| wasm-bindgen | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen |
| wasm-bindgen-futures | 0.4.58 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures |
| wasm-bindgen-macro | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro |
| wasm-bindgen-macro-support | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support |
| wasm-bindgen-shared | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared |
| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder |
| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-metadata |
| wasm-streams | 0.4.2 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
| wasm-streams | 0.5.0 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser |
| web-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys |
| web-time | 1.1.0 | MIT OR Apache-2.0 | https://github.com/daxpedda/web-time |
| webpki-root-certs | 1.0.7 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
| webpki-roots | 0.25.4 | MPL-2.0 | https://github.com/rustls/webpki-roots |
| webpki-roots | 0.26.11 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
| webpki-roots | 1.0.6 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
| widestring | 1.2.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/widestring-rs |
| winapi | 0.3.9 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
| winapi-util | 0.1.11 | Unlicense OR MIT | https://github.com/BurntSushi/winapi-util |
| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
| windows | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-collections | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-core | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-implement | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-interface | 0.59.3 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-link | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-numerics | 0.3.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-registry | 0.6.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-result | 0.4.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-strings | 0.5.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.45.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.48.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.52.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.61.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.53.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-threading | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| winnow | 0.7.14 | MIT | https://github.com/winnow-rs/winnow |
| winnow | 1.0.3 | MIT | https://github.com/winnow-rs/winnow |
| winreg | 0.50.0 | MIT | https://github.com/gentoo90/winreg-rs |
| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-component |
| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser |
| wmi | 0.18.4 | MIT OR Apache-2.0 | https://github.com/ohadravid/wmi-rs |
| writeable | 0.6.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| ws_stream_wasm | 0.7.5 | Unlicense | https://github.com/najamelan/ws_stream_wasm |
| x509-parser | 0.18.1 | MIT OR Apache-2.0 | https://github.com/rusticata/x509-parser.git |
| xattr | 1.6.1 | MIT OR Apache-2.0 | https://github.com/Stebalien/xattr |
| xml-rs | 0.8.28 | MIT | https://github.com/kornelski/xml-rs |
| xmltree | 0.10.3 | MIT | https://github.com/eminence/xmltree-rs |
| yasna | 0.6.0 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs |
| yoke | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| yoke-derive | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zbase32 | 0.1.2 | LGPL-3.0+ | https://gitlab.com/pgerber/zbase32-rust |
| zerocopy | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
| zerocopy-derive | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
| zerofrom | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zerofrom-derive | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| zerotrie | 0.2.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zerovec | 0.11.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zerovec-derive | 0.11.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zmij | 1.0.16 | MIT | https://github.com/dtolnay/zmij |

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.111-alpha"
version = "1.7.118-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
[dependencies]
# Core dependencies
tokio = { version = "1", features = ["full"] }
# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with
# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt).
socket2 = "0.5"
libc = "0.2" # process-group signalling for the supervised reticulum daemon
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@ -105,6 +108,10 @@ bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1"
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
# per-platform release zip (see mesh/flash.rs).
zip = { version = "2", default-features = false, features = ["deflate"] }
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
hkdf = "0.12.4"

View File

@ -202,7 +202,9 @@ impl ApiHandler {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
hyper::Body::from(
r#"{"error":"The seller does not accept Lightning for this item"}"#,
),
));
}

View File

@ -106,7 +106,7 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
let mut name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
@ -116,6 +116,14 @@ impl RpcHandler {
if name.is_empty() || name.len() > 64 {
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
}
// The default name was a single shared slot: every pairing popup
// replaced the previous phone's token, silently logging out the
// first phone the moment a second one paired. Default-named mints
// get a unique suffix so each device keeps its own credential;
// explicitly named devices keep replace-in-place semantics.
if name == "companion" {
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
}
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
Ok(serde_json::json!({ "name": name, "token": token }))
}

View File

@ -465,6 +465,7 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
}

View File

@ -279,6 +279,7 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.send_get()
.await
.context("Failed to connect to peer")?;
@ -364,6 +365,11 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
// The Cloud page's hottest call: without a fast-fail cap a
// cold FIPS path burned ~16.6s before Tor even started,
// against the UI's 30s deadline — users saw errors, not
// fallback.
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
.context("Failed to connect to peer")?;
@ -443,8 +449,7 @@ impl RpcHandler {
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
}))
});
if let Some(o) = already {
@ -454,12 +459,9 @@ impl RpcHandler {
owned_as = %o.content_id,
"paid download: already owned — serving cached copy, NOT paying again"
);
if let Some((mime, bytes)) = crate::content_owned::read_owned(
&self.config.data_dir,
&o.onion,
&o.content_id,
)
.await
if let Some((mime, bytes)) =
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
@ -692,10 +694,7 @@ impl RpcHandler {
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
@ -1144,6 +1143,7 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
.context("Failed to connect to peer for preview")?;

View File

@ -124,12 +124,15 @@ impl RpcHandler {
}
"lnd.getinfo" => self.handle_lnd_getinfo().await,
"lnd.listchannels" => self.handle_lnd_listchannels().await,
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
"lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
@ -388,7 +391,12 @@ impl RpcHandler {
// Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await,
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
"mesh.flash-status" => self.handle_mesh_flash_status().await,
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.refresh" => self.handle_mesh_refresh().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
"mesh.send" => self.handle_mesh_send(params).await,

View File

@ -865,7 +865,9 @@ impl RpcHandler {
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30));
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(&self.config.data_dir);
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {

View File

@ -13,7 +13,14 @@ use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
let mut v = serde_json::to_value(status)?;
// Dial outcome counters (process-lifetime): how often peer dials
// used FIPS vs fell back to Tor, broken down by reason. This is
// the observability that makes "FIPS uptime" measurable.
if let Some(obj) = v.as_object_mut() {
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
}
Ok(v)
}
/// Everything the companion app needs to join this node's mesh, embedded
@ -23,9 +30,11 @@ impl RpcHandler {
/// host/IP itself — it knows which origin the browser reached the node on.
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let npub = crate::identity::fips_npub(&identity_dir)
.await?
.ok_or_else(|| {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
@ -81,7 +90,8 @@ impl RpcHandler {
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
fips::config::install(&identity_dir).await?;
fips::service::activate(fips::SERVICE_UNIT).await?;
let unit = fips::service::activation_unit().await;
fips::service::activate(unit).await?;
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
}

View File

@ -30,6 +30,8 @@ struct ChannelInfo {
active: bool,
status: String,
channel_point: String,
#[serde(skip_serializing_if = "String::is_empty")]
closing_txid: String,
}
#[derive(Debug, Serialize)]
@ -58,6 +60,10 @@ struct LndChannel {
#[derive(Debug, Deserialize, Default)]
struct LndPendingChannelsResponse {
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
// Cooperative closes waiting for their closing tx to confirm
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
// Force closes serving out their timelock
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
}
#[derive(Debug, Deserialize)]
@ -65,6 +71,18 @@ struct LndPendingOpenChannel {
channel: Option<LndPendingChannel>,
}
#[derive(Debug, Deserialize)]
struct LndWaitingCloseChannel {
channel: Option<LndPendingChannel>,
closing_txid: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LndForceClosingChannel {
channel: Option<LndPendingChannel>,
closing_txid: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LndPendingChannel {
remote_node_pub: Option<String>,
@ -74,6 +92,52 @@ struct LndPendingChannel {
channel_point: Option<String>,
}
impl LndPendingChannel {
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
ChannelInfo {
chan_id: String::new(),
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
capacity: parse(&self.capacity),
local_balance: parse(&self.local_balance),
remote_balance: parse(&self.remote_balance),
active: false,
status: status.into(),
channel_point: self.channel_point.unwrap_or_default(),
closing_txid: closing_txid.unwrap_or_default(),
}
}
}
#[derive(Debug, Deserialize, Default)]
struct LndClosedChannelsResponse {
channels: Option<Vec<LndClosedChannel>>,
}
#[derive(Debug, Deserialize)]
struct LndClosedChannel {
chan_id: Option<String>,
remote_pubkey: Option<String>,
capacity: Option<String>,
settled_balance: Option<String>,
close_type: Option<String>,
closing_tx_hash: Option<String>,
channel_point: Option<String>,
close_height: Option<i64>,
}
#[derive(Debug, Serialize)]
struct ClosedChannelInfo {
chan_id: String,
remote_pubkey: String,
capacity: i64,
settled_balance: i64,
close_type: String,
closing_tx_hash: String,
channel_point: String,
close_height: i64,
}
impl RpcHandler {
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
@ -131,6 +195,7 @@ impl RpcHandler {
"inactive".into()
},
channel_point: ch.channel_point.unwrap_or_default(),
closing_txid: String::new(),
}
})
.collect();
@ -138,31 +203,20 @@ impl RpcHandler {
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
if let Some(ch) = pch.channel {
let capacity: i64 = ch
.capacity
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let local: i64 = ch
.local_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let remote: i64 = ch
.remote_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
pending_channels.push(ChannelInfo {
chan_id: String::new(),
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
capacity,
local_balance: local,
remote_balance: remote,
active: false,
status: "pending_open".into(),
channel_point: ch.channel_point.unwrap_or_default(),
});
pending_channels.push(ch.into_channel_info("pending_open", None));
}
}
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
if let Some(ch) = wch.channel {
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
}
}
for fch in pending_resp
.pending_force_closing_channels
.unwrap_or_default()
{
if let Some(ch) = fch.channel {
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
}
}
@ -349,6 +403,46 @@ impl RpcHandler {
Ok(body)
}
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp: LndClosedChannelsResponse = client
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?
.json()
.await
.context("Failed to parse LND closed channels response")?;
let channels: Vec<ClosedChannelInfo> = resp
.channels
.unwrap_or_default()
.into_iter()
.map(|ch| ClosedChannelInfo {
chan_id: ch.chan_id.unwrap_or_default(),
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
capacity: ch
.capacity
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
settled_balance: ch
.settled_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
close_type: ch.close_type.unwrap_or_default(),
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
channel_point: ch.channel_point.unwrap_or_default(),
close_height: ch.close_height.unwrap_or(0),
})
.collect();
Ok(serde_json::json!({ "channels": channels }))
}
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
&self,
params: Option<serde_json::Value>,
@ -389,27 +483,35 @@ impl RpcHandler {
"Closing Lightning channel"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let (_, macaroon_hex) = self.lnd_client().await?;
// The close endpoint is server-streaming: LND holds the connection
// open and emits updates until the closing tx CONFIRMS on-chain
// (potentially hours). Reading the whole body hangs the RPC even
// though the close already went through, and the shared lnd_client's
// 15s total timeout would abort the stream mid-read. Use a dedicated
// client and return as soon as the first streamed update arrives.
let client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
.context("Failed to create streaming HTTP client")?;
let url = format!(
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
let resp = client
let mut resp = client
.delete(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to close channel")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse close channel response")?;
if !status.is_success() {
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = body
.get("message")
.and_then(|v| v.as_str())
@ -417,6 +519,56 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
}
Ok(serde_json::json!({ "success": true }))
// First streamed line is {"result":{"close_pending":…}} on success or
// {"error":…} — the stream reports errors in-band after a 200.
let mut buf: Vec<u8> = Vec::new();
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
while let Some(chunk) = resp.chunk().await? {
buf.extend_from_slice(&chunk);
let line = match buf.iter().position(|&b| b == b'\n') {
Some(pos) => &buf[..pos],
None => &buf[..],
};
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
return Ok::<_, anyhow::Error>(Some(v));
}
}
Ok(None)
})
.await;
match first_update {
Ok(Ok(Some(update))) => {
if let Some(err) = update.get("error") {
let msg = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
}
// txid arrives base64-encoded in internal byte order; flip it
// into the display order explorers use.
use base64::Engine as _;
let closing_txid = update
.pointer("/result/close_pending/txid")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(|mut bytes| {
bytes.reverse();
hex::encode(bytes)
})
.unwrap_or_default();
info!(channel_point, closing_txid, "Channel close initiated");
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
}
Ok(Ok(None)) => Err(anyhow::anyhow!(
"LND ended the close stream without an update — check the channel list"
)),
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
// No update inside the window: the close is almost certainly still
// negotiating with the peer — report initiated, the channel list
// will show it under Closing.
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
}
}
}

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,50 @@ 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 — only LND may declare a payment failed,
// so a post-connect timeout is IN FLIGHT (status: pending), never
// failure. The window is deliberately SHORT: most payments settle in
// a couple of seconds and still get their answer in one round trip,
// while a slow multi-hop route flips the UI into its "settling…"
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
// freezing the modal for two minutes with no feedback (framework-pt
// user report, 2026-07-29).
let pay_client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(8))
.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 +150,104 @@ 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(

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)`.
///

View File

@ -0,0 +1,134 @@
use super::super::RpcHandler;
use crate::mesh;
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
use crate::mesh::types::DeviceType;
use anyhow::Result;
fn parse_family(s: &str) -> Result<DeviceType> {
match s.trim().to_lowercase().as_str() {
"meshcore" => Ok(DeviceType::Meshcore),
"meshtastic" => Ok(DeviceType::Meshtastic),
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
other => anyhow::bail!(
"Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"
),
}
}
fn parse_board(s: &str) -> Result<FlashBoard> {
match s.trim().to_lowercase().as_str() {
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
}
}
impl RpcHandler {
/// mesh.flash-list-firmware — resolve the available firmware version(s)
/// for a given family. v1 only ever surfaces "latest".
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let versions = flash::list_firmware(family).await?;
Ok(serde_json::json!({ "versions": versions }))
}
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
/// latest firmware for the given family, defaulting to a full chip
/// erase before write. `board` is optional: if the port's USB vid:pid
/// unambiguously resolves to a known board, that's used; otherwise the
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
/// doc comment on why we refuse to guess).
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let path = params
.as_ref()
.and_then(|p| p.get("path"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
.to_string();
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let detected = mesh::detect_devices().await;
anyhow::ensure!(
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
let board = match params
.as_ref()
.and_then(|p| p.get("board"))
.and_then(|v| v.as_str())
{
Some(explicit) => parse_board(explicit)?,
None => {
let info = mesh::detect_devices_info()
.await
.into_iter()
.find(|d| d.path == path);
info.as_ref()
.and_then(flash::resolve_flash_board)
.ok_or_else(|| {
anyhow::anyhow!(
"Could not auto-detect the board on {path} — specify board explicitly"
)
})?
}
};
flash::start_flash_job(
&self.flash_job,
&self.mesh_service_arc(),
self.config.data_dir.clone(),
path,
board,
family,
)
.await?;
Ok(serde_json::json!({ "started": true }))
}
/// mesh.flash-status — poll the current (or most recent) flash job.
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
let status: FlashJobStatus = j.snapshot().await;
let mut value = serde_json::to_value(&status)?;
if let Some(obj) = value.as_object_mut() {
obj.insert("active".into(), (!status.done).into());
}
Ok(value)
}
None => Ok(serde_json::json!({ "active": false })),
}
}
/// mesh.flash-cancel — best-effort; only honored before erase/write has
/// started (see `FlashJob::cancel`'s doc comment).
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
j.cancel().await?;
Ok(serde_json::json!({ "cancelled": true }))
}
None => anyhow::bail!("No flash job in progress"),
}
}
}

View File

@ -1,6 +1,7 @@
use super::super::RpcHandler;
use crate::mesh;
use anyhow::Result;
use std::sync::Arc;
use tracing::info;
impl RpcHandler {
@ -131,7 +132,14 @@ impl RpcHandler {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
// Empty clears the custom mesh name (falls back to the server
// name) — without this, a name could be set but never unset.
let trimmed = name.trim();
config.advert_name = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
if let Some(announce) = params
.get("announce_block_headers")
@ -202,10 +210,24 @@ impl RpcHandler {
mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config
let mut service = self.mesh_service.write().await;
if let Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?;
// Apply to the running service in the background: configure() may
// stop+start the listener (config changes now restart the session so
// they actually take effect), and that can take seconds when the old
// session is mid-probe. Holding the service write-lock for that long
// inside this handler stalled every concurrent mesh.status/mesh.peers
// poll behind it — the UI froze and nginx surfaced 502s. The config is
// already persisted above; the UI observes progress via mesh.status.
{
let service_arc = Arc::clone(&self.mesh_service);
let config_for_apply = config.clone();
tokio::spawn(async move {
let mut service = service_arc.write().await;
if let Some(svc) = service.as_mut() {
if let Err(e) = svc.configure(config_for_apply).await {
tracing::error!("Applying mesh config to running service failed: {e:#}");
}
}
});
}
info!("Mesh config updated");

View File

@ -1,5 +1,6 @@
mod assistant;
mod bitcoin_ops;
mod flash;
mod messaging;
mod safety;
mod status;

View File

@ -101,15 +101,61 @@ impl RpcHandler {
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
let service = self.mesh_service.read().await;
let probe = match service.as_ref() {
Some(svc) => svc.probe_device(&path).await?,
// No mesh service yet (radio never enabled) — probe directly.
None => mesh::listener::probe_device(&path).await?,
};
// Refuse to probe while a firmware flash is in flight. Confirmed
// live 2026-07-23: esptool ("multiple access on port?") and
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
// failed with symptoms consistent with a second process holding the
// same serial fd — the flash subprocess runs for minutes outside
// our own async runtime, so nothing previously stopped a concurrent
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
// from opening the identical port at the same time and corrupting
// both operations' handshakes.
if let Some(job) = self.flash_job.read().await.as_ref() {
anyhow::ensure!(
job.snapshot().await.done,
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
);
}
// Only hold the mesh_service lock long enough for the quick
// active-path guard check — NEVER across the actual probe, which
// can take 15-60s across its internal collision retries. Confirmed
// live 2026-07-23: holding this read lock for the full probe starved
// a concurrent firmware-flash job's MeshService::stop() (which needs
// the write lock) well past its own bounded timeout, surfacing as
// "Mesh listener did not release the serial port" even though
// stop() itself was fast.
{
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
svc.ensure_probe_allowed(&path).await?;
}
}
let probe = mesh::listener::probe_device(&path).await?;
Ok(serde_json::to_value(probe)?)
}
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
/// contact table and re-announce ourselves so quiet-but-alive neighbours
/// answer. This is what the UI's Refresh button calls — before it existed
/// the button only re-read server caches and never touched the radio.
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let Some(svc) = service.as_ref() else {
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
};
let status = svc.status().await;
if status.device_connected {
let state = svc.shared_state();
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
.await;
}
Ok(serde_json::json!({
"refreshed": status.device_connected,
"device_connected": status.device_connected,
}))
}
/// mesh.peers — List discovered mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;

View File

@ -575,14 +575,17 @@ impl RpcHandler {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let has_tor = peer_pubkey_hex
.as_ref()
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
.unwrap_or(false)
|| peer_did
.as_ref()
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let fed_node = nodes.iter().find(|n| {
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|| peer_did.as_deref() == Some(n.did.as_str())
});
let has_tor = fed_node.is_some();
// Distinct FIPS capability so the frontend can offer FIPS as its own
// pill (not just a generic "Tor") — the dial layer still picks
// FIPS-first with Tor fallback on the actual send; these are honest
// capability labels, with `last_transport` saying what worked last.
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
@ -618,6 +621,8 @@ impl RpcHandler {
"tier": tier,
"est_seconds": est_seconds,
"has_tor": has_tor,
"has_fips": has_fips,
"last_transport": last_transport,
"reason": reason,
"size": size,
"mesh_auto_max": MESH_AUTO_MAX,
@ -820,6 +825,8 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
.service(crate::settings::transport::PeerService::MeshFileSharing)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.record_transport(&self.config.data_dir)
.send_get()
.await
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;

View File

@ -89,6 +89,9 @@ pub struct RpcHandler {
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
/// job at a time, since flashing needs exclusive access to the port.
flash_job: crate::mesh::flash::FlashJobHandle,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
/// Shared content-addressed blob store. Set by ApiHandler after construction
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
@ -160,6 +163,7 @@ impl RpcHandler {
endpoint_rate_limiter,
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
flash_job: crate::mesh::flash::new_job_handle(),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
@ -556,8 +560,8 @@ impl RpcHandler {
.is_some(),
None => false,
};
let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
let totp_enabled =
!is_token_login && self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled {
let password = login_params
.as_ref()

View File

@ -137,6 +137,7 @@ impl RpcHandler {
None,
None,
None,
Some(&self.config.data_dir),
)
.await?;
@ -225,6 +226,7 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&req.from_pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
{

View File

@ -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;

View File

@ -766,7 +766,11 @@ async fn find_satellite(port: u16) -> Option<String> {
if self_ips.contains(&ip) {
continue;
}
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
set.spawn(async move {
tcp_alive(&ip.to_string(), port, 500)
.await
.then(|| ip.to_string())
});
}
while let Some(res) = set.join_next().await {
if let Ok(Some(ip)) = res {

View File

@ -133,6 +133,7 @@ impl RpcHandler {
Some(node_id.signing_key()),
recipient_pubkey.as_deref(),
node_name.as_deref(),
Some(&self.config.data_dir),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))

View File

@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
Ok(true)
}
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
/// seed onboarding has written the fips_key to disk. Runs in a detached task
/// so the user-facing RPC returns immediately — the systemctl calls can take
/// a few seconds the first time on slow hardware. Any failure is logged but
/// does not break onboarding; the user can still hit fips.install manually
/// from the dashboard as an escape hatch.
/// Best-effort: install fips.yaml + start the available FIPS systemd unit after
/// seed onboarding has written the fips_key to disk. Runs in a detached task so
/// the user-facing RPC returns immediately — the systemctl calls can take a few
/// seconds the first time on slow hardware. Any failure is logged but does not
/// break onboarding; the user can still hit fips.install manually from the
/// dashboard as an escape hatch.
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let identity_dir = data_dir.join("identity");
@ -78,11 +78,12 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tracing::warn!("post-onboarding fips config install failed: {}", e);
return;
}
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
let unit = crate::fips::service::activation_unit().await;
if let Err(e) = crate::fips::service::activate(unit).await {
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
return;
}
tracing::info!("archipelago-fips auto-activated post-onboarding");
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
});
}
@ -138,8 +139,8 @@ impl RpcHandler {
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// fips_key is now on disk — auto-activate archipelago-fips so the
// user doesn't have to hit an "Activate" button. Detached task;
// fips_key is now on disk — auto-activate FIPS so the user doesn't
// have to hit a manual Start button. Detached task;
// the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());

View File

@ -61,6 +61,28 @@ impl RpcHandler {
info!("Server name updated to: {}", name);
// Propagate to the mesh: the listener advertises the server name (when
// no explicit mesh advert_name overrides it), but it was only read at
// process startup — a rename never reached the radio/RNS until the
// next full restart. Push it into the service and bounce the listener
// in the background (the restart re-probes the radio, which can take
// seconds — don't block the rename response on it).
{
let mesh_arc = self.mesh_service_arc();
let name_for_mesh = name.clone();
tokio::spawn(async move {
let mut guard = mesh_arc.write().await;
if let Some(svc) = guard.as_mut() {
svc.set_server_name(Some(name_for_mesh));
if svc.config().advert_name.is_none() {
if let Err(e) = svc.restart_listener_if_running().await {
warn!("Mesh listener restart after rename failed: {}", e);
}
}
}
});
}
// Push the new name to federation peers in background
let data_dir = self.config.data_dir.clone();
let state_manager = self.state_manager.clone();

View File

@ -498,7 +498,9 @@ pub(super) async fn notify_federation_peers_address_change(
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30));
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir);
match req.send_json(&payload).await {
Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")

View File

@ -373,6 +373,42 @@ async fn run_runtime_assets() -> Result<bool> {
}
}
// Packaged radio tools (v1.7.118+): OTA updates only apply the backend
// binary and frontend tarball, so these PyInstaller binaries ride the
// runtime payload and get promoted here. Without this, fleet nodes keep
// a stale archy-reticulum-daemon (which exits on flags it doesn't know —
// the v1.7.117 --enable-transport rollout killed their mesh sessions)
// and never receive archy-rnodeconf at all (Flash LoRa: "No such file
// or directory"). Skipped when byte-identical; a running daemon is
// unaffected (install replaces the inode) and picks the new binary up
// on its next spawn.
for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] {
let src = runtime_dir.join("radio-tools").join(tool);
if !src.exists() {
continue;
}
let dest = format!("/usr/local/bin/{}", tool);
let same = match (fs::read(&src).await, fs::read(&dest).await) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
if same {
continue;
}
let src_s = src.to_string_lossy().to_string();
let status = host_sudo(&["install", "-m", "755", &src_s, &dest])
.await
.with_context(|| format!("install {}", tool))?;
if !status.success() {
anyhow::bail!("install {} exited with {}", tool, status);
}
info!(
tool,
"Promoted packaged radio tool from OTA runtime payload"
);
changed = true;
}
if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
if nginx_src.exists() {
@ -848,11 +884,17 @@ pub async fn ensure_audio_stack() {
{
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
Ok(s) => {
warn!("audio: package install exited with {} — will retry next start", s);
warn!(
"audio: package install exited with {} — will retry next start",
s
);
return;
}
Err(e) => {
warn!("audio: package install failed: {:#} — will retry next start", e);
warn!(
"audio: package install failed: {:#} — will retry next start",
e
);
return;
}
}
@ -878,12 +920,23 @@ pub async fn ensure_audio_stack() {
}
if unit_was_missing {
// First install on this node — bring it up now and on every boot.
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
let _ = host_sudo(&[
"systemctl",
"enable",
"--now",
"archipelago-audio-router.service",
])
.await;
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
} else if script_changed || unit_changed {
// Content update: restart only if it's running — never re-enable a
// unit an operator deliberately disabled.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
let _ = host_sudo(&[
"systemctl",
"try-restart",
"archipelago-audio-router.service",
])
.await;
info!("audio: router updated");
}
}
@ -911,10 +964,21 @@ pub async fn ensure_gamepad_keys() {
}
}
if unit_was_missing {
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
let _ = host_sudo(&[
"systemctl",
"enable",
"--now",
"archipelago-gamepad-keys.service",
])
.await;
info!("gamepad: bridge installed and enabled (TV controller input)");
} else if script_changed || unit_changed {
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
let _ = host_sudo(&[
"systemctl",
"try-restart",
"archipelago-gamepad-keys.service",
])
.await;
info!("gamepad: bridge updated");
}
}
@ -991,6 +1055,13 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
let needs_fedimint_css = content.contains("location /app/fedimint/")
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
// Companion mesh access: phones reach this node over FIPS at its fips0
// ULA (http://[fdxx:…]). Configs shipped before 2026-07-23 listened on
// IPv4 only, so the ULA could never connect — nothing answered [::]:80.
let missing_v6_http =
content.contains("listen 80 default_server;") && !content.contains("listen [::]:80");
let missing_v6_https =
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
@ -998,12 +1069,27 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !missing_pine_status
&& !has_lnd_dup_cors
&& !needs_fedimint_css
&& !missing_v6_http
&& !missing_v6_https
{
return Ok(false);
}
let mut patched = content.clone();
if missing_v6_http {
patched = patched.replace(
"listen 80 default_server;",
"listen 80 default_server;\n listen [::]:80 default_server;",
);
}
if missing_v6_https {
patched = patched.replace(
"listen 443 ssl default_server;",
"listen 443 ssl default_server;\n listen [::]:443 ssl default_server;",
);
}
if has_lnd_dup_cors {
// Drop the redundant nginx-side CORS headers so the backend's single
// validated Access-Control-Allow-Origin is the only one returned.

View File

@ -111,8 +111,7 @@ impl BootReconciler {
let mut failure_rounds: u32 = 0;
loop {
let installed = orchestrator.manifest_ids().await;
let failures =
crate::container::companion::reconcile(&installed).await;
let failures = crate::container::companion::reconcile(&installed).await;
for (companion, err) in &failures {
tracing::warn!(
companion = %companion,

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