Compare commits

...

63 Commits

Author SHA1 Message Date
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
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
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
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
117 changed files with 8077 additions and 1560 deletions

View File

@ -1,16 +1,16 @@
## Summary ## 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 ## Checklist
- [ ] TypeScript type-check passes (`npm run type-check`) - [ ] Rust formatting/clippy/tests pass when backend code changed.
- [ ] Frontend builds (`npm run build`) - [ ] Frontend type-check/build/tests pass when frontend code changed.
- [ ] Tests pass (`npm test`) - [ ] App manifests validate when app packaging changed.
- [ ] Rust clippy clean (if backend changes) - [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
- [ ] No new compiler warnings - [ ] Docs are updated for user-facing or developer-facing behavior changes.
- [ ] Tested on live server - [ ] No secrets, generated build outputs, local screenshots, or private host details are included.

View File

@ -8,11 +8,11 @@ on:
env: env:
RUST_VERSION: stable RUST_VERSION: stable
NODE_VERSION: 18 NODE_VERSION: 20
jobs: jobs:
rust: rust:
name: Rust (fmt + clippy + test) name: Rust
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -28,17 +28,17 @@ jobs:
toolchain: ${{ env.RUST_VERSION }} toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy components: rustfmt, clippy
- name: Check formatting - name: Format
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Clippy - name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings run: cargo clippy --all-targets --all-features -- -D warnings
- name: Tests - name: Test
run: cargo test --all-features run: cargo test --all-features
frontend: frontend:
name: Frontend (type-check + lint) name: Frontend
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -52,14 +52,31 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'npm' cache: npm
cache-dependency-path: neode-ui/package-lock.json cache-dependency-path: neode-ui/package-lock.json
- name: Install dependencies - name: Install
run: npm ci run: npm ci
- name: Type check - name: Type check
run: npm run type-check run: npm run type-check
- name: Test
run: npm test
- name: Build - name: Build
run: npm run 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/ .ssh/
# Rust build output # Rust build output
target/ target/
**/target/ **/target/
Cargo.lock
# Node.js # Node.js
node_modules/ node_modules/
@ -12,7 +11,6 @@ node_modules/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
package-lock.json
pnpm-debug.log* pnpm-debug.log*
# Build outputs # Build outputs
@ -28,49 +26,46 @@ build/
*.swo *.swo
*~ *~
.DS_Store .DS_Store
._*
Thumbs.db
# Environment and local overrides # Environment and local overrides
.env .env
.env.local .env.local
.env.*.local .env.*.local
.env.production
core/.env.production
scripts/deploy-config.sh scripts/deploy-config.sh
# Logs # Logs
logs/ logs/
*.log *.log
# OS
.DS_Store
Thumbs.db
# Testing # Testing
coverage/ coverage/
.nyc_output/ .nyc_output/
# Temporary files # Image / release artifacts
*.tmp
*.temp
# Build artifacts
*.iso *.iso
*.img *.img
*.dmg *.dmg
*.app *.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/ !releases/
!releases/manifest.json !releases/manifest.json
# macOS build output
build/macos/
# Image recipe output # Image recipe output
image-recipe/output/ image-recipe/output/
image-recipe/*.iso image-recipe/*.iso
image-recipe/*.img image-recipe/*.img
# Loop tool artifacts (created in every subdirectory) # Loop tool artifacts
*/loop/ */loop/
loop/loop/ loop/loop/
loop/loop.log.bak loop/loop.log.bak
@ -78,19 +73,17 @@ loop/loop.log.bak
# Separate repos nested in tree # Separate repos nested in tree
web/ web/
._* # Resilience harness reports contain session cookies.
# Resilience harness reports (generated, contains session cookies)
scripts/resilience/reports/ scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups # Codex / pnpm / python caches / editor backups
.codex .codex
.codex-target-*/ .codex-target-*/
.codex-tmp/ .codex-tmp/
.claude/
.pnpm-store/ .pnpm-store/
**/__pycache__/ **/__pycache__/
*.bak *.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an # Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename. # app/docs asset path with a descriptive filename.

Binary file not shown.

View File

@ -197,6 +197,12 @@ private fun injectSafeAreaVars(view: WebView) {
* the status-bar height: the padded strip shows the page's OWN background * 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 * (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. * 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). */ * Idempotent; runs on start (early) and finish (after the app rewrites head). */
private fun injectTopInset(view: WebView) { private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return val insets = view.rootWindowInsets ?: return
@ -206,6 +212,7 @@ private fun injectTopInset(view: WebView) {
view.evaluateJavascript( view.evaluateJavascript(
""" """
(function() { (function() {
var SAT = $sat;
var s = document.getElementById('archy-top-inset'); var s = document.getElementById('archy-top-inset');
if (!s) { if (!s) {
s = document.createElement('style'); s = document.createElement('style');
@ -213,7 +220,40 @@ private fun injectTopInset(view: WebView) {
(document.head || document.documentElement).appendChild(s); (document.head || document.documentElement).appendChild(s);
} }
s.textContent = s.textContent =
'body{padding-top:${sat}px!important;box-sizing:border-box!important;}'; '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(), """.trimIndent(),
null, null,

View File

@ -1,5 +1,30 @@
# Changelog # Changelog
## 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) ## 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. - 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.

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 # 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/). ### Frontend
## 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)
```bash ```bash
cd neode-ui cd neode-ui
npm install npm install
npm start # Dev server on :8100 npm start
npm run type-check # TypeScript validation npm run type-check
npm run build # Production build npm test
npm test # Run tests
``` ```
### Backend (Rust) ### Backend
Build on a Linux server (Debian 13), **not** macOS:
```bash ```bash
cargo clippy --all-targets --all-features cd core
cargo fmt --all cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features 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 ```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 ## Code style
- 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
### 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 ## Pull requests
- `thiserror` for library errors, `anyhow` for application errors
- `tracing` for structured logging — never `println!`
- Run `cargo clippy` and `cargo fmt` before commits
### 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 Suggested commit format:
- Comment WHY not WHAT
- Remove dead code — never comment it out
- No `TODO`/`FIXME` in commits
## Commit Format ```text
feat: add backup scheduling
``` fix: reject unsafe manifest volume
type: description docs: clarify app deployment flow
test: cover catalog drift check
``` ```
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:` ## Reporting bugs
Examples: Include:
- `feat: add backup scheduling to settings page`
- `fix: handle WiFi connection timeout gracefully`
- `test: add unit tests for RPC client retry logic`
## 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` ## Security
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
### PR Checklist Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
- [ ] 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.
## License ## 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 # 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/) [![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) [![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/) [![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)]() [![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. ## Platform model
- **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.
## Features Archipelago is built as a developer-ready app platform, not a fixed appliance:
### Bitcoin Infrastructure - Apps are declared in `apps/<app-id>/manifest.yml`.
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size - The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
- **LND** and **Core Lightning** with channel management - The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
- **ElectrumX** Electrum server for wallet connectivity - App data lives under `/var/lib/archipelago/<app-id>/`.
- **BTCPay Server** for accepting Bitcoin payments - Secrets are generated or read from `/var/lib/archipelago/secrets/` and
- **Mempool** block explorer and fee estimator injected through Podman secrets rather than static environment values.
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support - Release and app catalogs are signed and verified against a pinned trust
anchor.
### Self-Hosted Apps (50+) Start with:
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.
### Mesh Networking (tri-protocol) - [Architecture](docs/architecture.md)
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI - [Developer Guide](docs/developer-guide.md)
- End-to-end encryption with X3DH key agreement + double-ratchet - [App Developer Guide](docs/app-developer-guide.md)
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband - [App Manifest Spec](docs/app-manifest-spec.md)
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh - [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
- [Operations Runbook](docs/operations-runbook.md)
- [Troubleshooting](docs/troubleshooting.md)
### Decentralized Identity ## Quick start
- 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
### Multi-Node Federation ### Frontend
- 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
```bash ```bash
cd neode-ui cd neode-ui
npm install npm install
npm start # Dev server on http://localhost:8100 (mock backend on :5959) npm start
npm run type-check # TypeScript validation
npm run build # Production build → web/dist/neode-ui/
``` ```
### Backend Development The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
### Backend
```bash ```bash
cd core # Rust workspace root (no Cargo.toml at repo root) cd core
cargo build 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 ```bash
./scripts/deploy-to-target.sh --live # Deploy to primary dev server ./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers 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 ## Documentation map
`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
| Doc | Purpose | | Doc | Purpose |
|-----|---------| |-----|---------|
| [Architecture](docs/architecture.md) | System design, crate map, data paths | | [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions | | [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
| [API Reference](docs/api-reference.md) | RPC endpoint reference | | [API Reference](docs/api-reference.md) | JSON-RPC API overview |
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps | | [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema | | [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide | | [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions | | [Apps README](apps/README.md) | Packaged app catalog overview |
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery | | [Image Recipe](image-recipe/README.md) | Bootable image build flow |
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative | | [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items | | [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) | | [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs | | [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
## Contributing ## Contributing
1. Fork the repository Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
2. Create a feature branch (`feature/description`) security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md) issue.
4. Submit a pull request
## License ## License
[MIT License](LICENSE) Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
are listed in [NOTICE](NOTICE) and generated license inventories in component
## Acknowledgments release artifacts.
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/)

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 ## 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 ## 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

52
core/Cargo.lock generated
View File

@ -84,6 +84,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 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]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.1"
@ -95,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.116-alpha" version = "1.7.117-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
@ -161,6 +170,7 @@ dependencies = [
"uuid", "uuid",
"zbase32", "zbase32",
"zeroize", "zeroize",
"zip",
] ]
[[package]] [[package]]
@ -1175,6 +1185,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" 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]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@ -6895,8 +6916,37 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.16" version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" 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] [package]
name = "archipelago" name = "archipelago"
version = "1.7.116-alpha" version = "1.7.118-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]
@ -108,6 +108,10 @@ bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios) # Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1" 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) # Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
hkdf = "0.12.4" hkdf = "0.12.4"

View File

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

View File

@ -279,6 +279,7 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did) .header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.send_get() .send_get()
.await .await
.context("Failed to connect to peer")?; .context("Failed to connect to peer")?;
@ -364,6 +365,11 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content") crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30)) .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() .send_get()
.await .await
.context("Failed to connect to peer")?; .context("Failed to connect to peer")?;
@ -1137,6 +1143,7 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get() .send_get()
.await .await
.context("Failed to connect to peer for preview")?; .context("Failed to connect to peer for preview")?;

View File

@ -391,7 +391,12 @@ impl RpcHandler {
// Mesh networking (Meshcore LoRa) // Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await, "mesh.status" => self.handle_mesh_status().await,
"mesh.probe-device" => self.handle_mesh_probe_device(params).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.peers" => self.handle_mesh_peers().await,
"mesh.refresh" => self.handle_mesh_refresh().await,
"mesh.messages" => self.handle_mesh_messages(params).await, "mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await, "mesh.debug-dump" => self.handle_mesh_debug_dump().await,
"mesh.send" => self.handle_mesh_send(params).await, "mesh.send" => self.handle_mesh_send(params).await,

View File

@ -865,7 +865,9 @@ impl RpcHandler {
"/rpc/v1", "/rpc/v1",
) )
.service(crate::settings::transport::PeerService::Peers) .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 { match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => { Ok((resp, transport)) if resp.status().is_success() => {

View File

@ -13,7 +13,14 @@ use anyhow::Result;
impl RpcHandler { impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> { pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await; 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 /// Everything the companion app needs to join this node's mesh, embedded
@ -83,7 +90,8 @@ impl RpcHandler {
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> { pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir); let identity_dir = fips::identity_dir_from(&self.config.data_dir);
fips::config::install(&identity_dir).await?; 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; let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?) Ok(serde_json::to_value(status)?)
} }

View File

@ -198,8 +198,7 @@ impl RpcHandler {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.and_then(|arr| { .and_then(|arr| {
arr.iter().find(|p| { arr.iter().find(|p| {
p.get("payment_hash").and_then(|v| v.as_str()) p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
== Some(hash_lower.as_str())
}) })
}); });

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 super::super::RpcHandler;
use crate::mesh; use crate::mesh;
use anyhow::Result; use anyhow::Result;
use std::sync::Arc;
use tracing::info; use tracing::info;
impl RpcHandler { impl RpcHandler {
@ -131,7 +132,14 @@ impl RpcHandler {
config.broadcast_identity = broadcast; config.broadcast_identity = broadcast;
} }
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) { 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 if let Some(announce) = params
.get("announce_block_headers") .get("announce_block_headers")
@ -202,10 +210,24 @@ impl RpcHandler {
mesh::save_config(&self.config.data_dir, &config).await?; mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config // Apply to the running service in the background: configure() may
let mut service = self.mesh_service.write().await; // stop+start the listener (config changes now restart the session so
if let Some(svc) = service.as_mut() { // they actually take effect), and that can take seconds when the old
svc.configure(config.clone()).await?; // 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"); info!("Mesh config updated");

View File

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

View File

@ -101,15 +101,61 @@ impl RpcHandler {
detected.iter().any(|d| d == &path), detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port" "{path} is not a detected mesh-radio candidate port"
); );
let service = self.mesh_service.read().await; // Refuse to probe while a firmware flash is in flight. Confirmed
let probe = match service.as_ref() { // live 2026-07-23: esptool ("multiple access on port?") and
Some(svc) => svc.probe_device(&path).await?, // rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
// No mesh service yet (radio never enabled) — probe directly. // failed with symptoms consistent with a second process holding the
None => mesh::listener::probe_device(&path).await?, // 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)?) 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. /// mesh.peers — List discovered mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> { pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await; 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) let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let has_tor = peer_pubkey_hex let fed_node = nodes.iter().find(|n| {
.as_ref() peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk)) || peer_did.as_deref() == Some(n.did.as_str())
.unwrap_or(false) });
|| peer_did let has_tor = fed_node.is_some();
.as_ref() // Distinct FIPS capability so the frontend can offer FIPS as its own
.map(|d| nodes.iter().any(|n| &n.did == d)) // pill (not just a generic "Tor") — the dial layer still picks
.unwrap_or(false); // 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); 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, "tier": tier,
"est_seconds": est_seconds, "est_seconds": est_seconds,
"has_tor": has_tor, "has_tor": has_tor,
"has_fips": has_fips,
"last_transport": last_transport,
"reason": reason, "reason": reason,
"size": size, "size": size,
"mesh_auto_max": MESH_AUTO_MAX, "mesh_auto_max": MESH_AUTO_MAX,
@ -820,6 +825,8 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path) crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
.service(crate::settings::transport::PeerService::MeshFileSharing) .service(crate::settings::transport::PeerService::MeshFileSharing)
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.record_transport(&self.config.data_dir)
.send_get() .send_get()
.await .await
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?; .map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;

View File

@ -89,6 +89,9 @@ pub struct RpcHandler {
endpoint_rate_limiter: EndpointRateLimiter, endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache, response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>, 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>>>>, transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
/// Shared content-addressed blob store. Set by ApiHandler after construction /// Shared content-addressed blob store. Set by ApiHandler after construction
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a /// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
@ -160,6 +163,7 @@ impl RpcHandler {
endpoint_rate_limiter, endpoint_rate_limiter,
response_cache: ResponseCache::new(5), response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)), 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)), transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: 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)), self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),

View File

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

View File

@ -133,6 +133,7 @@ impl RpcHandler {
Some(node_id.signing_key()), Some(node_id.signing_key()),
recipient_pubkey.as_deref(), recipient_pubkey.as_deref(),
node_name.as_deref(), node_name.as_deref(),
Some(&self.config.data_dir),
) )
.await?; .await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion })) 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) Ok(true)
} }
/// Best-effort: install fips.yaml + start archipelago-fips.service after the /// 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 /// seed onboarding has written the fips_key to disk. Runs in a detached task so
/// so the user-facing RPC returns immediately — the systemctl calls can take /// the user-facing RPC returns immediately — the systemctl calls can take a few
/// a few seconds the first time on slow hardware. Any failure is logged but /// seconds the first time on slow hardware. Any failure is logged but does not
/// does not break onboarding; the user can still hit fips.install manually /// break onboarding; the user can still hit fips.install manually from the
/// from the dashboard as an escape hatch. /// dashboard as an escape hatch.
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) { fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {
let identity_dir = data_dir.join("identity"); 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); tracing::warn!("post-onboarding fips config install failed: {}", e);
return; return;
} }
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await { let unit = crate::fips::service::activation_unit().await;
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e); if let Err(e) = crate::fips::service::activate(unit).await {
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
return; 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. // Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?; crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// fips_key is now on disk — auto-activate archipelago-fips so the // fips_key is now on disk — auto-activate FIPS so the user doesn't
// user doesn't have to hit an "Activate" button. Detached task; // have to hit a manual Start button. Detached task;
// the onboarding RPC returns immediately. // the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone()); spawn_post_onboarding_fips_activate(self.config.data_dir.clone());

View File

@ -61,6 +61,28 @@ impl RpcHandler {
info!("Server name updated to: {}", name); 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 // Push the new name to federation peers in background
let data_dir = self.config.data_dir.clone(); let data_dir = self.config.data_dir.clone();
let state_manager = self.state_manager.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", "/rpc/v1",
) )
.service(crate::settings::transport::PeerService::Peers) .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 { match req.send_json(&payload).await {
Ok((_, transport)) => { Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change") 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 { if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await; let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
if nginx_src.exists() { if nginx_src.exists() {

View File

@ -22,8 +22,10 @@
//! single declarative call. //! single declarative call.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use tokio::fs; use tokio::fs;
use tokio::process::Command; use tokio::process::Command;
use tracing::{info, warn}; use tracing::{info, warn};
@ -35,6 +37,15 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15); const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900); const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300); const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
/// After a failed repair (image build/pull included), leave the companion
/// alone for this long. Without it, a node under IO pressure retried a 900s
/// image build every 30s reconcile tick — each build pegging the disk that
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Static description of one companion. The full list per backend /// Static description of one companion. The full list per backend
/// app_id lives in `companions_for`. /// app_id lives in `companions_for`.
@ -464,12 +475,28 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
match needs_repair(spec).await { match needs_repair(spec).await {
Ok(false) => {} Ok(false) => {}
Ok(true) => { Ok(true) => {
if let Some(failed_at) =
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
{
if failed_at.elapsed() < REPAIR_COOLDOWN {
continue;
}
}
info!( info!(
companion = spec.name, companion = spec.name,
"reconcile: companion not active, repairing" "reconcile: companion not active, repairing"
); );
if let Err(e) = install_one(spec).await { match install_one(spec).await {
failures.push((spec.name.to_string(), e)); Ok(()) => {
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
}
Err(e) => {
REPAIR_FAILED_AT
.lock()
.unwrap()
.insert(spec.name, Instant::now());
failures.push((spec.name.to_string(), e));
}
} }
} }
Err(e) => { Err(e) => {
@ -484,19 +511,58 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
/// Does this companion need install_one to be re-run? Returns true if /// Does this companion need install_one to be re-run? Returns true if
/// the unit file is missing, stale, or the service is not active. /// the unit file is missing, stale, or the service is not active.
///
/// This probe runs every reconcile tick for every companion, so it must be
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
/// to render the expected unit — under IO pressure the image-existence check
/// inside timed out, read as "image missing", and a 900s `podman build` ran
/// inside the probe even though the companion was up (the .198 load spiral).
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> { async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
let dir = quadlet::unit_dir().await?; let dir = quadlet::unit_dir().await?;
let unit_path = dir.join(format!("{}.container", spec.name)); let unit_path = dir.join(format!("{}.container", spec.name));
if !fs::try_exists(&unit_path).await.unwrap_or(false) { if !fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(true); return Ok(true);
} }
let expected_image = ensure_image_present(spec).await?; let svc = format!("{}.service", spec.name);
let expected_unit = build_unit(spec, &expected_image); // A hung `systemctl is-active` under IO pressure must not read as
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() { // "companion dead" — that's a repair (and possibly an image build) fired
// off exactly when the node can least afford one.
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
Ok(active) => {
if !active {
return Ok(true);
}
}
Err(_) => {
warn!(
companion = spec.name,
"is-active probe timed out; assuming active"
);
}
}
// Service is running. Flag it stale only on definitive, cheap signals:
// the on-disk unit matching none of the image refs install_one could
// have written, or a local build context newer than the built image.
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
let local_image = format!("localhost/{}:latest", spec.image_base);
let local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
let matches_known_shape = [&local_image, &local_image_compat, &registry_image]
.iter()
.any(|img| build_unit(spec, img).render() == on_disk);
if !matches_known_shape {
return Ok(true); return Ok(true);
} }
let svc = format!("{}.service", spec.name); if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
Ok(!quadlet::is_active(&svc).await) for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// Conservative on any timeout/error inside: reuse the cache.
return Ok(context_is_newer_than_image(dir, &local_image).await);
}
}
}
Ok(false)
} }
#[cfg(test)] #[cfg(test)]

View File

@ -212,9 +212,21 @@ impl DockerPackageScanner {
website: lan_address.clone(), website: lan_address.clone(),
tier: Some(metadata.tier.to_string()), tier: Some(metadata.tier.to_string()),
interfaces: if lan_address.is_some() || tor_address.is_some() { interfaces: if lan_address.is_some() || tor_address.is_some() {
// `ui` is no longer implied by a published port: a
// headless backend with an exposed port is a service,
// not a launchable app. ui_detection consults the
// manifest declaration first, then HTTP-probes the
// port. Addresses stay present either way so the
// Services tab can still show where a backend lives.
let has_ui = super::ui_detection::has_web_ui(
&app_id,
lan_address.as_deref(),
package_state == PackageState::Running,
)
.await;
Some(Interfaces { Some(Interfaces {
main: Some(MainInterface { main: Some(MainInterface {
ui: Some("true".to_string()), ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(), tor_config: tor_address.clone(),
lan_config: None, lan_config: None,
}), }),
@ -729,7 +741,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently /// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
/// drops a reachable launch URL. Reading digits after the final colon mirrors /// drops a reachable launch URL. Reading digits after the final colon mirrors
/// `port_from_url` in the RPC layer and tolerates a trailing path. /// `port_from_url` in the RPC layer and tolerates a trailing path.
fn launch_url_port(url: &str) -> Option<u16> { pub(crate) fn launch_url_port(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1; let after_colon = url.rsplit_once(':')?.1;
after_colon after_colon
.chars() .chars()

View File

@ -15,6 +15,7 @@ pub mod quadlet;
pub mod registry; pub mod registry;
pub mod secrets; pub mod secrets;
pub mod traits; pub mod traits;
pub mod ui_detection;
pub mod version_config; pub mod version_config;
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL}; pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};

View File

@ -0,0 +1,243 @@
//! Web-UI detection for discovered containers.
//!
//! The packages list used to mark every container that published a port (or
//! carried an onion address) as a UI app, which gave headless backends —
//! databases, media servers, self-deployed compose stacks — a Launch button.
//! UI-ness is decided here instead, for manifest apps and ad-hoc containers
//! alike:
//!
//! 1. A manifest that declares an `interfaces:` block is definitive: any
//! entry of `type: ui` means a browsable UI, a block without one means a
//! backend service. The signed catalog overlay is consulted before disk
//! manifests (catalog supremacy).
//! 2. Manifests without an `interfaces:` block (the overwhelming majority)
//! and manifest-less containers fall through to a short HTTP probe of the
//! launch port: an HTML page, a redirect, or a browser-auth wall means a
//! UI; JSON APIs, raw TCP protocols, and dead ports mean a service.
//!
//! Verdicts are cached — positives longer than negatives, since "no UI yet"
//! is often just an app that hasn't finished starting.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tracing::debug;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// A confirmed UI stays a UI — re-check rarely.
const POSITIVE_TTL: Duration = Duration::from_secs(15 * 60);
/// A "no UI" verdict may be a slow-starting app — re-check sooner.
const NEGATIVE_TTL: Duration = Duration::from_secs(2 * 60);
/// Decide whether `app_id` exposes a browsable web UI. `lan_address` is the
/// launch candidate already computed by the package scanner (host-published),
/// and `running` gates the probe: a stopped container can't answer, and a
/// dead-port verdict against it would poison the cache.
pub async fn has_web_ui(app_id: &str, lan_address: Option<&str>, running: bool) -> bool {
if let Some(declared) = manifest_declares_ui(app_id) {
return declared;
}
let Some(port) = lan_address.and_then(super::docker_packages::launch_url_port) else {
return false;
};
if !running {
// Serve a cached verdict if we have one, but never record one.
return cached_verdict(app_id, port).unwrap_or(false);
}
if let Some(v) = cached_verdict(app_id, port) {
return v;
}
let verdict = probe_port(port).await;
debug!(app_id, port, verdict, "web-UI probe");
cache_verdict(app_id, port, verdict);
verdict
}
// ─── Manifest declarations ───────────────────────────────────────────────
/// `Some(true)`: a manifest covers this app and declares a `type: ui`
/// interface. `Some(false)`: a manifest declares interfaces, none of them UI.
/// `None`: no manifest, or one without an `interfaces:` block — undeclared,
/// let the probe decide.
fn manifest_declares_ui(app_id: &str) -> Option<bool> {
// Catalog overlay first: disk manifests don't apply to catalog-covered
// apps, so the catalog must win where it speaks.
for (id, value) in super::app_catalog::catalog_manifest_values() {
if id == app_id {
if let Some(v) = declared_ui_in_value(&value) {
return Some(v);
}
break;
}
}
for path in disk_manifest_candidates(app_id) {
if !path.exists() {
continue;
}
match archipelago_container::AppManifest::from_file(&path) {
Ok(m) => {
if m.app.interfaces.is_empty() {
return None;
}
return Some(m.app.interfaces.values().any(|i| i.interface_type == "ui"));
}
// Malformed manifests are already reported by the orchestrator's
// loader; here they simply don't count as a declaration.
Err(_) => return None,
}
}
None
}
/// Same declaration logic against a catalog manifest carried as raw JSON.
fn declared_ui_in_value(manifest: &serde_json::Value) -> Option<bool> {
let interfaces = manifest.get("app")?.get("interfaces")?.as_object()?;
if interfaces.is_empty() {
return None;
}
Some(interfaces.values().any(|i| {
// An omitted `type` defaults to "ui", mirroring the YAML schema.
i.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "ui")
.unwrap_or(true)
}))
}
fn disk_manifest_candidates(app_id: &str) -> Vec<std::path::PathBuf> {
let mut roots: Vec<std::path::PathBuf> = Vec::new();
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
let v = v.trim();
if !v.is_empty() {
roots.push(v.into());
}
}
roots.push("/opt/archipelago/apps".into());
roots
.into_iter()
.map(|root| root.join(app_id).join("manifest.yml"))
.collect()
}
// ─── HTTP probe ──────────────────────────────────────────────────────────
async fn probe_port(port: u16) -> bool {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("static probe client")
});
match client.get(format!("http://127.0.0.1:{port}/")).send().await {
Ok(resp) => {
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth_challenge = resp
.headers()
.contains_key(reqwest::header::WWW_AUTHENTICATE);
ui_verdict(resp.status().as_u16(), content_type, auth_challenge)
}
// Connection refused, timeout, or a non-HTTP protocol on the port.
Err(_) => false,
}
}
/// The pure classification rule, split out for tests: a browsable UI is an
/// HTML response (any status — error pages included), a redirect (login
/// flows), or a browser auth prompt. JSON/plaintext APIs are services.
fn ui_verdict(status: u16, content_type: &str, auth_challenge: bool) -> bool {
if content_type.contains("text/html") {
return true;
}
if (300..400).contains(&status) {
return true;
}
if (status == 401 || status == 403) && auth_challenge {
return true;
}
false
}
// ─── Verdict cache ───────────────────────────────────────────────────────
fn cache() -> &'static Mutex<HashMap<String, (bool, Instant)>> {
static CACHE: OnceLock<Mutex<HashMap<String, (bool, Instant)>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn cached_verdict(app_id: &str, port: u16) -> Option<bool> {
let cache = cache().lock().ok()?;
let (verdict, at) = cache.get(&format!("{app_id}:{port}"))?;
let ttl = if *verdict { POSITIVE_TTL } else { NEGATIVE_TTL };
(at.elapsed() < ttl).then_some(*verdict)
}
fn cache_verdict(app_id: &str, port: u16, verdict: bool) {
if let Ok(mut cache) = cache().lock() {
cache.insert(format!("{app_id}:{port}"), (verdict, Instant::now()));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn html_is_ui_regardless_of_status() {
assert!(ui_verdict(200, "text/html; charset=utf-8", false));
assert!(ui_verdict(404, "text/html", false));
assert!(ui_verdict(401, "text/html", false));
}
#[test]
fn redirects_are_ui() {
assert!(ui_verdict(302, "", false));
assert!(ui_verdict(307, "text/plain", false));
}
#[test]
fn auth_walls_are_ui_only_with_challenge() {
assert!(ui_verdict(401, "text/plain", true));
assert!(!ui_verdict(401, "application/json", false));
}
#[test]
fn apis_and_plain_ports_are_services() {
assert!(!ui_verdict(200, "application/json", false));
assert!(!ui_verdict(200, "text/plain", false));
assert!(!ui_verdict(500, "", false));
}
#[test]
fn catalog_value_declaration() {
let ui = json!({"app": {"interfaces": {"main": {"type": "ui", "port": 80}}}});
assert_eq!(declared_ui_in_value(&ui), Some(true));
// Omitted type defaults to "ui", mirroring the YAML schema default.
let default_ty = json!({"app": {"interfaces": {"main": {"port": 80}}}});
assert_eq!(declared_ui_in_value(&default_ty), Some(true));
let api_only = json!({"app": {"interfaces": {"rpc": {"type": "api", "port": 80}}}});
assert_eq!(declared_ui_in_value(&api_only), Some(false));
// No interfaces block ⇒ undeclared, not "no UI".
let none = json!({"app": {"id": "x"}});
assert_eq!(declared_ui_in_value(&none), None);
let empty = json!({"app": {"interfaces": {}}});
assert_eq!(declared_ui_in_value(&empty), None);
}
#[test]
fn verdict_cache_roundtrip() {
cache_verdict("test-app", 1234, true);
assert_eq!(cached_verdict("test-app", 1234), Some(true));
assert_eq!(cached_verdict("test-app", 9999), None);
}
}

View File

@ -232,26 +232,32 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// leaving `anchor_connected=false` and every peer dial falling back to /// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout. /// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> { pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len()); // Concurrent, each connect hard-capped: the old serial loop waited
for anchor in anchors { // unbounded on every `sudo fipsctl connect`, so one hung subprocess
let out = Command::new("sudo") // stalled the whole apply — and the periodic anchor tick behind it,
.args([ // which is exactly when a wedged daemon most needs the re-apply.
"-n", let futs = anchors.iter().cloned().map(|anchor| async move {
"fipsctl", let out = tokio::time::timeout(
"connect", std::time::Duration::from_secs(15),
&anchor.npub, Command::new("sudo")
&anchor.address, .args([
&anchor.transport, "-n",
]) "fipsctl",
.output() "connect",
.await; &anchor.npub,
&anchor.address,
&anchor.transport,
])
.output(),
)
.await;
let result = match out { let result = match out {
Ok(o) if o.status.success() => ApplyResult { Ok(Ok(o)) if o.status.success() => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: true, ok: true,
message: String::from_utf8_lossy(&o.stdout).trim().to_string(), message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
}, },
Ok(o) => ApplyResult { Ok(Ok(o)) => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: false, ok: false,
message: format!( message: format!(
@ -260,11 +266,16 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
), ),
}, },
Err(e) => ApplyResult { Ok(Err(e)) => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: false, ok: false,
message: format!("sudo fipsctl launch failed: {}", e), message: format!("sudo fipsctl launch failed: {}", e),
}, },
Err(_) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: "sudo fipsctl connect timed out after 15s".to_string(),
},
}; };
if result.ok { if result.ok {
tracing::debug!(npub = %result.npub, "Seed anchor applied"); tracing::debug!(npub = %result.npub, "Seed anchor applied");
@ -275,9 +286,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
"Seed anchor apply failed (non-fatal)" "Seed anchor apply failed (non-fatal)"
); );
} }
results.push(result); result
} });
results futures_util::future::join_all(futs).await
} }
/// Outcome of a single `fipsctl connect` call. /// Outcome of a single `fipsctl connect` call.
@ -290,12 +301,12 @@ pub struct ApplyResult {
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated /// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port. /// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
const FIPS_UDP_PORT: u16 = 8668; const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
/// Build transient seed-anchor entries that dial LAN-discovered federation peers /// Build transient seed-anchor entries that dial LAN-discovered federation peers
/// directly over their FIPS UDP transport. For each peer the registry knows both /// directly over their FIPS UDP transport. For each peer the registry knows both
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at /// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link /// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
/// instead of depending on the global anchor's spanning tree to route between /// instead of depending on the global anchor's spanning tree to route between
/// them (the cause of every dial falling back to Tor when the anchor link flaps). /// them (the cause of every dial falling back to Tor when the anchor link flaps).
/// ///
@ -448,4 +459,39 @@ mod tests {
assert_eq!(a.transport, "udp"); assert_eq!(a.transport, "udp");
assert_eq!(a.label, ""); assert_eq!(a.label, "");
} }
#[test]
fn lan_fips_anchor_port_matches_daemon_bind() {
// Drift guard: direct LAN anchors must dial the UDP port the
// generated fips.yaml actually binds. These were out of sync for
// months (anchors dialed 8668, the daemon bound 2121), making the
// whole direct-peering feature dial a dead port.
let yaml = crate::fips::config::render_config_yaml();
assert!(
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
);
}
#[test]
fn lan_fips_anchors_builds_direct_entry() {
let peer = crate::transport::PeerRecord {
did: "did:key:zpeer".to_string(),
lan_address: Some("192.168.63.198:5678".to_string()),
fips_npub: Some("npub1peer".to_string()),
..Default::default()
};
let out = lan_fips_anchors(&[peer]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
assert_eq!(out[0].transport, "udp");
// Peers missing either the LAN address or the npub produce nothing.
let no_npub = crate::transport::PeerRecord {
did: "did:key:zother".to_string(),
lan_address: Some("192.168.63.199:5678".to_string()),
..Default::default()
};
assert!(lan_fips_anchors(&[no_npub]).is_empty());
}
} }

View File

@ -6,39 +6,7 @@
//! no listener, so allowing them is inert. //! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[ pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
2342, 8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
3000, 18083, 23000, 32838, 50002,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8088,
8089,
8090,
8096,
8123,
8175,
8176,
8240,
8334,
8888,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
23000,
32838,
50002,
]; ];

View File

@ -240,11 +240,21 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
// framework-pt). Ship the allowance as a fips.d drop-in on every // framework-pt). Ship the allowance as a fips.d drop-in on every
// install/upgrade so no node ever regresses to a UI-less mesh. // install/upgrade so no node ever regresses to a UI-less mesh.
sudo_install_dir("/etc/fips/fips.d").await?; sudo_install_dir("/etc/fips/fips.d").await?;
let dropin = "# Written by archipelago on every daemon config install.\n\ // PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
# Allows the web UI + peer API through the fips0\n\ // mesh envelopes, DWN and invoices. It was missing from this allowlist
# default-deny inbound baseline (fips.nft).\n\ // while the comment claimed "web UI + peer API" — so every hardened
tcp dport 80 accept\n\ // node silently dropped peers' FIPS dials at the firewall and the whole
tcp dport 8443 accept\n"; // fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
let dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Allows the web UI + peer API through the fips0\n\
# default-deny inbound baseline (fips.nft).\n\
tcp dport 80 accept\n\
tcp dport 8443 accept\n\
tcp dport {peer_port} accept\n",
peer_port = crate::fips::dial::PEER_PORT
);
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id())); let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
tokio::fs::write(&nft_stage, dropin) tokio::fs::write(&nft_stage, dropin)
.await .await
@ -277,7 +287,10 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
app_install?; app_install?;
// Make the allowance live immediately; a no-op error when the // Make the allowance live immediately; a no-op error when the
// hardening baseline isn't installed on this node yet. // hardening baseline isn't installed on this node yet.
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) { if tokio::fs::try_exists("/etc/fips/fips.nft")
.await
.unwrap_or(false)
{
match Command::new("sudo") match Command::new("sudo")
.args(["nft", "-f", "/etc/fips/fips.nft"]) .args(["nft", "-f", "/etc/fips/fips.nft"])
.output() .output()

View File

@ -24,6 +24,7 @@
//! ``` //! ```
#![allow(dead_code)] #![allow(dead_code)]
use super::telemetry::{self, FallbackReason};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv6Addr}; use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration; use std::time::Duration;
@ -150,6 +151,12 @@ pub async fn warm_path(npub: &str) {
if !is_service_active().await { if !is_service_active().await {
return; return;
} }
warm_path_unchecked(npub).await
}
/// [`warm_path`] without the service-active check — for callers (the warm
/// tick) that already verified the daemon once for the whole batch.
pub async fn warm_path_unchecked(npub: &str) {
let Ok(base) = peer_base_url(npub).await else { let Ok(base) = peer_base_url(npub).await else {
return; return;
}; };
@ -276,21 +283,39 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
// ── High-level peer request helpers ──────────────────────────────────── // ── High-level peer request helpers ────────────────────────────────────
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
/// service state changes on human timescales, so 10s staleness is free.
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream) /// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
/// currently `systemctl is-active`? Async wrapper intended for the /// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
/// migration call sites; unlike `FipsTransport::is_available` this does /// concurrent refreshes are harmless (idempotent probe, last write wins).
/// not maintain a cache, so callers that poll frequently should cache
/// themselves.
pub async fn is_service_active() -> bool { pub async fn is_service_active() -> bool {
use std::sync::atomic::Ordering;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
return SERVICE_ACTIVE.load(Ordering::Relaxed);
}
let mut active = false;
for unit in [ for unit in [
crate::fips::SERVICE_UNIT, crate::fips::SERVICE_UNIT,
crate::fips::UPSTREAM_SERVICE_UNIT, crate::fips::UPSTREAM_SERVICE_UNIT,
] { ] {
if crate::fips::service::unit_state(unit).await == "active" { if crate::fips::service::unit_state(unit).await == "active" {
return true; active = true;
break;
} }
} }
false SERVICE_ACTIVE.store(active, Ordering::Relaxed);
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
active
} }
/// Builder for a peer request that may be sent over FIPS (preferred) or /// Builder for a peer request that may be sent over FIPS (preferred) or
@ -317,6 +342,11 @@ pub struct PeerRequest<'a> {
/// large content download needs so its long FIPS transfer isn't truncated. /// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>, pub fips_timeout: Option<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>, pub service: Option<crate::settings::transport::PeerService>,
/// When set, the transport that actually served this request is written
/// to federation storage (`record_peer_transport`, matched by onion) so
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
} }
impl<'a> PeerRequest<'a> { impl<'a> PeerRequest<'a> {
@ -329,6 +359,27 @@ impl<'a> PeerRequest<'a> {
timeout: std::time::Duration::from_secs(30), timeout: std::time::Duration::from_secs(30),
fips_timeout: None, fips_timeout: None,
service: None, service: None,
record_data_dir: None,
}
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
self.record_data_dir = Some(data_dir.into());
self
}
fn spawn_record(&self, kind: crate::transport::TransportKind) {
if let Some(dir) = &self.record_data_dir {
let dir = dir.clone();
let onion = self.onion_host.to_string();
let transport = kind.to_string();
tokio::spawn(async move {
let _ =
crate::federation::record_peer_transport(&dir, None, Some(&onion), &transport)
.await;
});
} }
} }
@ -389,8 +440,22 @@ impl<'a> PeerRequest<'a> {
// fix (404 path-not-served / 5xx) and we're allowed to // fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back. // fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips)); return Ok((resp, crate::transport::TransportKind::Fips));
} }
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS POST {} answered but status triggers Tor fallback",
self.path
);
} }
None => { None => {
if pref == TransportPref::Fips { if pref == TransportPref::Fips {
@ -402,6 +467,7 @@ impl<'a> PeerRequest<'a> {
} }
} }
let resp = self.send_tor_post_json(body).await?; let resp = self.send_tor_post_json(body).await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor)) Ok((resp, crate::transport::TransportKind::Tor))
} }
@ -413,8 +479,22 @@ impl<'a> PeerRequest<'a> {
match self.try_fips_get().await? { match self.try_fips_get().await? {
Some(resp) => { Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips)); return Ok((resp, crate::transport::TransportKind::Fips));
} }
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS GET {} answered but status triggers Tor fallback",
self.path
);
} }
None => { None => {
if pref == TransportPref::Fips { if pref == TransportPref::Fips {
@ -426,6 +506,7 @@ impl<'a> PeerRequest<'a> {
} }
} }
let resp = self.send_tor_get().await?; let resp = self.send_tor_get().await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor)) Ok((resp, crate::transport::TransportKind::Tor))
} }
@ -434,67 +515,127 @@ impl<'a> PeerRequest<'a> {
body: &B, body: &B,
) -> Result<Option<reqwest::Response>> { ) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else { let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None); return Ok(None);
}; };
if !is_service_active().await { if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None); return Ok(None);
} }
let base = match peer_base_url(npub).await { let base = match peer_base_url(npub).await {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
tracing::debug!("FIPS resolve for {} failed: {}", npub, e); telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
return Ok(None); return Ok(None);
} }
}; };
let url = format!("{}{}", base, self.path); let url = format!("{}{}", base, self.path);
let c = client_with_timeout(self.fips_attempt_timeout()); let budget = self.fips_attempt_timeout();
// With an explicit fast-fail cap, halve the per-attempt client
// timeout so the one-retry path in send_with_retry fits inside the
// budget instead of silently doubling it ("fips_timeout(6s)" used
// to really mean ~12.6s). Without one (long streaming downloads),
// keep the full budget per attempt — the client timeout also
// governs body streaming and must not truncate a real transfer.
let per_attempt = if self.fips_timeout.is_some() {
budget / 2
} else {
budget
};
let c = client_with_timeout(per_attempt);
let mut rb = c.post(&url).json(body); let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers { for (k, v) in &self.headers {
rb = rb.header(*k, v); rb = rb.header(*k, v);
} }
match send_with_retry(rb).await { match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(r) => Ok(Some(r)), Ok(Ok(r)) => Ok(Some(r)),
Err(e) => { Ok(Err(e)) => {
tracing::debug!( telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} failed after retry: {}, falling back to Tor", "FIPS POST {} failed after retry: {}, falling back to Tor",
url, url,
e e
); );
Ok(None) Ok(None)
} }
Err(_) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
);
Ok(None)
}
} }
} }
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> { async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else { let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None); return Ok(None);
}; };
if !is_service_active().await { if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None); return Ok(None);
} }
let base = match peer_base_url(npub).await { let base = match peer_base_url(npub).await {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
tracing::debug!("FIPS resolve for {} failed: {}", npub, e); telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
return Ok(None); return Ok(None);
} }
}; };
let url = format!("{}{}", base, self.path); let url = format!("{}{}", base, self.path);
let c = client_with_timeout(self.fips_attempt_timeout()); let budget = self.fips_attempt_timeout();
// Same budget discipline as the POST path: halve per attempt only
// under an explicit fast-fail cap; hard-cap the retry sequence.
let per_attempt = if self.fips_timeout.is_some() {
budget / 2
} else {
budget
};
let c = client_with_timeout(per_attempt);
let mut rb = c.get(&url); let mut rb = c.get(&url);
for (k, v) in &self.headers { for (k, v) in &self.headers {
rb = rb.header(*k, v); rb = rb.header(*k, v);
} }
match send_with_retry(rb).await { match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(r) => Ok(Some(r)), Ok(Ok(r)) => Ok(Some(r)),
Err(e) => { Ok(Err(e)) => {
tracing::debug!( telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} failed after retry: {}, falling back to Tor", "FIPS GET {} failed after retry: {}, falling back to Tor",
url, url,
e e
); );
Ok(None) Ok(None)
} }
Err(_) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
);
Ok(None)
}
} }
} }

View File

@ -31,6 +31,7 @@ pub mod config;
pub mod dial; pub mod dial;
pub mod iface; pub mod iface;
pub mod service; pub mod service;
pub mod telemetry;
pub mod update; pub mod update;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -54,7 +55,8 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e); tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
return; return;
} }
if let Err(e) = service::activate(SERVICE_UNIT).await { let unit = service::activation_unit().await;
if let Err(e) = service::activate(unit).await {
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e); tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
return; return;
} }
@ -78,25 +80,71 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) { pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25)); let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
// Connectivity watcher state: re-apply seed anchors the moment the
// anchor link drops (edge) or the data path degrades (dials keep
// failing with zero successes), instead of waiting for the 300s
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
let mut prev_connected: Option<bool> = None;
let mut prev_totals = telemetry::totals();
let mut last_apply: Option<std::time::Instant> = None;
loop { loop {
tick.tick().await; tick.tick().await;
// Bring FIPS up on its own once onboarding has materialised the key. // Bring FIPS up on its own once onboarding has materialised the key.
ensure_activated(&data_dir).await; ensure_activated(&data_dir).await;
if !dial::is_service_active().await { if !dial::is_service_active().await {
prev_connected = None; // daemon restart = fresh edge detection
continue; continue;
} }
// ── Warm the union of federation peers + configured seed
// anchors. Warming only federation npubs left the direct
// anchors (vps2, LAN peers) to go cold between 300s ticks.
let nodes = crate::federation::load_nodes(&data_dir) let nodes = crate::federation::load_nodes(&data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let seed = anchors::load(&data_dir).await.unwrap_or_default();
let mut warm_npubs: std::collections::BTreeSet<String> =
nodes.iter().filter_map(|n| n.fips_npub.clone()).collect();
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
let mut handles = Vec::new(); let mut handles = Vec::new();
for node in nodes { for npub in warm_npubs {
if let Some(npub) = node.fips_npub.clone() { // Service-active was checked once above for the whole batch.
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await })); handles.push(tokio::spawn(async move {
} dial::warm_path_unchecked(&npub).await
}));
} }
for h in handles { for h in handles {
let _ = h.await; let _ = h.await;
} }
// ── Connectivity watcher: detect anchor-link loss AND silent
// data-path death (daemon reports "connected" but every dial
// connect-fails — observed live on .198, 2026-07-27, where the
// 300s tick never healed it).
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
let totals = telemetry::totals();
let link_dropped = prev_connected == Some(true) && !connected;
let never_connected = prev_connected.is_none() && !connected;
let data_path_dead =
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
prev_connected = Some(connected);
prev_totals = totals;
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
{
tracing::info!(
link_dropped,
never_connected,
data_path_dead,
"FIPS connectivity degraded — re-applying seed anchors now"
);
last_apply = Some(std::time::Instant::now());
let _ = anchors::apply(&seed).await;
}
} }
}); });
} }

View File

@ -32,6 +32,16 @@ pub async fn unit_state(unit: &str) -> String {
} }
} }
/// Whether systemd knows about `unit`.
pub async fn unit_exists(unit: &str) -> bool {
Command::new("systemctl")
.args(["cat", unit])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
}
/// Whether the `fips` debian package is installed on the host. /// Whether the `fips` debian package is installed on the host.
pub async fn package_installed() -> bool { pub async fn package_installed() -> bool {
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present. // dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
@ -131,17 +141,28 @@ done
Ok(()) Ok(())
} }
/// Resolve which systemd unit is actually supervising the fips daemon /// Resolve which systemd unit should be started when FIPS is inactive.
/// on this host. Nodes installed from the archipelago ISO run /// Newer Archipelago images may ship `archipelago-fips.service`; nodes with
/// `archipelago-fips.service`; nodes that were apt-installed (or had /// the upstream Debian package may only have `fips.service`. Activation must
/// fips running before archipelago took over) may only have the /// choose a unit systemd can actually load, otherwise the dashboard repeatedly
/// upstream `fips.service`. Restart/Reconnect must operate on whichever /// offers an "Activate" action that can never succeed.
/// one is running, otherwise the UI button is a silent no-op. pub async fn activation_unit() -> &'static str {
if unit_exists(super::SERVICE_UNIT).await {
return super::SERVICE_UNIT;
}
if unit_exists(super::UPSTREAM_SERVICE_UNIT).await {
return super::UPSTREAM_SERVICE_UNIT;
}
super::SERVICE_UNIT
}
/// Resolve which systemd unit is actually supervising the fips daemon on this
/// host. Restart/Reconnect must operate on whichever one is running, otherwise
/// the UI button is a silent no-op.
/// ///
/// Returns the archipelago-managed unit name if it's active, /// Returns the archipelago-managed unit name if it's active,
/// else the upstream unit name if that's active, /// else the upstream unit name if that's active,
/// else the archipelago-managed name as a default (so activate() can /// else a startable activation unit.
/// bring it up).
pub async fn active_unit() -> &'static str { pub async fn active_unit() -> &'static str {
if unit_state(super::SERVICE_UNIT).await == "active" { if unit_state(super::SERVICE_UNIT).await == "active" {
return super::SERVICE_UNIT; return super::SERVICE_UNIT;
@ -149,7 +170,7 @@ pub async fn active_unit() -> &'static str {
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" { if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
return super::UPSTREAM_SERVICE_UNIT; return super::UPSTREAM_SERVICE_UNIT;
} }
super::SERVICE_UNIT activation_unit().await
} }
pub async fn mask(unit: &str) -> Result<()> { pub async fn mask(unit: &str) -> Result<()> {
@ -248,4 +269,10 @@ mod tests {
// Must not panic regardless of host state. // Must not panic regardless of host state.
let _ = package_installed().await; let _ = package_installed().await;
} }
#[tokio::test]
async fn test_unit_exists_is_bool() {
// Must not panic regardless of host state.
let _ = unit_exists("archipelago-bogus-test.service").await;
}
} }

View File

@ -0,0 +1,155 @@
//! In-process counters for FIPS dial outcomes.
//!
//! Every peer dial that could have used FIPS either succeeds over FIPS or
//! falls back to Tor for one of six reasons (F1F6). Before these counters
//! existed, fallbacks were `debug!`-only and invisible in production, which
//! made "FIPS uptime" unfalsifiable — several paths were 100% Tor for months
//! (dead ports, firewalled listeners, allowlist 404s) and nothing surfaced
//! it. The counters are process-lifetime (reset on restart) and exposed via
//! `fips.status` as `dial_stats`, so a fleet-wide fallback regression shows
//! up on the dashboard instead of as vague slowness.
use std::sync::atomic::{AtomicU64, Ordering};
/// Why a FIPS-capable dial fell back to Tor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FallbackReason {
/// F1 — no FIPS npub known for the peer (never meshed, or pre-npub
/// federation record). Expected for non-FIPS peers; high counts here
/// mean npub propagation is broken, not the transport.
NoNpub,
/// F2 — the local FIPS daemon service isn't active.
ServiceInactive,
/// F3 — the local FIPS DNS resolver couldn't resolve the peer's npub
/// (daemon up but peer not in the identity cache / mesh unreachable).
DnsFail,
/// F4 — TCP/HTTP dial to the peer's ULA failed or exceeded the FIPS
/// attempt budget (firewalled :5679, cold hole-punch, peer down).
ConnectFail,
/// F5 — peer answered over FIPS with 404: its listener doesn't serve
/// this path (older build / stricter allowlist).
Http404,
/// F6 — peer answered over FIPS with a 5xx server error.
Http5xx,
}
impl FallbackReason {
pub fn key(self) -> &'static str {
match self {
Self::NoNpub => "no_npub",
Self::ServiceInactive => "service_inactive",
Self::DnsFail => "dns_fail",
Self::ConnectFail => "connect_fail",
Self::Http404 => "http_404",
Self::Http5xx => "http_5xx",
}
}
}
static FIPS_OK: AtomicU64 = AtomicU64::new(0);
static NO_NPUB: AtomicU64 = AtomicU64::new(0);
static SERVICE_INACTIVE: AtomicU64 = AtomicU64::new(0);
static DNS_FAIL: AtomicU64 = AtomicU64::new(0);
static CONNECT_FAIL: AtomicU64 = AtomicU64::new(0);
static HTTP_404: AtomicU64 = AtomicU64::new(0);
static HTTP_5XX: AtomicU64 = AtomicU64::new(0);
fn counter(reason: FallbackReason) -> &'static AtomicU64 {
match reason {
FallbackReason::NoNpub => &NO_NPUB,
FallbackReason::ServiceInactive => &SERVICE_INACTIVE,
FallbackReason::DnsFail => &DNS_FAIL,
FallbackReason::ConnectFail => &CONNECT_FAIL,
FallbackReason::Http404 => &HTTP_404,
FallbackReason::Http5xx => &HTTP_5XX,
}
}
/// A dial completed over FIPS (any HTTP status that wasn't a fallback
/// trigger — the peer was reached on the mesh).
pub fn record_fips_ok() {
FIPS_OK.fetch_add(1, Ordering::Relaxed);
}
/// A FIPS-capable dial fell back to Tor.
pub fn record_fallback(reason: FallbackReason) {
counter(reason).fetch_add(1, Ordering::Relaxed);
}
/// `(fips_ok, connect_fail)` totals for the connectivity watcher: a window
/// where connect_fail grows while fips_ok doesn't is a degraded data path —
/// including the "daemon says connected but packets blackhole" failure the
/// link-state check alone can't see (observed live 2026-07-27 on .198).
pub fn totals() -> (u64, u64) {
(
FIPS_OK.load(Ordering::Relaxed),
CONNECT_FAIL.load(Ordering::Relaxed),
)
}
/// Snapshot for `fips.status` (`dial_stats`). Process-lifetime counts.
pub fn snapshot() -> serde_json::Value {
let f1 = NO_NPUB.load(Ordering::Relaxed);
let f2 = SERVICE_INACTIVE.load(Ordering::Relaxed);
let f3 = DNS_FAIL.load(Ordering::Relaxed);
let f4 = CONNECT_FAIL.load(Ordering::Relaxed);
let f5 = HTTP_404.load(Ordering::Relaxed);
let f6 = HTTP_5XX.load(Ordering::Relaxed);
serde_json::json!({
"fips_ok": FIPS_OK.load(Ordering::Relaxed),
"fallbacks": {
"no_npub": f1,
"service_inactive": f2,
"dns_fail": f3,
"connect_fail": f4,
"http_404": f5,
"http_5xx": f6,
"total": f1 + f2 + f3 + f4 + f5 + f6,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_counts_recorded_events() {
// Counters are global; assert deltas rather than absolutes so this
// test stays correct alongside any other test that dials.
let before = snapshot();
record_fips_ok();
record_fallback(FallbackReason::ConnectFail);
record_fallback(FallbackReason::Http404);
let after = snapshot();
let d = |v: &serde_json::Value, path: &[&str]| -> u64 {
let mut cur = v;
for p in path {
cur = &cur[p];
}
cur.as_u64().unwrap()
};
assert_eq!(d(&after, &["fips_ok"]) - d(&before, &["fips_ok"]), 1);
assert_eq!(
d(&after, &["fallbacks", "connect_fail"]) - d(&before, &["fallbacks", "connect_fail"]),
1
);
assert_eq!(
d(&after, &["fallbacks", "http_404"]) - d(&before, &["fallbacks", "http_404"]),
1
);
assert!(d(&after, &["fallbacks", "total"]) >= 2);
}
#[test]
fn reason_keys_are_stable() {
// These strings are the fips.status API surface — renaming one is a
// breaking change for the UI.
assert_eq!(FallbackReason::NoNpub.key(), "no_npub");
assert_eq!(FallbackReason::ServiceInactive.key(), "service_inactive");
assert_eq!(FallbackReason::DnsFail.key(), "dns_fail");
assert_eq!(FallbackReason::ConnectFail.key(), "connect_fail");
assert_eq!(FallbackReason::Http404.key(), "http_404");
assert_eq!(FallbackReason::Http5xx.key(), "http_5xx");
}
}

View File

@ -0,0 +1,992 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
//! three firmware families the mesh module already knows how to detect (see
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
//! flash time (never bundled/pinned in the repo), and every flash defaults
//! to a full chip erase before write.
//!
//! MeshCore and Meshtastic are flashed the same way: download a released
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
//! this family, so we deliberately don't resolve a firmware URL ourselves
//! for Reticulum; rnodeconf already knows how).
use super::serial::DetectedDeviceInfo;
use super::types::DeviceType;
use super::MeshService;
use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, OnceLock};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::{info, warn};
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
/// esptool target covers both), but ship different USB identities and
/// different per-board firmware assets upstream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashBoard {
HeltecV3,
HeltecV4,
}
impl FlashBoard {
/// Meshtastic's board id (matches the release manifest's `board` field
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
fn meshtastic_id(self) -> &'static str {
match self {
Self::HeltecV3 => "heltec-v3",
Self::HeltecV4 => "heltec-v4",
}
}
}
/// Map a detected USB vid:pid to a known flashable board, using the same
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
/// auto-match since that vid:pid is bridge-chip-specific.
///
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
/// with product string "USB JTAG/serial debug unit" — that descriptor is
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
/// board's image. Callers (the RPC layer / frontend) must let the user pick
/// the board manually whenever this returns `None`.
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
match (info.vid.as_deref(), info.pid.as_deref()) {
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashStage {
Downloading,
Erasing,
Writing,
Autoinstalling,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct FlashJobStatus {
pub board: FlashBoard,
pub family: DeviceType,
pub path: String,
pub stage: FlashStage,
pub percent: Option<u8>,
pub log_tail: Vec<String>,
pub done: bool,
pub error: Option<String>,
}
const LOG_TAIL_MAX: usize = 200;
/// How long to wait after a successful flash before resuming the mesh
/// listener, so the board finishes its own post-flash boot/reset before we
/// start opening the port (which itself toggles DTR/RTS) again.
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
/// Absolute ceiling on a whole flash job (download + erase + write, or
/// autoinstall), regardless of what it's doing internally. Last-resort
/// safety net so a hang anywhere can't wedge the single-flash-job guard
/// forever — generous enough to never trigger on a legitimately slow
/// multi-hundred-MB transfer.
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
/// How long to wait for MeshService::stop() to release the serial port
/// before giving up. Confirmed live 2026-07-23: the listener's own
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
/// between candidates, so stop() can take a while (or, if the loop is
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
/// cycle without leaving a flash request hanging indefinitely if the
/// listener genuinely won't let go.
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// How long to keep retrying the port-free check before giving up.
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Confirm nothing else has `path` open by actually opening (and immediately
/// closing) it ourselves. Retries across the timeout since a just-stopped
/// listener's fd can take a moment to actually release even after `stop()`
/// returns (task abort is a request, not an instant guarantee the OS-level
/// resource is gone yet).
async fn wait_for_port_free(path: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
let mut last_err = None;
loop {
match serial2_tokio::SerialPort::open(path, 115200) {
Ok(_) => return Ok(()),
Err(e) => last_err = Some(e),
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
last_err.map(|e| e.to_string()).unwrap_or_default()
))
}
/// Live state for the one flash job that can run at a time. A single global
/// slot is sufficient because flashing needs exclusive serial access to the
/// one port being flashed — there is no meaningful concept of two concurrent
/// flash jobs on this node.
pub struct FlashJob {
status: RwLock<FlashJobStatus>,
/// Set once the background task is spawned. Only used while `stage` is
/// still `Downloading` — an interrupted erase/write can leave the chip
/// in a worse state than either finished or unstarted, so cancellation
/// is refused once erase begins (see `cancel()`).
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
}
impl FlashJob {
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
Arc::new(Self {
abort_handle: RwLock::new(None),
status: RwLock::new(FlashJobStatus {
board,
family,
path,
stage: FlashStage::Downloading,
percent: None,
log_tail: Vec::new(),
done: false,
error: None,
}),
})
}
pub async fn snapshot(&self) -> FlashJobStatus {
self.status.read().await.clone()
}
async fn set_stage(&self, stage: FlashStage) {
let mut s = self.status.write().await;
s.stage = stage;
s.percent = None;
}
async fn set_percent(&self, percent: u8) {
self.status.write().await.percent = Some(percent.min(100));
}
async fn push_log(&self, line: impl Into<String>) {
let mut s = self.status.write().await;
s.log_tail.push(line.into());
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
if overflow > 0 {
s.log_tail.drain(0..overflow);
}
}
async fn fail(&self, err: &anyhow::Error) {
let mut s = self.status.write().await;
s.stage = FlashStage::Failed;
s.error = Some(format!("{err:#}"));
s.done = true;
}
async fn finish(&self) {
let mut s = self.status.write().await;
s.stage = FlashStage::Done;
s.done = true;
}
/// Best-effort cancel: only honored before erase/write/autoinstall has
/// started (i.e. still in `Downloading`). Once a stage that touches the
/// chip begins, this refuses — interrupting an erase or write can leave
/// the flash in a state worse than either finished or unstarted.
pub async fn cancel(&self) -> Result<()> {
let mut s = self.status.write().await;
if s.done {
anyhow::bail!("Flash job already finished");
}
if s.stage != FlashStage::Downloading {
anyhow::bail!(
"Cannot cancel once {:?} has started — let it finish or fail on its own",
s.stage
);
}
if let Some(handle) = self.abort_handle.write().await.take() {
handle.abort();
}
s.stage = FlashStage::Failed;
s.error = Some("Cancelled by user".to_string());
s.done = true;
Ok(())
}
}
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
pub fn new_job_handle() -> FlashJobHandle {
Arc::new(RwLock::new(None))
}
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
data_dir.join("mesh").join("firmware-cache")
}
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
/// the *entire* request including streaming the response body, which would
/// kill a legitimate large download partway through (Meshtastic's esp32s3
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
/// applies a per-chunk stall timeout, and metadata calls (small JSON
/// responses) get their own short timeout at the call site.
fn github_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("archipelago-mesh-flash")
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to build HTTP client")
}
/// Applied per-chunk while streaming a firmware download — if the transfer
/// stalls (no bytes for this long) it's treated as a failure, but a slow
/// download that's still making progress is never killed just for taking a
/// while.
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Applied to metadata calls (GitHub release JSON) — these are small
/// responses with no reason to ever take this long.
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// Resolve what firmware is available for a board+family. v1 only ever
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
/// resolves on its own" (it does its own version checking upstream).
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
match family {
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
DeviceType::Meshtastic => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Meshcore => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
}
}
#[derive(serde::Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
/// Start a flash job in the background. Returns as soon as the job has been
/// registered and the listener released — callers poll `FlashJobHandle` via
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
pub async fn start_flash_job(
handle: &FlashJobHandle,
mesh_service: &Arc<RwLock<Option<MeshService>>>,
data_dir: PathBuf,
path: String,
board: FlashBoard,
family: DeviceType,
) -> Result<()> {
{
let existing = handle.read().await;
if let Some(job) = existing.as_ref() {
if !job.snapshot().await.done {
anyhow::bail!("A firmware flash is already in progress on this node");
}
}
}
let job = FlashJob::new(board, family, path.clone());
*handle.write().await = Some(Arc::clone(&job));
let bg_job = Arc::clone(&job);
let bg_service = Arc::clone(mesh_service);
let task = tokio::spawn(async move {
// esptool/archy-rnodeconf need exclusive serial access — release
// the listener's hold on the port before touching it. This USED
// TO run synchronously in start_flash_job before the job was even
// spawned, blocking the RPC call itself on s.stop().await — a real
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
// reconnect/probe sequence that doesn't check its shutdown signal
// between candidates, so stop() never returned. The HTTP request
// timed out client-side ("Operation failed"), while the job
// (already inserted into `handle`) was permanently wedged — nothing
// had been spawned yet to ever mark it done, so every later flash
// attempt failed with "already in progress" until a full restart.
// Now this runs inside the spawned task with its own bounded
// timeout, so the RPC call always returns immediately regardless,
// and a slow-to-stop listener fails the job cleanly instead of
// hanging everything downstream of it forever.
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
s.stop().await;
}
})
.await;
if stop_result.is_err() {
let err = anyhow::anyhow!(
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
STOP_LISTENER_TIMEOUT.as_secs()
);
bg_job.push_log(format!("ERROR: {err:#}")).await;
bg_job.fail(&err).await;
return;
}
// Belt-and-suspenders port-free check. `stop()` above should have
// fully released the port, but esptool/rnodeconf run as external
// subprocesses for minutes outside our own async runtime — if
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
// fd from an aborted task, anything we haven't anticipated), handing
// the port to the flasher anyway risks exactly the corruption
// confirmed live 2026-07-23: esptool's "device disconnected or
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
// and definitive — before ever starting the flasher.
if let Err(e) = wait_for_port_free(&path).await {
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(&e).await;
return;
}
// Outer ceiling on top of run_flash's own internal timeouts —
// belt-and-suspenders so that no future hang (network, subprocess,
// anything) can ever wedge the single-flash-job guard permanently
// again the way a stuck download did on 2026-07-23 (every
// subsequent mesh.flash-device call failed with "already in
// progress" until the service was restarted). Generous enough that
// a legitimately slow multi-hundred-MB transfer still completes.
let result = match tokio::time::timeout(
MAX_JOB_DURATION,
run_flash(board, family, &data_dir, &path, &bg_job),
)
.await
{
Ok(inner) => inner,
Err(_) => Err(anyhow::anyhow!(
"Flash job exceeded the {}-minute ceiling — aborted",
MAX_JOB_DURATION.as_secs() / 60
)),
};
let succeeded = result.is_ok();
match &result {
Ok(()) => {
bg_job
.push_log("Flash completed successfully".to_string())
.await;
bg_job.finish().await;
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
}
Err(e) => {
// {:#} (alternate Display) walks the full anyhow context
// chain — plain {} / %e only prints the outermost .context()
// message, which made a real 2026-07-23 esptool failure
// undiagnosable from journalctl alone (just "esptool
// erase_flash failed", no actual esptool stderr).
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(e).await;
}
}
// The board's firmware may now differ from whatever was pinned
// before — clear the pin either way so a later reconnect's strict
// auto-detect order picks up reality instead of getting wedged
// trying the old protocol first.
if let Ok(mut config) = super::load_config(&data_dir).await {
config.device_kind = None;
if let Err(e) = super::save_config(&data_dir, &config).await {
warn!(error = %e, "Failed to clear device_kind pin after flash");
}
}
if !succeeded {
// Deliberately do NOT auto-restart the listener here. A failed
// flash means we can't vouch for the board's state — reopening
// the port immediately (esptool/rnodeconf's own reset sequence
// plus our open() toggling DTR/RTS again right after) risks
// hammering a marginal device with reconnect attempts. Confirmed
// live 2026-07-23: exactly this sequence left a real Heltec V3
// boot-looping for 5+ minutes after a failed flash. Leave mesh
// stopped; the user reconnects explicitly via the hot-swap
// modal/Mesh page once they've confirmed the board is alive.
warn!(
path = %path,
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
);
return;
}
// On success, give the board a moment to finish booting after the
// flash tool's own reset sequence before we start hammering it with
// connection attempts — same reasoning as above, just the
// lower-risk (successful-flash) side of it.
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
match super::load_config(&data_dir).await {
Ok(config) => {
// Only resume if mesh is actually still enabled per the
// CURRENT persisted config — confirmed live 2026-07-23:
// unconditionally forcing a restart here, regardless of
// `enabled`, overrode a user's own concurrent "disable
// mesh" toggle and left the listener running while
// config said disabled. That inconsistent state is what
// made a later legitimate "Keep As Is" click (which
// correctly tries to start on a false→true transition)
// fail with "already running" — the listener had already
// been force-started behind the config's back.
let should_run = config.enabled;
if let Err(e) = s.configure(config).await {
warn!(error = %e, "Failed to resume mesh listener after flash");
}
if should_run {
if let Err(e) = s.start() {
warn!(error = %e, "Failed to restart mesh listener after flash");
}
}
}
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
}
}
});
*job.abort_handle.write().await = Some(task.abort_handle());
Ok(())
}
async fn run_flash(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
path: &str,
job: &Arc<FlashJob>,
) -> Result<()> {
match family {
DeviceType::Meshtastic | DeviceType::Meshcore => {
let image = fetch_esptool_image(board, family, data_dir, job).await?;
esptool_erase_and_write(path, &image, job).await
}
DeviceType::Reticulum => {
let lora_region = super::load_config(data_dir)
.await
.ok()
.and_then(|c| c.lora_region);
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
}
}
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
async fn fetch_esptool_image(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let cache = firmware_cache_dir(data_dir);
tokio::fs::create_dir_all(&cache)
.await
.context("Creating firmware cache dir")?;
let client = github_client()?;
match family {
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
_ => anyhow::bail!("{family} is not flashed via esptool"),
}
}
async fn fetch_meshtastic_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.timeout(METADATA_TIMEOUT)
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
// zip rather than shipping per-board top-level assets — both Heltec V3
// and V4 are esp32s3, so this is the right zip for both (confirmed live
// against v2.7.26.54e0d8d).
let zip_asset = release
.assets
.iter()
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
let version = zip_asset
.name
.strip_prefix("firmware-esp32s3-")
.and_then(|s| s.strip_suffix(".zip"))
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
.to_string();
let zip_path = cache.join(&zip_asset.name);
if tokio::fs::metadata(&zip_path).await.is_err() {
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
} else {
job.push_log(format!("Using cached {}", zip_asset.name))
.await;
}
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
// partition table + app) meant to be written at offset 0x0 on a freshly
// erased chip — confirmed by inspecting the real zip's contents, as
// opposed to the plain "*.bin" OTA-update image which assumes an
// existing bootloader/partition table already on the chip.
let entry_name = format!("firmware-{}-{}.factory.bin", board.meshtastic_id(), version);
let out_path = cache.join(&entry_name);
if tokio::fs::metadata(&out_path).await.is_ok() {
return Ok(out_path);
}
job.push_log(format!("Extracting {entry_name} from {}", zip_asset.name))
.await;
let zip_path_owned = zip_path.clone();
let entry_name_owned = entry_name.clone();
let out_path_owned = out_path.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let file =
std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
let mut entry = archive
.by_name(&entry_name_owned)
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
let mut out =
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
Ok(())
})
.await
.context("Firmware extraction task panicked")??;
Ok(out_path)
}
async fn fetch_meshcore_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.timeout(METADATA_TIMEOUT)
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
// Upstream's casing differs between boards (Heltec_v3_... vs
// heltec_v4_...) — match case-insensitively on the exact per-board
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
// variants (there's a "_tft_" in between, so a straight substring match
// on "heltec_v4_companion_radio_usb" is already safe).
let needle = match board {
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
};
let asset = release
.assets
.iter()
.find(|a| {
let lower = a.name.to_lowercase();
lower.contains(needle) && lower.ends_with("-merged.bin")
})
.ok_or_else(|| {
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
})?;
let out_path = cache.join(&asset.name);
if tokio::fs::metadata(&out_path).await.is_ok() {
job.push_log(format!("Using cached {}", asset.name)).await;
return Ok(out_path);
}
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
Ok(out_path)
}
async fn download_to_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Downloading).await;
// Bound only the wait for the response to *start* (headers) — NOT a
// request-level `.timeout()`, which would cap the whole body transfer
// again (the bug this replaced: a blanket 30s client timeout killed
// large downloads mid-stream). If the server never responds at all,
// this is what stops the job from hanging forever; the per-chunk stall
// timeout below is what guards the body once streaming starts. Without
// this, a server that accepts the TCP connection but never sends
// headers back hangs this call indefinitely — confirmed live
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
// for good, permanently blocking every subsequent flash attempt with
// "already in progress" until the service was restarted.
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
.await
.context("Firmware download server did not respond")?
.context("Starting firmware download")?
.error_for_status()
.context("Firmware download returned an error status")?;
let total = resp.content_length();
let tmp = dest.with_extension("part");
let mut file = tokio::fs::File::create(&tmp)
.await
.context("Creating firmware download file")?;
let mut stream = resp.bytes_stream();
let mut downloaded: u64 = 0;
use futures_util::StreamExt;
loop {
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
.await
.context("Firmware download stalled")?;
let Some(chunk) = next else { break };
let chunk = chunk.context("Reading firmware download stream")?;
file.write_all(&chunk)
.await
.context("Writing firmware download")?;
downloaded += chunk.len() as u64;
if let Some(total) = total {
if total > 0 {
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
.await;
}
}
}
file.flush().await.ok();
tokio::fs::rename(&tmp, dest)
.await
.context("Finalizing firmware download")?;
job.push_log(format!(
"Downloaded {} ({downloaded} bytes)",
dest.display()
))
.await;
Ok(())
}
/// Both Heltec V3 and V4 are ESP32-S3 boards.
const ESPTOOL_CHIP: &str = "esp32s3";
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
/// board+adapter combinations — esptool's own docs recommend retrying at a
/// lower baud rate when this happens. Rather than fail the whole job on the
/// first hiccup, retry once at a conservative baud before giving up.
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
/// `write_flash --erase-all` erases the whole chip before writing, in one
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
/// esptool_global_args' doc comment) — without it, --erase-all hits the
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
/// does not support function erase_flash", confirmed live 2026-07-23), since
/// esptool's --erase-all is implemented as the same full-chip-erase command,
/// not a per-sector loop.
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
job.set_stage(FlashStage::Writing).await;
let image_str = image.to_string_lossy().to_string();
esptool_with_retry(
path,
&["write_flash", "--erase-all", "0x0", &image_str],
job,
)
.await
.context("esptool write_flash failed")?;
Ok(())
}
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
/// appending `--baud 115200` after the subcommand on the retry path
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
/// time, so the fallback-baud retry never actually got a chance to run.
/// Building global args separately from subcommand args keeps this correct
/// by construction instead of relying on call-site ordering.
///
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
/// fetches the exact same file from the matching upstream esptool release
/// tag and installs it alongside the apt package (see the esptool install
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
/// stub) was tried first and works for connecting, but the ROM bootloader
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
/// so --no-stub broke our "always erase before write" default outright
/// rather than just being slower. Restoring the real stub file is the
/// correct fix, not routing around its absence.
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
if let Some(b) = baud {
args.push("--baud");
args.push(b);
}
args
}
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
let mut cmd = Command::new("esptool");
cmd.args(esptool_global_args(path, None));
cmd.args(subcommand);
match run_streamed(cmd, None, job).await {
Ok(()) => Ok(()),
Err(first_err) => {
job.push_log(format!(
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
))
.await;
let mut retry = Command::new("esptool");
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
retry.args(subcommand);
run_streamed(retry, None, job)
.await
.context(format!("retry also failed (first attempt: {first_err:#})"))
}
}
}
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
fn rnodeconf_bin() -> String {
std::env::var("ARCHY_RNODECONF_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
}
/// True when `name` resolves to an executable on PATH.
fn which_on_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
let candidate = dir.join(name);
candidate.is_file()
})
})
.unwrap_or(false)
}
/// `--autoinstall`'s "which board is this" step is interactive by design —
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
/// (their bootstrap-time USB identity is often generic, same root cause as
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
/// sequence observed for a Heltec board that already has *some* RNode
/// firmware installed (the common case — a truly blank chip likely skips
/// straight to the same "Device Selection" menu):
/// 1. numbered device-type menu → answer with the menu number
/// 2. "Hit enter to continue" → answer with a blank line
/// 3. numbered band menu → answer with the menu number
/// 4. "Is the above correct? [y/N]" → answer "y"
/// Feeding all four answers up front (rather than watching stdout for each
/// prompt text) works because the menu is always asked in this fixed order
/// for every board that needs (re)provisioning — verified by driving it
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
/// bootstrap → "Device signature validated" on the next probe).
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
match board {
FlashBoard::HeltecV3 => "8",
FlashBoard::HeltecV4 => "9",
}
}
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
/// (868/915/923 MHz), not the final operating frequency — that's still
/// configured later via the daemon's interface config, same as today. This
/// is a best-effort mapping from the node's persisted Meshtastic-style
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
/// 915 MHz as the broadest-compatibility default.
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
match lora_region.map(|s| s.trim().to_uppercase()) {
Some(r) if r.contains("868") => "1",
Some(r) if r.contains("923") => "3",
_ => "2",
}
}
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
/// --help` AND a real end-to-end flash on real hardware) — this is the
/// RNode-side equivalent of our "always erase before write" default, since
/// autoinstall doesn't try to preserve any existing on-device state.
async fn rnodeconf_autoinstall(
path: &str,
board: FlashBoard,
lora_region: Option<&str>,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Autoinstalling).await;
let bin = rnodeconf_bin();
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else if which_on_path("rnodeconf") {
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command::new("rnodeconf")
} else {
// Older ISOs/OTAs never shipped the tool — say so instead of the
// bare "No such file or directory" the spawn would produce.
anyhow::bail!(
"{bin} is not installed on this node — RNode flashing needs the packaged \
archy-rnodeconf tool, which ships with the v1.7.118+ update (or can be \
sideloaded from a dev box). Update the node, then retry."
);
};
cmd.args(["--autoinstall", path]);
let stdin = format!(
"{}\n\n{}\ny\n",
rnodeconf_device_menu_number(board),
rnodeconf_band_menu_number(lora_region)
);
run_streamed(cmd, Some(stdin.into_bytes()), job)
.await
.context("archy-rnodeconf --autoinstall failed")
}
// ─── Subprocess streaming ────────────────────────────────────────────────
fn percent_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
}
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if stdin.is_some() {
cmd.stdin(Stdio::piped());
}
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
// the chip in a worse state than either finished or unstarted (see the
// cancellation-safety note in mesh flashing docs). The job is expected
// to run to completion or fail on its own.
let mut child = cmd.spawn().context("Failed to start subprocess")?;
if let Some(bytes) = stdin {
if let Some(mut child_stdin) = child.stdin.take() {
child_stdin
.write_all(&bytes)
.await
.context("Writing to subprocess stdin")?;
}
}
let mut tasks = Vec::new();
if let Some(stdout) = child.stdout.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(cap) = percent_regex().captures(&line) {
if let Ok(pct) = cap[1].parse::<u8>() {
job.set_percent(pct).await;
}
}
job.push_log(line).await;
}
}));
}
if let Some(stderr) = child.stderr.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
job.push_log(line).await;
}
}));
}
let status = child.wait().await.context("Waiting for subprocess")?;
for t in tasks {
let _ = t.await;
}
if !status.success() {
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
// stderr (already captured into job.log_tail by the reader tasks
// above) is what actually explains a failure. Confirmed live
// 2026-07-23: a bare "Command exited with exit status: 1" told us
// nothing when esptool's real error was sitting in the log tail the
// whole time, only visible via the UI's live poll, not journald.
let tail: Vec<String> = job
.snapshot()
.await
.log_tail
.iter()
.rev()
.take(10)
.rev()
.cloned()
.collect();
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
}
Ok(())
}

View File

@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received(
.insert(contact_id, shared_secret); .insert(contact_id, shared_secret);
// Update peer record // Update peer record
let peer = MeshPeer { let mut peer = MeshPeer {
contact_id, contact_id,
// .get(): a malformed DID shorter than the "did:key:" prefix must // .get(): a malformed DID shorter than the "did:key:" prefix must
// not panic the listener on a radio-supplied string. // not panic the listener on a radio-supplied string.
@ -607,6 +607,24 @@ pub(super) async fn handle_identity_received(
let is_new = { let is_new = {
let mut peers = state.peers.write().await; let mut peers = state.peers.write().await;
let is_new = !peers.contains_key(&contact_id); let is_new = !peers.contains_key(&contact_id);
if let Some(existing) = peers.get(&contact_id) {
// This id is shared with the federation-seeded row for the same
// node (that's the point — identity adverts MERGE, not duplicate).
// The wholesale insert below must not stomp the federation row's
// real node name with our synthetic "Archy-…" placeholder — with
// Reticulum re-emitting identity adverts every announce tick,
// that renamed every federated contact once a minute. Same for a
// known position: keep it rather than nulling it out.
if !existing.advert_name.trim().is_empty()
&& !existing.advert_name.starts_with("Archy-")
{
peer.advert_name = existing.advert_name.clone();
}
if peer.lat.is_none() {
peer.lat = existing.lat;
peer.lon = existing.lon;
}
}
peers.insert(contact_id, peer.clone()); peers.insert(contact_id, peer.clone());
is_new is_new
}; };

View File

@ -557,6 +557,10 @@ pub fn spawn_mesh_listener(
let mut shutdown = shutdown; let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx; let mut cmd_rx = cmd_rx;
let mut reconnect_delay = RECONNECT_DELAY_INIT; let mut reconnect_delay = RECONNECT_DELAY_INIT;
// Mutable so a successful auto-detect can pin the firmware kind for
// the rest of this listener's lifetime — see the pin-on-first-success
// block below for why.
let mut device_kind = device_kind;
// Backlog #12 hot-swap re-binding: each run_mesh_session call already // Backlog #12 hot-swap re-binding: each run_mesh_session call already
// builds a fresh device struct (contacts/current_region/etc. all // builds a fresh device struct (contacts/current_region/etc. all
// start empty), so per-device session state is naturally isolated // start empty), so per-device session state is naturally isolated
@ -628,6 +632,45 @@ pub fn spawn_mesh_listener(
} }
} }
// Pin the firmware kind after the first successful auto-detect.
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
// after clearing a stale pin), EVERY reconnect re-runs the full
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
// candidate past the first does its own open() with the DTR/RTS
// reset both boards need, so a device correctly identified as
// Meshtastic still gets reset once for the failed Meshcore
// attempt before Meshtastic's own open() resets it again. That
// doubled the reset count on every single reconnect indefinitely,
// not just during initial detection. Once auto-detect has
// identified the device this listener is actually talking to,
// there's no reason to keep guessing on subsequent reconnects —
// pin it, both in this task's own loop (takes effect
// immediately) and on disk (survives a service restart). A
// genuine hot-swap to different firmware is still handled: the
// setup modal's `mesh.probe-device` always re-probes unpinned,
// and the flash flow already clears this pin on its own.
if device_kind.is_none() {
let detected = state.status.read().await.device_type;
if detected != super::types::DeviceType::Unknown {
device_kind = Some(detected);
match super::load_config(&data_dir).await {
Ok(mut cfg) if cfg.device_kind.is_none() => {
cfg.device_kind = Some(detected);
if let Err(e) = super::save_config(&data_dir, &cfg).await {
warn!("Failed to persist auto-detected device_kind: {}", e);
} else {
info!(
kind = %detected,
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
);
}
}
Ok(_) => {}
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
}
}
}
// Update status to disconnected. device_type/firmware_version are // Update status to disconnected. device_type/firmware_version are
// reset too — they were previously left holding the LAST radio's // reset too — they were previously left holding the LAST radio's
// identity, so after a hot-swap the UI showed the old firmware // identity, so after a hot-swap the UI showed the old firmware

View File

@ -270,6 +270,7 @@ async fn auto_detect_and_open(
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>, device_kind: Option<DeviceType>,
skip_path: Option<&str>, skip_path: Option<&str>,
advert_name: Option<&str>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> { ) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut paths = super::super::serial::detect_serial_devices().await; let mut paths = super::super::serial::detect_serial_devices().await;
// When falling back from a just-failed preferred path, don't probe that // When falling back from a just-failed preferred path, don't probe that
@ -287,6 +288,7 @@ async fn auto_detect_and_open(
None => "No serial devices found in /dev".to_string(), None => "No serial devices found in /dev".to_string(),
}); });
} }
info!(candidates = ?paths, "Auto-detect candidate ports for this attempt");
for path in &paths { for path in &paths {
debug!(path = %path, "Probing for mesh radio device"); debug!(path = %path, "Probing for mesh radio device");
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon // Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
@ -304,6 +306,7 @@ async fn auto_detect_and_open(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
{ {
@ -465,6 +468,7 @@ async fn open_preferred_path(
our_ed_pubkey_hex: &str, our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>, device_kind: Option<DeviceType>,
advert_name: Option<&str>,
) -> Result<(MeshRadioDevice, DeviceInfo)> { ) -> Result<(MeshRadioDevice, DeviceInfo)> {
// Pinned: try only the configured firmware and surface its own error — // Pinned: try only the configured firmware and surface its own error —
// never fall through to (and inject probe bytes into) another firmware's // never fall through to (and inject probe bytes into) another firmware's
@ -497,6 +501,7 @@ async fn open_preferred_path(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
.context("Could not open preferred path as Reticulum")?; .context("Could not open preferred path as Reticulum")?;
@ -510,40 +515,21 @@ async fn open_preferred_path(
}; };
} }
// Reticulum first — see the matching comment on auto_detect_and_open: // Unpinned: don't probe this path ourselves at all. Confirmed live
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while // 2026-07-23 — this function used to run its own Reticulum→Meshcore→
// trying Meshcore/Meshtastic first was observed leaving a real RNode // Meshtastic sequence here, and the caller (run_mesh_session) falls
// board unresponsive by the time Reticulum's turn came. // back to `auto_detect_and_open` on any error, which scans every
match ReticulumLink::open( // candidate path (this one included) with the exact same three-protocol
path, // sequence. With a single physical radio — the overwhelmingly common
data_dir, // case — `path` here IS the one candidate `auto_detect_and_open` is
Some(our_ed_pubkey_hex), // about to try, so every unpinned reconnect was resetting the board via
Some(our_x25519_pubkey_hex), // Reticulum/Meshcore/Meshtastic's DTR/RTS toggle TWICE: once here, once
) // again moments later in auto-detect. Bailing immediately (no port
.await // access at all) means auto-detect's single pass is the only one that
{ // ever touches the port when nothing is pinned yet. (auto_detect_and_open
Ok(mut dev) => match dev.initialize().await { // carries the advert_name threading from main, so nothing is lost.)
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)), let _ = advert_name;
Err(e) => { anyhow::bail!("No device_kind pin — deferring to auto-detect for {path}")
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
}
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
}
} }
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no /// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
@ -556,6 +542,7 @@ async fn open_reticulum_tcp(
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: &str, our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
advert_name: Option<&str>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> { ) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut dev = match cfg { let mut dev = match cfg {
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server( ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
@ -563,6 +550,7 @@ async fn open_reticulum_tcp(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
.context("Could not open Reticulum TCP server interface")?, .context("Could not open Reticulum TCP server interface")?,
@ -571,6 +559,7 @@ async fn open_reticulum_tcp(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
.context("Could not open Reticulum TCP client interface")?, .context("Could not open Reticulum TCP client interface")?,
@ -987,49 +976,110 @@ pub(super) async fn run_mesh_session(
// auto-detect. TCP mode is additive/dev-only; it never changes behavior // auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None. // for existing serial/RNode deployments where `reticulum_tcp` is None.
// //
// The name we present on the mesh: the operator's configured mesh name /
// server name, falling back to a DID fragment. Computed BEFORE the open
// sequence because Reticulum needs it at daemon-spawn time — the RNS
// announce carries it from the very first announce. (Meshcore/Meshtastic
// still receive it via set_advert_name after connect, below.)
let desired_advert_name: String = match server_name {
// Meshcore firmware limits advert names — truncate to 20 chars.
Some(name) => name.chars().take(20).collect(),
None => format!(
"Archy-{}",
our_did.chars().skip(8).take(8).collect::<String>()
),
};
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe // The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
// can't interleave its own handshakes on the same tty (see the lock's // can't interleave its own handshakes on the same tty (see the lock's
// doc comment). Held only until the device is opened, then released. // doc comment). Held only until the device is opened, then released.
let port_guard = PORT_OPEN_LOCK.lock().await; //
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp { // The sequence is raced against the shutdown signal: probes/handshakes
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await? // can take 10s+, and without this a stop() issued mid-probe (config
} else if let Some(path) = preferred_path { // change, disable, rename) always burned the full listener-shutdown
match open_preferred_path( // timeout and ended in a hard abort — observed live on archi-dev-box
path, // 2026-07-28. Dropping the open future mid-probe is safe: it holds no
data_dir, // session state yet and the port guard/serial handle close with it.
our_ed_pubkey_hex, let open_fut = async {
our_x25519_pubkey_hex, let port_guard = PORT_OPEN_LOCK.lock().await;
device_kind, let result = if let Some(tcp_cfg) = &reticulum_tcp {
) open_reticulum_tcp(
.await tcp_cfg,
{ data_dir,
Ok((dev, info)) => (path.to_string(), dev, info), our_ed_pubkey_hex,
Err(e) => { our_x25519_pubkey_hex,
warn!( Some(&desired_advert_name),
"Preferred path {} probe failed: {} — trying auto-detect", )
path, e .await
); } else if let Some(path) = preferred_path {
if device_kind.is_none() {
// Unpinned: open_preferred_path bails without ever touching
// the port, so the preferred path has NOT been probed this
// cycle — auto-detect must keep it as a candidate
// (skip_path=None). Passing Some(path) here excluded the only
// radio on single-device nodes and the mesh never came up.
auto_detect_and_open( auto_detect_and_open(
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
device_kind, device_kind,
Some(path), None,
Some(&desired_advert_name),
) )
.await? .await
} else {
match open_preferred_path(
path,
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
Some(&desired_advert_name),
)
.await
{
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
Err(e) => {
warn!(
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open(
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
Some(path),
Some(&desired_advert_name),
)
.await
}
}
} }
} } else {
} else { auto_detect_and_open(
auto_detect_and_open( data_dir,
data_dir, our_ed_pubkey_hex,
our_ed_pubkey_hex, our_x25519_pubkey_hex,
our_x25519_pubkey_hex, device_kind,
device_kind, None,
None, Some(&desired_advert_name),
) )
.await? .await
};
drop(port_guard);
result
};
let (device_path, mut device, device_info) = tokio::select! {
res = open_fut => res?,
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Shutdown requested during device open — ending session");
return Ok(());
}
anyhow::bail!("shutdown signal changed during device open");
}
}; };
drop(port_guard);
// Update status // Update status
{ {
@ -1176,19 +1226,14 @@ pub(super) async fn run_mesh_session(
} }
} }
// Set advert name to the server's human-readable name (e.g. "ThinkPad"), // Set advert name to the configured mesh/server name (computed above).
// falling back to the DID fragment if no name is configured. Skipped in // Skipped in keep-as-is mode for radio-held names — the radio keeps the
// keep-as-is mode — the radio keeps the name it came with (already // name it came with (already reflected in status from the connect
// reflected in status from the connect handshake). // handshake). Reticulum is exempt from keep-as-is: its display name
if manage_radio { // lives in OUR daemon (the RNode holds no name), so "keep as is" has
let advert_name = if let Some(name) = server_name { // nothing to preserve and an unnamed node would be anonymous on RNS.
// Meshcore firmware limits advert names — truncate to 20 chars if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) {
name.chars().take(20).collect::<String>() if let Err(e) = device.set_advert_name(&desired_advert_name).await {
} else {
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
format!("Archy-{}", short_did)
};
if let Err(e) = device.set_advert_name(&advert_name).await {
warn!("Failed to set advert name: {}", e); warn!("Failed to set advert name: {}", e);
} else { } else {
// Reflect the post-set name in MeshStatus too so the UI can filter // Reflect the post-set name in MeshStatus too so the UI can filter
@ -1196,7 +1241,7 @@ pub(super) async fn run_mesh_session(
// still carries whatever pre-set name the firmware reported and the // still carries whatever pre-set name the firmware reported and the
// self-filter never matches. // self-filter never matches.
let mut status = state.status.write().await; let mut status = state.status.write().await;
status.self_advert_name = Some(advert_name.clone()); status.self_advert_name = Some(desired_advert_name.clone());
} }
} }
@ -1489,6 +1534,15 @@ async fn handle_send_command(
} else { } else {
*consecutive_write_failures = 0; *consecutive_write_failures = 0;
} }
// The self-advert alone is a no-op for discovery on Meshtastic
// (heartbeat + time carry no identity) — the NodeInfo broadcast
// is what makes peers learn/refresh us. want_response=true so
// neighbours answer with their own NodeInfo: the user pressed
// Broadcast to be seen AND to see who's out there. No-op on
// Meshcore/Reticulum, whose self-advert already carries identity.
if let Err(e) = device.send_nodeinfo_advert(true).await {
warn!("Failed to send NodeInfo advert: {}", e);
}
} }
MeshCommand::RebootRadio { seconds } => { MeshCommand::RebootRadio { seconds } => {
if let Err(e) = device.reboot(seconds).await { if let Err(e) = device.reboot(seconds).await {

View File

@ -8,6 +8,7 @@
pub mod alerts; pub mod alerts;
pub mod bitcoin_relay; pub mod bitcoin_relay;
pub mod crypto; pub mod crypto;
pub mod flash;
pub mod listener; pub mod listener;
pub mod meshtastic; pub mod meshtastic;
pub mod message_types; pub mod message_types;
@ -477,6 +478,31 @@ impl Default for MeshConfig {
} }
} }
/// Whether a mesh config file has ever been written for this node — lets the
/// boot path distinguish "operator explicitly disabled mesh" (file exists,
/// enabled=false) from "never configured" (no file), which is the only case
/// radio auto-enable should touch.
pub fn config_file_exists(data_dir: &Path) -> bool {
data_dir.join(MESH_CONFIG_FILE).exists()
}
/// True when `new` differs from `old` in any field a running mesh session
/// captured by value at spawn (device path/kind, advert name, region, PHY
/// params, channel, manage_radio, TCP interface) — i.e. when applying `new`
/// to a live service requires a listener restart. Fields the session reads
/// live from shared state (broadcast flags, assistant settings, steganography
/// mode, …) deliberately don't trigger a restart.
fn session_config_changed(old: &MeshConfig, new: &MeshConfig) -> bool {
old.device_path != new.device_path
|| old.device_kind != new.device_kind
|| old.advert_name != new.advert_name
|| old.lora_region != new.lora_region
|| old.lora_radio_params != new.lora_radio_params
|| old.channel_name != new.channel_name
|| old.manage_radio != new.manage_radio
|| old.reticulum_tcp != new.reticulum_tcp
}
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> { pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
let path = data_dir.join(MESH_CONFIG_FILE); let path = data_dir.join(MESH_CONFIG_FILE);
if !path.exists() { if !path.exists() {
@ -742,10 +768,18 @@ impl MeshService {
self.server_name = name; self.server_name = name;
} }
/// Start the background mesh listener. /// Start the background mesh listener. Idempotent: if the listener is
/// already running, this is a harmless no-op rather than an error —
/// confirmed live 2026-07-23, a real race between the flash job's own
/// post-flash restart and a concurrent user "Keep As Is" click (both
/// legitimately trying to ensure the listener is running) surfaced this
/// as a user-facing "Mesh listener already running" RPC error. Ensuring
/// the listener is running is the intent every caller actually has;
/// whichever caller's start() happens to win the race, the other
/// finding it already satisfied is success, not failure.
pub fn start(&mut self) -> Result<()> { pub fn start(&mut self) -> Result<()> {
if self.listener_handle.is_some() { if self.listener_handle.is_some() {
anyhow::bail!("Mesh listener already running"); return Ok(());
} }
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
@ -764,7 +798,14 @@ impl MeshService {
self.our_ed_pubkey_hex.clone(), self.our_ed_pubkey_hex.clone(),
self.our_x25519_secret, self.our_x25519_secret,
self.our_x25519_pubkey_hex.clone(), self.our_x25519_pubkey_hex.clone(),
self.server_name.clone(), // The mesh-page "Name on the mesh" (config.advert_name) wins over
// the server name — it existed as write-only config with no reader
// until this line, which is why renaming on the Mesh page never
// changed anything on the air.
self.config
.advert_name
.clone()
.or_else(|| self.server_name.clone()),
self.config.lora_region.clone(), self.config.lora_region.clone(),
self.config.lora_radio_params, self.config.lora_radio_params,
self.config.channel_name.clone(), self.config.channel_name.clone(),
@ -1055,13 +1096,12 @@ impl MeshService {
self.state.peers.read().await.values().cloned().collect() self.state.peers.read().await.values().cloned().collect()
} }
/// Probe a serial port for a mesh radio without provisioning or keeping /// Refuse to probe the port the live session currently occupies (the
/// it — powers the hot-swap "device detected" modal's current-details /// probe would steal the serial port from under the session); a
/// view. Refuses to probe the port the live session currently occupies
/// (the probe would steal the serial port from under the session); a
/// detected-but-not-connected port is fair game, accepting a benign race /// detected-but-not-connected port is fair game, accepting a benign race
/// with the reconnect loop (whichever loses just retries). /// with the reconnect loop (whichever loses just retries). Split out from
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> { /// the actual probe on purpose — see `probe_device`'s doc comment.
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
let status = self.state.status.read().await; let status = self.state.status.read().await;
if status.device_connected { if status.device_connected {
if let Some(active) = status.device_path.as_deref() { if let Some(active) = status.device_path.as_deref() {
@ -1076,8 +1116,7 @@ impl MeshService {
} }
} }
} }
drop(status); Ok(())
listener::probe_device(path).await
} }
/// Get message history. /// Get message history.
@ -1124,7 +1163,32 @@ impl MeshService {
let peer = peers let peer = peers
.get(&contact_id) .get(&contact_id)
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?; .ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
let pubkey_hex = peer // Cross-transport twin resolution: callers frequently hold the
// FEDERATION twin's contact_id (the UI's merged conversation row),
// whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
// routing key. Sending a Reticulum resource with that prefix fails
// with "Unknown Reticulum prefix" (observed live 2026-07-28,
// image-over-LoRa to a merged contact). Route via the radio twin —
// same arch identity, radio-range id — whose pubkey_hex is the
// actual over-the-air routing key (RNS dest hash / firmware key).
let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
peer.arch_pubkey_hex
.as_deref()
.and_then(|arch| {
peers.values().find(|p| {
p.contact_id < FEDERATION_CONTACT_ID_BASE
&& p.arch_pubkey_hex.as_deref() == Some(arch)
})
})
.ok_or_else(|| {
anyhow::anyhow!(
"Peer is federation-only (no radio twin) — not reachable over the radio"
)
})?
} else {
peer
};
let pubkey_hex = radio_peer
.pubkey_hex .pubkey_hex
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?; .ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
@ -1262,12 +1326,45 @@ impl MeshService {
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some()) .map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
.unwrap_or(false) .unwrap_or(false)
}; };
// Transport policy: LoRa first when it can actually carry the message,
// then FIPS, then Tor. A federation-synthetic id (what the UI's merged
// conversation holds) used to ALWAYS take the federation path, even
// when the very same node was sitting one LoRa hop away — so chats
// between two radio-equipped nodes silently rode FIPS/Tor. If the
// federation contact has a REACHABLE radio twin (same archipelago
// identity, radio-range id) and the payload fits the radio, skip the
// federation branch: the fall-through LoRa path twin-resolves the
// routing key via peer_dest_prefix.
let device_connected = self.state.status.read().await.device_connected;
let radio_twin_reachable =
is_federation_synthetic && !exceeds_lora && device_connected && {
let peers = self.state.peers.read().await;
peers
.get(&contact_id)
.and_then(|p| p.arch_pubkey_hex.clone())
.map(|arch| {
peers.values().any(|p| {
p.contact_id < FEDERATION_CONTACT_ID_BASE
&& p.reachable
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
})
})
.unwrap_or(false)
};
let mesh_only_mode = load_config(&self.data_dir) let mesh_only_mode = load_config(&self.data_dir)
.await .await
.ok() .ok()
.and_then(|cfg| cfg.mesh_only_mode) .and_then(|cfg| cfg.mesh_only_mode)
.unwrap_or(false); .unwrap_or(false);
if radio_twin_reachable && !mesh_only_mode {
tracing::info!(
contact_id,
bytes = wire.len(),
"Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
);
}
if !mesh_only_mode if !mesh_only_mode
&& !radio_twin_reachable
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable) && (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
{ {
// Resolve the peer's pubkey/did. Prefer the live mesh peer table, // Resolve the peer's pubkey/did. Prefer the live mesh peer table,
@ -2091,6 +2188,7 @@ impl MeshService {
save_config(&self.data_dir, &config).await?; save_config(&self.data_dir, &config).await?;
let was_enabled = self.config.enabled; let was_enabled = self.config.enabled;
let needs_session_restart = session_config_changed(&self.config, &config);
self.config = config.clone(); self.config = config.clone();
// Update the status to reflect new config // Update the status to reflect new config
@ -2115,11 +2213,31 @@ impl MeshService {
status.firmware_version = None; status.firmware_version = None;
status.self_node_id = None; status.self_node_id = None;
status.peer_count = 0; status.peer_count = 0;
} else if config.enabled && was_enabled && needs_session_restart {
info!("Mesh session config changed — restarting listener to apply");
self.stop().await;
self.start()?;
} }
Ok(()) Ok(())
} }
/// The service's current (last-applied) config.
pub fn config(&self) -> &MeshConfig {
&self.config
}
/// Restart the listener (if running) so it picks up out-of-band state a
/// spawn captured by value — currently the server name pushed by
/// `server.set-name`.
pub async fn restart_listener_if_running(&mut self) -> Result<()> {
if self.listener_handle.is_some() {
self.stop().await;
self.start()?;
}
Ok(())
}
/// Get a reference to shared state (for RPC handlers). /// Get a reference to shared state (for RPC handlers).
pub fn shared_state(&self) -> Arc<MeshState> { pub fn shared_state(&self) -> Arc<MeshState> {
Arc::clone(&self.state) Arc::clone(&self.state)
@ -2241,6 +2359,46 @@ async fn bitcoin_rpc_getblockheader_by_height(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn session_config_change_detection() {
let base = MeshConfig::default();
// Same config → no restart.
assert!(!session_config_changed(&base, &base.clone()));
// Every session-captured field individually triggers a restart.
let mut c = base.clone();
c.device_kind = Some(types::DeviceType::Reticulum);
assert!(session_config_changed(&base, &c));
let mut c = base.clone();
c.device_path = Some("/dev/ttyUSB0".into());
assert!(session_config_changed(&base, &c));
let mut c = base.clone();
c.advert_name = Some("RNode Shaza".into());
assert!(session_config_changed(&base, &c));
let mut c = base.clone();
c.manage_radio = !base.manage_radio;
assert!(session_config_changed(&base, &c));
let mut c = base.clone();
c.lora_region = Some("EU_868".into());
assert!(session_config_changed(&base, &c));
let mut c = base.clone();
c.channel_name = Some("private-net".into());
assert!(session_config_changed(&base, &c));
// Live-read fields must NOT force a session restart.
let mut c = base.clone();
c.broadcast_identity = !base.broadcast_identity;
c.announce_block_headers = !base.announce_block_headers;
c.assistant_enabled = !base.assistant_enabled;
assert!(!session_config_changed(&base, &c));
}
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer { fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
MeshPeer { MeshPeer {
contact_id, contact_id,

View File

@ -115,27 +115,73 @@ fn is_loopback_host(host: &str) -> bool {
/// Reticulum-carried identity binds onto the existing Archy contact via the /// Reticulum-carried identity binds onto the existing Archy contact via the
/// existing `parse_identity_broadcast`/`handle_identity_received` path, /// existing `parse_identity_broadcast`/`handle_identity_received` path,
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code. /// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
/// Resolve the daemon invocation: packaged binary, else the dev venv script.
/// `(program, Some(script_arg))` for the venv fallback.
fn daemon_program() -> (String, Option<String>) {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
if Path::new(&bin).exists() {
return (bin, None);
}
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
(py, Some(script))
}
/// Whether the installed daemon understands `--enable-transport`. OTA updates
/// ship the archipelago binary ahead of the packaged daemon tools, so a fleet
/// node can run a new binary against an old daemon — whose argparse EXITS on
/// an unknown flag, killing the mesh session on every spawn (live regression,
/// framework-pt on v1.7.117). Probe `--help` and only pass the flag when the
/// daemon advertises it; an old daemon then runs edge-only exactly as before.
async fn daemon_supports_enable_transport() -> bool {
let (program, script) = daemon_program();
let mut cmd = Command::new(&program);
if let Some(script) = &script {
cmd.arg(script);
}
cmd.arg("--help")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
let out = tokio::time::timeout(Duration::from_secs(10), async { cmd.output().await }).await;
match out {
Ok(Ok(out)) => {
let supported = String::from_utf8_lossy(&out.stdout).contains("--enable-transport");
if !supported {
warn!(
program,
"installed reticulum-daemon predates --enable-transport — running edge-only until the daemon tools update"
);
}
supported
}
_ => {
warn!(
program,
"reticulum-daemon --help probe failed — not passing --enable-transport"
);
false
}
}
}
fn daemon_command( fn daemon_command(
socket_path: &Path, socket_path: &Path,
iface: &ReticulumInterface<'_>, iface: &ReticulumInterface<'_>,
identity_key: &Path, identity_key: &Path,
archy_ed_pubkey_hex: Option<&str>, archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
enable_transport: bool,
) -> Command { ) -> Command {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN") let (program, script) = daemon_program();
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string()); let mut cmd = Command::new(program);
let mut cmd = if Path::new(&bin).exists() { if let Some(script) = script {
Command::new(bin) cmd.arg(script);
} else { }
// Dev fallback: run the script through its venv interpreter.
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
let mut c = Command::new(py);
c.arg(script);
c
};
cmd.arg("--identity-key") cmd.arg("--identity-key")
.arg(identity_key) .arg(identity_key)
.arg("--socket") .arg("--socket")
@ -153,12 +199,30 @@ fn daemon_command(
} }
} }
} }
// Archy nodes are RNS transport nodes: they relay 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 (`enable_transport = no`) left every node an island
// limited to its own radio horizon. Gated on the installed daemon actually
// supporting the flag — see `daemon_supports_enable_transport`.
if enable_transport {
cmd.arg("--enable-transport");
}
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) { if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex") cmd.arg("--archy-ed-pubkey-hex")
.arg(ed) .arg(ed)
.arg("--archy-x25519-pubkey-hex") .arg("--archy-x25519-pubkey-hex")
.arg(x); .arg(x);
} }
// The RNS-visible display name (what Sideband/NomadNet/other archy nodes
// show for us). Without this the daemon falls back to its argparse
// default and every archy node announces the same anonymous name.
if let Some(name) = display_name {
let name = name.trim();
if !name.is_empty() {
cmd.arg("--display-name").arg(name);
}
}
// Run the daemon as its own process-group leader. The packaged binary is // Run the daemon as its own process-group leader. The packaged binary is
// a PyInstaller one-file bootloader that forks the real Python process; // a PyInstaller one-file bootloader that forks the real Python process;
// making it a group leader lets shutdown signal the WHOLE group so the // making it a group leader lets shutdown signal the WHOLE group so the
@ -207,6 +271,10 @@ struct ReticulumPeer {
/// `bind_federation_twins`, which those two transports rely on instead). /// `bind_federation_twins`, which those two transports rely on instead).
arch_pubkey_hex: Option<String>, arch_pubkey_hex: Option<String>,
reachable: bool, reachable: bool,
/// Unix time of the last announce heard from this peer over the air.
/// In-memory only (a persisted value would be stale by definition) —
/// `0` after a restart until the peer re-announces.
last_advert_at: u64,
} }
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key, /// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
@ -245,6 +313,11 @@ pub struct ReticulumLink {
/// matching `resource_progress`/`resource_sent`/`resource_failed` events /// matching `resource_progress`/`resource_sent`/`resource_failed` events
/// back to a log line; sends are fire-and-forget (see `send_resource`). /// back to a log line; sends are fire-and-forget (see `send_resource`).
resource_id_counter: u64, resource_id_counter: u64,
/// Set when the daemon's RPC socket closes or its process exits. Once
/// true, `try_recv_frame` returns an error so the session loop tears
/// down and the outer reconnect loop respawns the daemon — without this
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
daemon_gone: bool,
} }
impl ReticulumLink { impl ReticulumLink {
@ -269,6 +342,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
probe_rnode(path) probe_rnode(path)
.await .await
@ -278,6 +352,7 @@ impl ReticulumLink {
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
) )
.await .await
} }
@ -290,6 +365,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind); let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
anyhow::ensure!( anyhow::ensure!(
@ -302,6 +378,7 @@ impl ReticulumLink {
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
) )
.await .await
} }
@ -313,6 +390,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
anyhow::ensure!( anyhow::ensure!(
!targets.is_empty(), !targets.is_empty(),
@ -323,6 +401,7 @@ impl ReticulumLink {
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
) )
.await .await
} }
@ -332,6 +411,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
// Keep the RPC socket under the archipelago-owned data dir (not the // Keep the RPC socket under the archipelago-owned data dir (not the
// shared system temp dir) so its access is bounded by the same // shared system temp dir) so its access is bounded by the same
@ -373,12 +453,15 @@ impl ReticulumLink {
); );
} }
let enable_transport = daemon_supports_enable_transport().await;
let mut cmd = daemon_command( let mut cmd = daemon_command(
&socket_path, &socket_path,
&iface, &iface,
&identity_key, &identity_key,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
enable_transport,
); );
cmd.env("TMPDIR", &tmp_dir); cmd.env("TMPDIR", &tmp_dir);
let child = cmd let child = cmd
@ -450,6 +533,7 @@ impl ReticulumLink {
peers_file: runtime_dir.join("peers.json"), peers_file: runtime_dir.join("peers.json"),
inbound: std::collections::VecDeque::new(), inbound: std::collections::VecDeque::new(),
resource_id_counter: 0, resource_id_counter: 0,
daemon_gone: false,
}; };
link.load_persisted_peers(); link.load_persisted_peers();
Ok(link) Ok(link)
@ -472,21 +556,53 @@ impl ReticulumLink {
}; };
let prefix: [u8; 6] = hash[..6].try_into().unwrap(); let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash); self.prefix_to_hash.insert(prefix, hash);
// Heal names persisted by pre-2026-07-28 builds, which could
// store a raw `ARCHY:…` identity blob as the display name (seen
// live on archi-dev-box). Blob-only announces assert no name, so
// nothing would ever overwrite it — swap in the placeholder.
let display_name = if p.display_name.starts_with("ARCHY:") {
format!("Reticulum {}", hex::encode(&hash[..4]))
} else {
p.display_name
};
self.peers.insert( self.peers.insert(
hash, hash,
ReticulumPeer { ReticulumPeer {
dest_hash: hash, dest_hash: hash,
display_name: p.display_name, display_name,
arch_pubkey_hex: p.arch_pubkey_hex, arch_pubkey_hex: p.arch_pubkey_hex,
// Reachability is a live property, not a persisted fact — // Reachability is a live property, not a persisted fact —
// start conservative and let the first real event refresh it. // start conservative and let the first real event refresh it.
reachable: false, reachable: false,
last_advert_at: 0,
}, },
); );
} }
info!(count = self.peers.len(), "Loaded persisted Reticulum peers"); info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
} }
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
/// destination hash. Two shapes arrive here depending on how the contact
/// record was born (observed live 2026-07-28, image-to-merged-contact):
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
/// announce) — direct `prefix_to_hash` hit;
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
/// no dest-hash match possible, so fall back to scanning peers whose
/// announce-bound `arch_pubkey_hex` starts with the prefix.
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
if let Some(hash) = self.prefix_to_hash.get(prefix) {
return Some(*hash);
}
let hex_prefix = hex::encode(prefix);
self.peers.values().find_map(|p| {
p.arch_pubkey_hex
.as_deref()
.filter(|arch| arch.starts_with(&hex_prefix))
.map(|_| p.dest_hash)
})
}
/// Best-effort sync write of the current peer table — called after any /// Best-effort sync write of the current peer table — called after any
/// insert that adds/renames a peer. Infrequent (announces/first-contact, /// insert that adds/renames a peer. Infrequent (announces/first-contact,
/// not per-message) so a blocking write here is a fine trade for keeping /// not per-message) so a blocking write here is a fine trade for keeping
@ -533,10 +649,12 @@ impl ReticulumLink {
} }
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> { pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
// The daemon's display_name is fixed at spawn time (CLI arg); changing // Live rename: the daemon's `set_name` verb updates the LXMF delivery
// it live would require an RPC verb we haven't added. Track locally so // destination's display_name and re-announces, so peers pick the new
// `advert_name()` reflects the caller's intent even though the // name up on their next announce receipt. Also tracked locally so
// RNS-visible name doesn't change until the daemon restarts. // `advert_name()` reflects it immediately.
self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name}))
.await?;
self.display_name = Some(name.to_string()); self.display_name = Some(name.to_string());
Ok(()) Ok(())
} }
@ -562,9 +680,7 @@ impl ReticulumLink {
payload: &[u8], payload: &[u8],
) -> Result<()> { ) -> Result<()> {
let dest_hash = self let dest_hash = self
.prefix_to_hash .resolve_dest_hash(dest_pubkey_prefix)
.get(dest_pubkey_prefix)
.copied()
.with_context(|| { .with_context(|| {
format!( format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet", "Unknown Reticulum prefix {} — peer hasn't announced yet",
@ -606,9 +722,7 @@ impl ReticulumLink {
) -> Result<()> { ) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self let dest_hash = self
.prefix_to_hash .resolve_dest_hash(dest_pubkey_prefix)
.get(dest_pubkey_prefix)
.copied()
.with_context(|| { .with_context(|| {
format!( format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet", "Unknown Reticulum prefix {} — peer hasn't announced yet",
@ -638,9 +752,7 @@ impl ReticulumLink {
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> { pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self let dest_hash = self
.prefix_to_hash .resolve_dest_hash(dest_pubkey_prefix)
.get(dest_pubkey_prefix)
.copied()
.with_context(|| { .with_context(|| {
format!( format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet", "Unknown Reticulum prefix {} — peer hasn't announced yet",
@ -685,7 +797,7 @@ impl ReticulumLink {
.map(|p| ParsedContact { .map(|p| ParsedContact {
public_key_hex: hex::encode(p.dest_hash), public_key_hex: hex::encode(p.dest_hash),
advert_name: p.display_name.clone(), advert_name: p.display_name.clone(),
last_advert: 0, last_advert: p.last_advert_at as u32,
// Deliberately not 1 ("friend"/meshcore type), so the // Deliberately not 1 ("friend"/meshcore type), so the
// meshcore-only auto-heal `reset_contact_path` loop in // meshcore-only auto-heal `reset_contact_path` loop in
// `refresh_contacts` (session.rs) skips these — RNS does its // `refresh_contacts` (session.rs) skips these — RNS does its
@ -718,6 +830,12 @@ impl ReticulumLink {
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> { pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
self.drain_events().await; self.drain_events().await;
if self.daemon_gone {
// Surface the dead daemon as a hard error so run_mesh_session
// bails and the outer reconnect loop respawns it, instead of
// idling on an empty queue until the RX-stall watchdog fires.
anyhow::bail!("reticulum-daemon is gone (process exited or RPC socket closed)");
}
Ok(self.inbound.pop_front()) Ok(self.inbound.pop_front())
} }
@ -743,6 +861,15 @@ impl ReticulumLink {
/// Drain any buffered daemon events (non-blocking) and translate them into /// Drain any buffered daemon events (non-blocking) and translate them into
/// peer-table updates / synthetic InboundFrames. /// peer-table updates / synthetic InboundFrames.
async fn drain_events(&mut self) { async fn drain_events(&mut self) {
// A daemon that died without closing the socket cleanly (SIGKILL,
// OOM) leaves the socket readable-with-EOF or just silent — poll the
// child's exit status too so death is never mistaken for quiet.
if !self.daemon_gone {
if let Ok(Some(status)) = self.child.try_wait() {
warn!(%status, "reticulum-daemon process exited");
self.daemon_gone = true;
}
}
loop { loop {
let mut line = String::new(); let mut line = String::new();
let read = let read =
@ -750,10 +877,16 @@ impl ReticulumLink {
.await; .await;
let n = match read { let n = match read {
Ok(Ok(n)) => n, Ok(Ok(n)) => n,
_ => break, // timeout (no data) or read error — stop draining Ok(Err(e)) => {
warn!("Reticulum daemon RPC read failed: {}", e);
self.daemon_gone = true;
break;
}
Err(_) => break, // timeout — no data buffered
}; };
if n == 0 { if n == 0 {
warn!("Reticulum daemon RPC connection closed"); warn!("Reticulum daemon RPC connection closed");
self.daemon_gone = true;
break; break;
} }
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else { let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
@ -775,6 +908,23 @@ impl ReticulumLink {
}; };
let prefix: [u8; 6] = hash[..6].try_into().unwrap(); let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash); self.prefix_to_hash.insert(prefix, hash);
// Current daemons decode the LXMF announce app_data themselves
// and hand us clean fields: `display_name` (LXMF-standard
// msgpack name, Sideband-interoperable) and `archy_blob` (the
// `ARCHY:n:` identity string, carried as an extra msgpack list
// element stock clients ignore). The raw `app_data` text path
// below remains for announces from pre-upgrade archy nodes,
// whose app_data was EITHER the blob OR a bare-utf8 name.
let explicit_name = ev
.get("display_name")
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let explicit_blob = ev
.get("archy_blob")
.and_then(Value::as_str)
.map(str::to_string)
.filter(|s| !s.is_empty());
let app_data_text = ev let app_data_text = ev
.get("app_data") .get("app_data")
.and_then(Value::as_str) .and_then(Value::as_str)
@ -794,12 +944,19 @@ impl ReticulumLink {
// now carry the same `arch_pubkey_hex`, instead of relying on // now carry the same `arch_pubkey_hex`, instead of relying on
// `bind_federation_twins`'s advert_name matching, which never // `bind_federation_twins`'s advert_name matching, which never
// matches here — see `display_name` below. // matches here — see `display_name` below.
let parsed_identity = app_data_text let legacy_identity = app_data_text
.as_deref() .as_deref()
.and_then(protocol::parse_identity_broadcast); .and_then(protocol::parse_identity_broadcast);
let is_identity_blob = parsed_identity.is_some(); let is_legacy_blob = legacy_identity.is_some();
if is_identity_blob { let identity_blob_text =
let text = app_data_text.clone().unwrap(); explicit_blob.or_else(|| app_data_text.clone().filter(|_| is_legacy_blob));
let parsed_identity = identity_blob_text
.as_deref()
.and_then(protocol::parse_identity_broadcast);
if let Some(text) = identity_blob_text
.as_deref()
.filter(|_| parsed_identity.is_some())
{
let mut data = Vec::with_capacity(7 + text.len()); let mut data = Vec::with_capacity(7 + text.len());
data.push(0); // channel index — unused by the identity path data.push(0); // channel index — unused by the identity path
data.extend_from_slice(&prefix); data.extend_from_slice(&prefix);
@ -812,23 +969,31 @@ impl ReticulumLink {
} }
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey); let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
let display_name = app_data_text let announced_name =
.filter(|_| !is_identity_blob) pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))); let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.peers self.peers
.entry(hash) .entry(hash)
.and_modify(|p| { .and_modify(|p| {
p.display_name = display_name.clone(); if let Some(name) = announced_name.clone() {
p.display_name = name;
}
p.reachable = true; p.reachable = true;
p.last_advert_at = now;
if arch_pubkey_hex.is_some() { if arch_pubkey_hex.is_some() {
p.arch_pubkey_hex = arch_pubkey_hex.clone(); p.arch_pubkey_hex = arch_pubkey_hex.clone();
} }
}) })
.or_insert(ReticulumPeer { .or_insert_with(|| ReticulumPeer {
dest_hash: hash, dest_hash: hash,
display_name, display_name: announced_name
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))),
arch_pubkey_hex, arch_pubkey_hex,
reachable: true, reachable: true,
last_advert_at: now,
}); });
self.persist_peers(); self.persist_peers();
} }
@ -844,16 +1009,24 @@ impl ReticulumLink {
// A peer that messages us without ever announcing still needs // A peer that messages us without ever announcing still needs
// to survive a restart — give it a placeholder name (the real // to survive a restart — give it a placeholder name (the real
// one, if any, arrives via a later "announce" and overwrites // one, if any, arrives via a later "announce" and overwrites
// this) so its routing entry alone doesn't get lost. // this) so its routing entry alone doesn't get lost. An
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) // existing entry is proof of life too: mark it reachable so a
{ // restart-restored (reachable=false) peer that DMs us doesn't
e.insert(ReticulumPeer { // stay red-dotted until its next announce.
dest_hash: source_hash, match self.peers.entry(source_hash) {
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), std::collections::hash_map::Entry::Vacant(e) => {
arch_pubkey_hex: None, e.insert(ReticulumPeer {
reachable: true, dest_hash: source_hash,
}); display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
self.persist_peers(); arch_pubkey_hex: None,
reachable: true,
last_advert_at: 0,
});
self.persist_peers();
}
std::collections::hash_map::Entry::Occupied(mut e) => {
e.get_mut().reachable = true;
}
} }
// A stock LXMF client (Sideband/NomadNet — not an archy peer) // A stock LXMF client (Sideband/NomadNet — not an archy peer)
@ -924,23 +1097,37 @@ impl ReticulumLink {
.push_back(build_synthetic_frame(&prefix, &content)); .push_back(build_synthetic_frame(&prefix, &content));
} }
Some("resource_recv") => { Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else { let source_hex = ev
return; .get("source_hash")
}; .and_then(Value::as_str)
.unwrap_or_default();
let Ok(source_hash) = parse_hash16(source_hex) else { let Ok(source_hash) = parse_hash16(source_hex) else {
// An empty source_hash means the sender's link wasn't
// identified (pre-identify daemon build on the far end) —
// the blob is undeliverable without attribution, but say
// so instead of vanishing it.
warn!(
source = source_hex,
"Dropping inbound Reticulum resource without valid source identity"
);
return; return;
}; };
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap(); let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash); self.prefix_to_hash.insert(prefix, source_hash);
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) match self.peers.entry(source_hash) {
{ std::collections::hash_map::Entry::Vacant(e) => {
e.insert(ReticulumPeer { e.insert(ReticulumPeer {
dest_hash: source_hash, dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
arch_pubkey_hex: None, arch_pubkey_hex: None,
reachable: true, reachable: true,
}); last_advert_at: 0,
self.persist_peers(); });
self.persist_peers();
}
std::collections::hash_map::Entry::Occupied(mut e) => {
e.get_mut().reachable = true;
}
} }
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let Some(data) = ev let Some(data) = ev
@ -1089,8 +1276,10 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART // ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the // bridge chip) treat a DTR/RTS transition on open as a reset signal, the
// same mechanism esptool uses to force bootloader entry. Deassert both // same mechanism esptool uses to force bootloader entry. Deassert both
// and let the board settle before writing the probe, or the reboot eats // before writing the probe. Boards behind a USB-UART bridge (CP2102 on
// the DETECT_RESP window below. // the Heltec V3) get the reset pulse from the open() itself, before we
// can deassert anything — that case is handled by the boot-settle retry
// below.
let _ = port.set_dtr(false); let _ = port.set_dtr(false);
let _ = port.set_rts(false); let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await; tokio::time::sleep(Duration::from_millis(300)).await;
@ -1109,26 +1298,81 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
0x00, 0x00,
KISS_FEND, KISS_FEND,
]; ];
// Attempt 1: probe immediately. A board that did NOT reset on open (it
// was already up — e.g. a re-probe of a running RNode) answers in well
// under a second, so the fast path stays fast.
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe)) tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
.await .await
.context("RNode probe write timed out")? .context("RNode probe write timed out")?
.context("RNode probe write failed")?; .context("RNode probe write failed")?;
let mut buf = [0u8; 256]; let mut buf = [0u8; 256];
let mut seen = Vec::new(); let mut seen = Vec::new();
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT; if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
while tokio::time::Instant::now() < deadline { return Ok(());
}
// No DETECT_RESP. If the open() power-cycled the board (verified live on
// a Heltec V3 RNode behind a CP2102: the ESP32 spends ~2.5-3s in boot
// ROM + app init and silently eats anything written meanwhile, so the
// first probe lands in the void), wait for its boot chatter to go quiet
// and probe once more with a fresh response window.
const BOOT_QUIET_WINDOW: Duration = Duration::from_millis(800);
const BOOT_SETTLE_MAX: Duration = Duration::from_secs(6);
let settle_deadline = tokio::time::Instant::now() + BOOT_SETTLE_MAX;
let mut last_data = tokio::time::Instant::now();
while tokio::time::Instant::now() < settle_deadline {
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await { match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => { Ok(Ok(n)) if n > 0 => {
seen.extend_from_slice(&buf[..n]); seen.extend_from_slice(&buf[..n]);
// A late DETECT_RESP to the first write still counts.
if contains_detect_resp(&seen) { if contains_detect_resp(&seen) {
return Ok(()); return Ok(());
} }
last_data = tokio::time::Instant::now();
}
_ => {
if last_data.elapsed() >= BOOT_QUIET_WINDOW {
break;
}
}
}
}
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
.await
.context("RNode probe rewrite timed out")?
.context("RNode probe rewrite failed")?;
seen.clear();
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
return Ok(());
}
anyhow::bail!(
"No RNode DETECT_RESP within {:?} (incl. post-boot-settle retry)",
PROBE_READ_TIMEOUT
)
}
/// Read from `port` for up to `window`, accumulating into `seen`; true once
/// the KISS DETECT_RESP sequence shows up anywhere in the stream.
async fn await_detect_resp(
port: &serial2_tokio::SerialPort,
buf: &mut [u8],
seen: &mut Vec<u8>,
window: Duration,
) -> bool {
let deadline = tokio::time::Instant::now() + window;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(150), port.read(buf)).await {
Ok(Ok(n)) if n > 0 => {
seen.extend_from_slice(&buf[..n]);
if contains_detect_resp(seen) {
return true;
}
} }
_ => continue, _ => continue,
} }
} }
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT) false
} }
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the /// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
@ -1138,6 +1382,33 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP]) .any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
} }
/// The display name an announce actually asserted, if any.
///
/// Precedence: the daemon-decoded LXMF display name (`display_name` event
/// field), then — legacy peers only — bare-utf8 app_data that wasn't an
/// identity blob. The bare-utf8 fallback must actually look like text:
/// lossy-decoded msgpack (a new-format announce whose name the daemon failed
/// to decode) is full of U+FFFD/control chars and would otherwise become a
/// mojibake display name. `None` (e.g. a blob-only legacy announce) means
/// "no name asserted" and must NOT stomp a previously-learned name.
fn pick_announced_name(
explicit_name: Option<String>,
app_data_text: Option<String>,
is_legacy_blob: bool,
) -> Option<String> {
explicit_name
// A legacy blob-only announce utf8-decodes cleanly, so LXMF's
// display_name_from_app_data hands the daemon the ENTIRE `ARCHY:…`
// string as a "name" — seen live from a pre-upgrade Framework PT.
// An identity blob is never a display name.
.filter(|s| !s.starts_with("ARCHY:"))
.or_else(|| {
app_data_text
.filter(|_| !is_legacy_blob)
.filter(|s| !s.chars().any(|c| c.is_control() || c == '\u{FFFD}'))
})
}
impl Drop for ReticulumLink { impl Drop for ReticulumLink {
fn drop(&mut self) { fn drop(&mut self) {
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`). // Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
@ -1154,6 +1425,40 @@ impl Drop for ReticulumLink {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn announced_name_precedence() {
// Daemon-decoded LXMF name always wins.
assert_eq!(
pick_announced_name(
Some("RNode Shaza".into()),
Some("ARCHY:2:aa:bb".into()),
true
),
Some("RNode Shaza".to_string())
);
// Legacy bare-utf8 name (old daemon, no explicit field).
assert_eq!(
pick_announced_name(None, Some("zaza".into()), false),
Some("zaza".to_string())
);
// Legacy blob-only announce asserts NO name (must not stomp).
assert_eq!(
pick_announced_name(None, Some("ARCHY:2:aa:bb".into()), true),
None
);
// Lossy-decoded msgpack must not become a mojibake name.
assert_eq!(
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
None
);
assert_eq!(
pick_announced_name(None, Some("has\u{1}ctl".into()), false),
None
);
// Nothing at all.
assert_eq!(pick_announced_name(None, None, false), None);
}
#[test] #[test]
fn detect_resp_found_in_kiss_stream() { fn detect_resp_found_in_kiss_stream() {
let stream = [ let stream = [
@ -1245,6 +1550,45 @@ mod tests {
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1); assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1);
} }
// One test covers both directions to avoid two tests racing on the same
// process-global env var under the parallel test runner.
#[tokio::test]
async fn transport_flag_gated_on_daemon_support() {
let dir = std::env::temp_dir().join(format!("archy-daemon-stub-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let old = dir.join("old-daemon");
std::fs::write(&old, "#!/bin/sh\necho 'usage: --serial-port --no-radio'\n").unwrap();
let new = dir.join("new-daemon");
std::fs::write(
&new,
"#!/bin/sh\necho 'usage: --serial-port --enable-transport --no-radio'\n",
)
.unwrap();
for p in [&old, &new] {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
// An old daemon whose --help doesn't advertise the flag: never pass it
// (its argparse would exit and kill the mesh session — the v1.7.117
// fleet regression this guards against).
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &old);
assert!(!daemon_supports_enable_transport().await);
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &new);
assert!(daemon_supports_enable_transport().await);
// A missing binary must also fail safe (probe error → no flag).
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", dir.join("missing"));
// (falls through to the venv dev path, which doesn't exist in the
// test environment either → probe fails → false)
assert!(!daemon_supports_enable_transport().await);
std::env::remove_var("ARCHY_RETICULUM_DAEMON_BIN");
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn synthetic_frame_matches_meshtastic_layout() { fn synthetic_frame_matches_meshtastic_layout() {
let prefix = [1, 2, 3, 4, 5, 6]; let prefix = [1, 2, 3, 4, 5, 6];

View File

@ -557,29 +557,39 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// Scan for serial devices that could be Meshcore radios. /// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files. /// Returns paths to existing serial device files.
/// ///
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev /// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so /// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
/// without this one physical board shows up (and gets probed, and gets its /// primary radio currently enumerates as, so both names always pointed at
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which /// the same candidate list entry and both passed this scan — confirmed live
/// keeps the stable /dev/mesh-radio name when the symlink exists. /// 2026-07-23, this made an already-connected, working radio (connected via
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
/// hot-swap UI's active-session guard compares path strings, so it didn't
/// recognize the two aliases as the same port, showed the "device detected"
/// modal for a radio that was already set up, and probing it there opened
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
/// with — a continuous, UI-driven reset loop that only ran while that view
/// was open (matches the reported "stops when I leave, resumes when I come
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
/// dedup and is what's reported when both alias and target are present.
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
/// of the 2026-07-28 merge carried an equivalent implementation.)
pub async fn detect_serial_devices() -> Vec<String> { pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new(); let mut devices = Vec::new();
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new(); let mut seen_real_paths = std::collections::HashSet::new();
for path in SERIAL_CANDIDATES { for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() { if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) { if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device"); debug!(path = %path, "Skipping known non-mesh serial device");
continue; continue;
} }
let canonical = tokio::fs::canonicalize(path) let real_path = tokio::fs::canonicalize(path)
.await .await
.unwrap_or_else(|_| std::path::PathBuf::from(path)); .unwrap_or_else(|_| std::path::PathBuf::from(path));
if seen_canonical.contains(&canonical) { if !seen_real_paths.insert(real_path.clone()) {
debug!(path = %path, canonical = %canonical.display(), debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
"Skipping alias of an already-detected serial device");
continue; continue;
} }
seen_canonical.push(canonical);
devices.push(path.to_string()); devices.push(path.to_string());
} }
} }
@ -610,12 +620,23 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
let mut out = Vec::new(); let mut out = Vec::new();
for path in detect_serial_devices().await { for path in detect_serial_devices().await {
let usb = usb_info_for_tty(&path).await; let usb = usb_info_for_tty(&path).await;
let plugged_at = tokio::fs::metadata(&path) // Birth time (btime), falling back to inode-change time (ctime) —
.await // NOT mtime: a tty node's mtime bumps on every open()/write, so with
.ok() // mtime here each probe/session open minted a "new" plugged_at, the
.and_then(|m| m.modified().ok()) // UI's (path, plugged_at) dismissal key never matched again, and the
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) // setup modal re-fired forever on a device that never left the port
.map(|d| d.as_secs()); // (observed live on archi-dev-box 2026-07-28). btime/ctime only
// change when udev (re)creates/chowns the node — i.e. on real plugs.
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
m.created()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.or_else(|| {
use std::os::unix::fs::MetadataExt;
u64::try_from(m.ctime()).ok()
})
});
out.push(DetectedDeviceInfo { out.push(DetectedDeviceInfo {
path, path,
vid: usb.0, vid: usb.0,

View File

@ -134,6 +134,7 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
for onion in &unique_onions { for onion in &unique_onions {
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
match sync_single_peer( match sync_single_peer(
data_dir,
fips_npub.as_deref(), fips_npub.as_deref(),
&store, &store,
onion, onion,
@ -173,6 +174,7 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
/// Sync with a single peer: pull their messages and push ours. /// Sync with a single peer: pull their messages and push ours.
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor. /// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
async fn sync_single_peer( async fn sync_single_peer(
data_dir: &Path,
fips_npub: Option<&str>, fips_npub: Option<&str>,
store: &crate::network::dwn_store::DwnStore, store: &crate::network::dwn_store::DwnStore,
onion: &str, onion: &str,
@ -186,6 +188,8 @@ async fn sync_single_peer(
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health") let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
.service(crate::settings::transport::PeerService::Federation) .service(crate::settings::transport::PeerService::Federation)
.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)
.send_get() .send_get()
.await .await
.context("Peer DWN unreachable")?; .context("Peer DWN unreachable")?;
@ -211,6 +215,8 @@ async fn sync_single_peer(
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn") let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
.service(crate::settings::transport::PeerService::Federation) .service(crate::settings::transport::PeerService::Federation)
.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)
.send_json(&pull_body) .send_json(&pull_body)
.await .await
.context("Failed to query peer DWN")?; .context("Failed to query peer DWN")?;
@ -269,6 +275,8 @@ async fn sync_single_peer(
match PeerRequest::new(fips_npub, onion, "/dwn") match PeerRequest::new(fips_npub, onion, "/dwn")
.service(crate::settings::transport::PeerService::Federation) .service(crate::settings::transport::PeerService::Federation)
.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)
.send_json(&push_body) .send_json(&push_body)
.await .await
{ {

View File

@ -342,6 +342,9 @@ pub async fn send_to_peer(
signing_key: Option<&ed25519_dalek::SigningKey>, signing_key: Option<&ed25519_dalek::SigningKey>,
recipient_pubkey: Option<&str>, recipient_pubkey: Option<&str>,
from_name: Option<&str>, from_name: Option<&str>,
// Federation data dir for last-transport recording; None skips recording
// (callers without a data dir in scope).
record_data_dir: Option<&std::path::Path>,
) -> Result<()> { ) -> Result<()> {
validate_onion(onion)?; validate_onion(onion)?;
@ -370,24 +373,24 @@ pub async fn send_to_peer(
body["from_name"] = serde_json::Value::String(name.to_string()); body["from_name"] = serde_json::Value::String(name.to_string());
} }
let (resp, transport) = let mut req =
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message") crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
.service(crate::settings::transport::PeerService::Messaging) .service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(60))
.send_json(&body) .fips_timeout(std::time::Duration::from_secs(8));
.await if let Some(dir) = record_data_dir {
.map_err(|e| { req = req.record_transport(dir);
let msg = e.to_string(); }
if msg.contains("connection refused") || msg.contains("Connection refused") { let (resp, transport) = req.send_json(&body).await.map_err(|e| {
anyhow::anyhow!( let msg = e.to_string();
"Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status." if msg.contains("connection refused") || msg.contains("Connection refused") {
) anyhow::anyhow!("Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status.")
} else if msg.contains("timeout") || msg.contains("timed out") { } else if msg.contains("timeout") || msg.contains("timed out") {
anyhow::anyhow!("Connection timed out. The peer may be offline.") anyhow::anyhow!("Connection timed out. The peer may be offline.")
} else { } else {
anyhow::anyhow!("Failed to send: {}", msg) anyhow::anyhow!("Failed to send: {}", msg)
} }
})?; })?;
if !resp.status().is_success() { if !resp.status().is_success() {
anyhow::bail!( anyhow::bail!(
@ -410,6 +413,7 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
// circuit that hasn't answered /health in 12s is "offline" for UI // circuit that hasn't answered /health in 12s is "offline" for UI
// purposes; the old 30s made the Connected Nodes probes crawl. // purposes; the old 30s made the Connected Nodes probes crawl.
.timeout(std::time::Duration::from_secs(12)) .timeout(std::time::Duration::from_secs(12))
.fips_timeout(std::time::Duration::from_secs(4))
.send_get() .send_get()
.await .await
{ {

View File

@ -294,8 +294,13 @@ impl Server {
.await .await
.unwrap_or_default(); .unwrap_or_default();
// Auto-enable mesh if a radio is detected and no config exists yet // Auto-enable mesh if a radio is detected and no config exists
if !mesh_config.enabled { // yet. Only on a genuinely missing config: an existing file
// with enabled=false is an explicit operator decision (e.g.
// via mesh.configure) and force-re-enabling it on every boot
// made "disable mesh" impossible on any node with a radio
// plugged in.
if !mesh_config.enabled && !crate::mesh::config_file_exists(&data_dir) {
let devices = crate::mesh::detect_devices().await; let devices = crate::mesh::detect_devices().await;
if !devices.is_empty() { if !devices.is_empty() {
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices); info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
@ -433,8 +438,18 @@ impl Server {
), ),
)); ));
// LAN transport (mDNS discovery) // LAN transport (mDNS discovery). Advertise our FIPS npub in
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678); // the TXT record so co-located peers can form a direct FIPS
// link (see `lan_fips_anchors`).
let local_fips_npub = crate::identity::fips_npub(&data_dir.join("identity"))
.await
.unwrap_or(None);
let mut lan = crate::transport::lan::LanTransport::new(
&did,
&pubkey_hex,
5678,
local_fips_npub,
);
match lan.start(registry.clone()) { match lan.start(registry.clone()) {
Ok(()) => info!("📡 LAN transport (mDNS) started"), Ok(()) => info!("📡 LAN transport (mDNS) started"),
Err(e) => debug!("LAN transport init (non-fatal): {}", e), Err(e) => debug!("LAN transport init (non-fatal): {}", e),
@ -753,12 +768,25 @@ impl Server {
// (often flaky) global anchor's spanning tree to route to each // (often flaky) global anchor's spanning tree to route to each
// other. For every peer the registry knows both a LAN address // other. For every peer the registry knows both a LAN address
// AND a FIPS npub for, dial it on its FIPS UDP transport port // AND a FIPS npub for, dial it on its FIPS UDP transport port
// (8668) at its LAN IP. This is FIPS's own transport over the // at its LAN IP. This is FIPS's own transport over the
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure // LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
// FIPS. `fipsctl connect` is idempotent, so re-applying every // FIPS. `fipsctl connect` is idempotent, so re-applying every
// tick just keeps the direct link warm; unknown/remote peers // tick just keeps the direct link warm; unknown/remote peers
// (no LAN address) are left to the anchor as before. // (no LAN address) are left to the anchor as before.
if let Some(reg) = fips_peer_registry.as_ref() { if let Some(reg) = fips_peer_registry.as_ref() {
// Hydrate FIPS npubs into the registry from federation
// storage (did-keyed). Peers discovered before the mDNS
// TXT `fips` key existed — or running builds that don't
// advertise it yet — would otherwise never satisfy the
// `fips_npub` requirement in lan_fips_anchors(), leaving
// direct LAN peering a no-op.
if let Ok(nodes) = crate::federation::load_nodes(&data_dir).await {
for n in &nodes {
if let Some(npub) = n.fips_npub.as_deref() {
reg.set_fips_npub(&n.did, npub).await;
}
}
}
let direct = crate::fips::anchors::lan_fips_anchors(&reg.all_peers().await); let direct = crate::fips::anchors::lan_fips_anchors(&reg.all_peers().await);
if !direct.is_empty() { if !direct.is_empty() {
let _ = crate::fips::anchors::apply(&direct).await; let _ = crate::fips::anchors::apply(&direct).await;
@ -891,7 +919,7 @@ impl Server {
// Post-onboarding auto-activation for archipelago-fips. Runs once // Post-onboarding auto-activation for archipelago-fips. Runs once
// at startup: if fips_key is on disk, install /etc/fips/fips.yaml // at startup: if fips_key is on disk, install /etc/fips/fips.yaml
// (schema-refreshed) and start the service. This removes the // (schema-refreshed) and start the service. This removes the
// need for a user-facing "Activate" button — the node comes up // need for a user-facing manual Start button — the node comes up
// with FIPS running whenever the seed has been onboarded. Also // with FIPS running whenever the seed has been onboarded. Also
// self-heals legacy raw-byte fips.key files (load_fips_keys // self-heals legacy raw-byte fips.key files (load_fips_keys
// rewrites them as bech32 nsec the first time they're read). // rewrites them as bech32 nsec the first time they're read).
@ -940,14 +968,16 @@ impl Server {
); );
} }
} }
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await { let unit = crate::fips::service::activation_unit().await;
if let Err(e) = crate::fips::service::activate(unit).await {
tracing::warn!( tracing::warn!(
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC", "FIPS activate failed on startup via {}: {} — user can retry via fips.install RPC",
unit,
e e
); );
return; return;
} }
tracing::info!("archipelago-fips auto-activated on startup"); tracing::info!("FIPS auto-activated on startup via {}", unit);
}); });
} }
@ -1000,10 +1030,8 @@ impl Server {
_ => None, _ => None,
}; };
let v6_task = if let Some(port) = v4_any_port { let v6_task = if let Some(port) = v4_any_port {
let v6_addr = SocketAddr::new( let v6_addr =
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), port);
port,
);
match bind_v6_only(v6_addr) { match bind_v6_only(v6_addr) {
Ok(listener) => { Ok(listener) => {
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr); info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
@ -1017,7 +1045,10 @@ impl Server {
))) )))
} }
Err(e) => { Err(e) => {
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e); warn!(
"IPv6 web listener bind {} failed: {} — UI stays v4-only",
v6_addr, e
);
None None
} }
} }
@ -1031,9 +1062,9 @@ impl Server {
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got // IPv4 only for most apps, so a phone dialing [ULA]:8123 got
// nothing even with the firewall open (HA/FileBrowser/Gitea/ // nothing even with the firewall open (HA/FileBrowser/Gitea/
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::] // Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
// themselves). Bridge each catalog launch port to its v4 loopback // themselves). Bridge each catalog launch port on the fips0 ULA
// listener with a V6ONLY relay — self-selecting: where the app // only. Binding wildcard [::]:port reserves the same host ports
// already answers on v6 the bind fails and the app wins. // Podman needs and can restart-loop apps that publish those ports.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe())); let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let peer_task = tokio::spawn(peer_late_bind_loop( let peer_task = tokio::spawn(peer_late_bind_loop(
@ -1087,10 +1118,14 @@ fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
tokio::net::TcpListener::from_std(socket.into()) tokio::net::TcpListener::from_std(socket.into())
} }
fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
SocketAddr::new(std::net::IpAddr::V6(ip), port)
}
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for /// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
/// why). Rescans every 60s so ports of freshly installed apps get bridged /// why). Rescans every 60s so ports of freshly installed apps get bridged
/// without a daemon restart. Each relay is a V6ONLY listener forwarding /// without a daemon restart. Each relay binds to the fips0 ULA only and
/// raw TCP to the same port on IPv4 loopback. /// forwards raw TCP to the same port on IPv4 loopback.
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) { async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
use std::collections::HashSet; use std::collections::HashSet;
let mut bridged: HashSet<u16> = HashSet::new(); let mut bridged: HashSet<u16> = HashSet::new();
@ -1099,6 +1134,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
loop { loop {
tokio::select! { tokio::select! {
_ = interval.tick() => { _ = interval.tick() => {
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
if bridged.contains(&port) { if bridged.contains(&port) {
continue; continue;
@ -1123,15 +1159,12 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
if !v4_up { if !v4_up {
continue; continue;
} }
let addr = SocketAddr::new( let addr = fips_app_relay_addr(fips_ip, port);
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), // EADDRINUSE = fipsd or another process already answers
port, // on this mesh address/port, so stay out of the way.
);
// EADDRINUSE = the app itself already answers on v6 —
// exactly when we must stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue }; let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port); bridged.insert(port);
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}"); debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
let mut rx = shutdown_rx.clone(); let mut rx = shutdown_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
@ -1189,18 +1222,36 @@ async fn peer_late_bind_loop(
} }
}; };
info!("FIPS peer listener bound {}", addr); info!("FIPS peer listener bound {}", addr);
// Once bound, serve until shutdown fires. accept_loop // Serve until shutdown, a persistent accept failure, or a
// returns on shutdown, which also ends this outer loop. // fips0 ULA change. The listener must be REBINDABLE: a
accept_loop( // daemon re-key tears fips0 down and brings it back with a
handler, // (possibly different) ULA, and the old one-shot bind left
listener, // the node inbound-dead over FIPS until process restart.
active_connections, tokio::select! {
true, // peer listener: apply path filter _ = accept_loop(
shutdown_rx, handler.clone(),
addr, listener,
) active_connections.clone(),
.await; true, // peer listener: apply path filter
return; shutdown_rx.clone(),
addr,
) => {
if *shutdown_rx.borrow() { return; }
warn!("FIPS peer accept loop ended — rebinding");
}
_ = async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
if crate::fips::iface::fips0_ula() != Some(ip) {
break;
}
}
} => {
info!("fips0 ULA changed — rebinding FIPS peer listener");
// Dropping the select arm cancels accept_loop and
// frees the socket; the outer loop rebinds fresh.
}
}
} }
_ = shutdown_rx.changed() => { _ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() { return; } if *shutdown_rx.borrow() { return; }
@ -1236,6 +1287,12 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
) )
// Prefix-matched content endpoints (peer file browse + fetch) // Prefix-matched content endpoints (peer file browse + fetch)
|| path.starts_with("/content/") || path.starts_with("/content/")
// Mesh file sharing — blob fetch by CID, signature-gated in the
// handler. Absent from this list it 404'd over FIPS and the feature
// was 100% Tor by construction.
|| path.starts_with("/blob/")
// DWN sync — /dwn/health is step 1 of every sync; same story.
|| path.starts_with("/dwn/")
} }
async fn accept_loop( async fn accept_loop(
@ -1246,22 +1303,70 @@ async fn accept_loop(
mut shutdown_rx: tokio::sync::watch::Receiver<bool>, mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
local_addr: SocketAddr, local_addr: SocketAddr,
) { ) {
// Consecutive accept-error tracking: a fips0 teardown/re-key leaves the
// peer listener's socket permanently broken — `continue`-ing forever
// made the node inbound-dead over FIPS until process restart. After a
// burst of consecutive errors the peer accept loop returns so its
// caller (peer_late_bind_loop) can rebind on the current ULA.
let mut consecutive_errors: u32 = 0;
loop { loop {
tokio::select! { tokio::select! {
result = listener.accept() => { result = listener.accept() => {
let (stream, peer_addr) = match result { let (stream, peer_addr) = match result {
Ok(c) => c, Ok(c) => { consecutive_errors = 0; c }
Err(e) => { Err(e) => {
error!("{} accept error: {}", local_addr, e); error!("{} accept error: {}", local_addr, e);
consecutive_errors += 1;
if peer_only && consecutive_errors >= 10 {
warn!("{} accept failing persistently — returning for rebind", local_addr);
return;
}
// Don't hot-loop on a dead socket.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
continue; continue;
} }
}; };
let handler = handler.clone(); let handler = handler.clone();
let permit = active_connections.clone().acquire_owned().await; // NEVER park the accept loop on the connection budget.
// `acquire_owned().await` here froze accept() entirely when
// permits drained — and permits drained because half-open
// clients and hung upstreams held them forever (the .228
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
// load instead: accept, answer 503, close.
let permit = match active_connections.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
warn!(
"{} connection budget exhausted — shedding {}",
local_addr, peer_addr
);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut stream = stream;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.write_all(
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
),
)
.await;
let _ = stream.shutdown().await;
});
continue;
}
};
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; let _permit = permit;
// Set when a request carries an Upgrade header (websocket):
// upgraded connections are legitimately long-lived and are
// exempt from the non-upgraded connection deadline below.
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
let upgraded_flag = upgraded.clone();
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| { let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
let handler = handler.clone(); let handler = handler.clone();
if req.headers().contains_key(hyper::header::UPGRADE) {
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
}
async move { async move {
// Record the TCP peer so rate limiting only trusts // Record the TCP peer so rate limiting only trusts
// forwarded headers on loopback (nginx) connections. // forwarded headers on loopback (nginx) connections.
@ -1280,13 +1385,48 @@ async fn accept_loop(
.map_err(|e| std::io::Error::other(format!("{}", e))) .map_err(|e| std::io::Error::other(format!("{}", e)))
} }
}); });
if let Err(e) = Http::new() // header_read_timeout: a client that connects and never
// sends a request (slowloris / half-open) is dropped
// instead of holding a permit until the heat death of the
// node. Long RPCs are safe — the clock only covers header
// read.
let conn = Http::new()
.http1_keep_alive(false) .http1_keep_alive(false)
.http1_header_read_timeout(std::time::Duration::from_secs(30))
.serve_connection(stream, service) .serve_connection(stream, service)
.with_upgrades() .with_upgrades();
.await tokio::pin!(conn);
{ // Deadline watchdog for NON-upgraded connections. With
error!("Error serving connection from {}: {}", peer_addr, e); // keep-alive off a plain connection serves one exchange;
// 15 min bounds even the slowest legitimate RPC/stream
// while guaranteeing a hung upstream can't hold a permit
// forever. Upgraded (websocket) connections are exempt.
const NON_UPGRADED_DEADLINE: std::time::Duration =
std::time::Duration::from_secs(900);
let started = std::time::Instant::now();
let watchdog = async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
&& started.elapsed() >= NON_UPGRADED_DEADLINE
{
return;
}
}
};
tokio::select! {
r = &mut conn => {
if let Err(e) = r {
error!("Error serving connection from {}: {}", peer_addr, e);
}
}
_ = watchdog => {
warn!(
"connection from {} exceeded {}s without completing or upgrading — dropping",
peer_addr,
NON_UPGRADED_DEADLINE.as_secs()
);
}
} }
}); });
} }
@ -1939,10 +2079,25 @@ mod merge_tests {
); );
assert!(is_peer_allowed_path("/rpc/v1")); assert!(is_peer_allowed_path("/rpc/v1"));
assert!(is_peer_allowed_path("/health")); assert!(is_peer_allowed_path("/health"));
// Mesh blob fetch + DWN sync — both were missing from the allowlist,
// which made them deterministically 404 over FIPS and therefore
// 100% Tor by construction.
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
// Not on the allow-list → rejected (no broad surface over the mesh). // Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak"); assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/")); assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2")); assert!(!is_peer_allowed_path("/rpc/v2"));
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
}
#[test]
fn app_relay_binds_to_fips_ula_not_wildcard() {
let ula = "fd12:3456:789a::1".parse().unwrap();
let addr = fips_app_relay_addr(ula, 8083);
assert_eq!(addr.ip(), std::net::IpAddr::V6(ula));
assert_eq!(addr.port(), 8083);
} }
#[test] #[test]

View File

@ -24,17 +24,27 @@ pub struct LanTransport {
our_did: String, our_did: String,
our_pubkey_hex: String, our_pubkey_hex: String,
our_port: u16, our_port: u16,
/// This node's FIPS npub, advertised in the mDNS TXT record so
/// co-located peers can form a direct FIPS link (`lan_fips_anchors`)
/// without waiting for federation storage to sync.
our_fips_npub: Option<String>,
daemon: Option<ServiceDaemon>, daemon: Option<ServiceDaemon>,
available: AtomicBool, available: AtomicBool,
} }
impl LanTransport { impl LanTransport {
/// Create a new LAN transport. Does not start discovery yet. /// Create a new LAN transport. Does not start discovery yet.
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self { pub fn new(
our_did: &str,
our_pubkey_hex: &str,
port: u16,
our_fips_npub: Option<String>,
) -> Self {
Self { Self {
our_did: our_did.to_string(), our_did: our_did.to_string(),
our_pubkey_hex: our_pubkey_hex.to_string(), our_pubkey_hex: our_pubkey_hex.to_string(),
our_port: port, our_port: port,
our_fips_npub,
daemon: None, daemon: None,
available: AtomicBool::new(false), available: AtomicBool::new(false),
} }
@ -47,11 +57,14 @@ impl LanTransport {
// Advertise our service // Advertise our service
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]); let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
let properties = vec![ let mut properties = vec![
("did".to_string(), self.our_did.clone()), ("did".to_string(), self.our_did.clone()),
("pubkey".to_string(), self.our_pubkey_hex.clone()), ("pubkey".to_string(), self.our_pubkey_hex.clone()),
("version".to_string(), "0.1.0".to_string()), ("version".to_string(), "0.1.0".to_string()),
]; ];
if let Some(npub) = &self.our_fips_npub {
properties.push(("fips".to_string(), npub.clone()));
}
let service_info = ServiceInfo::new( let service_info = ServiceInfo::new(
SERVICE_TYPE, SERVICE_TYPE,
@ -93,6 +106,11 @@ impl LanTransport {
.map(|v| v.val_str().to_string()); .map(|v| v.val_str().to_string());
let addresses = info.get_addresses(); let addresses = info.get_addresses();
let fips_npub = info
.get_properties()
.get("fips")
.map(|v| v.val_str().to_string());
if let (Some(did), Some(pubkey)) = (did, pubkey) { if let (Some(did), Some(pubkey)) = (did, pubkey) {
if let Some(scoped_ip) = addresses.iter().next() { if let Some(scoped_ip) = addresses.iter().next() {
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() { let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
@ -106,6 +124,9 @@ impl LanTransport {
.await; .await;
registry_clone.set_lan_address(&did, socket_addr).await; registry_clone.set_lan_address(&did, socket_addr).await;
registry_clone.set_name(&did, info.get_fullname()).await; registry_clone.set_name(&did, info.get_fullname()).await;
if let Some(npub) = fips_npub.as_deref() {
registry_clone.set_fips_npub(&did, npub).await;
}
} }
} }
} }

View File

@ -106,7 +106,7 @@ pub enum PeerSource {
} }
/// Unified peer record with per-transport capabilities. /// Unified peer record with per-transport capabilities.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PeerRecord { pub struct PeerRecord {
pub did: String, pub did: String,
pub pubkey_hex: String, pub pubkey_hex: String,

9
demo/content/README.md Normal file
View File

@ -0,0 +1,9 @@
# Demo Content Provenance
All media in `demo/content/` and `demo/peer-media/` — music tracks, photos,
book covers, posters, and documents — are original works created and owned by
the Archipelago project author (Dorian), included here as demo content and
released with the project under the repository MIT license.
None of this content is sourced from third-party stock libraries or
commercial catalogs.

View File

@ -0,0 +1,300 @@
# FIPS near-100% uptime + optimistic UI state — implementation plan
**Date:** 2026-07-27. **Status:** researched + root-caused live on the fleet; ready to
implement for the next release. Two workstreams: (A) make node↔node FIPS transport
succeed whenever a FIPS path physically exists, (B) stop the UI reloading everything
on every navigation (optimistic/cached cards, stale-while-revalidate) while keeping
data fresh.
**Honesty note on "100%":** if a node's network blackholes every anchor (the .116
WiFi case, `docs/HANDOFF-2026-07-20-fips-peer-files.md:117-133`), Tor fallback is
*correct*. The achievable target is: **FIPS wins whenever a FIPS path exists, and
fallback frequency is measured in-product so regressions are visible.** Today several
paths are 0% FIPS *by construction* regardless of network health — that's the bug.
---
## Part A — why Cloud/FIPS "commonly falls back to Tor": ranked root causes
All verified live on 2026-07-27 (.116 local, .198, .228, Framework PT, x250s) plus a
full code audit of `core/archipelago/src/{fips,transport,federation,server.rs}`.
### RC0 — 🔥 The hardening firewall drops the peer-API port on every hardened node (PROVEN)
The fips0 default-deny baseline (`/etc/fips/fips.nft`) is opened by archipelago's
drop-in `80-web-ui.nft` (`fips/config.rs:236-255`) for **80 + 8443 + app ports only**.
The peer-API listener — which carries *all* federation sync, cloud browse/download,
mesh envelopes, DWN, invoices — is **`PEER_PORT = 5679`** (`fips/dial.rs:35`).
**5679 is not in the allowlist.** The drop-in's own comment claims "web UI + peer
API" but the peer API port was never added.
Live proof (2026-07-27):
- .116 nft chain: 5,965 dropped packets; .198: **28,670 dropped packets** — that's
peers' FIPS dials dying at the firewall.
- .198 → .116 `GET :5679/health`: **timeout (6s)** before; **HTTP 200 in 0.35s**
after `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`.
Same result in reverse direction (200 in 0.64s).
- Explains the exact fleet split in `federation/nodes.json`: hardened-baseline nodes
(Framework PT, .198, .228, x250-dev, x250-mad2) = `last_transport: tor`;
non-hardened nodes (Austin Sapien, X250-Beta, X250-PA) answer :5679 (404 from the
path allowlist = listener reachable) = `last_transport: fips`.
- Every dial to a hardened peer pays the 8s FIPS connect timeout
(`dial.rs:114`) ×2 (retry, `dial.rs:128-140`) → then Tor. That's the "Cloud takes
forever / shows Tor" experience.
**Fix (one line + reload):** add `tcp dport 5679 accept` to the drop-in in
`fips/config.rs` (use a constant shared with `dial.rs::PEER_PORT`, not a literal).
The drop-in reinstalls on every daemon config install, so it heals fleet-wide on OTA.
⚠️ Transient manual rules were inserted on .116 and .198 during diagnosis (2026-07-27)
— they vanish on the next `nft -f /etc/fips/fips.nft` reload or reboot; the code fix
makes them permanent.
### RC1 — .228 (Shorty's) runs fips 0.3.0-dev; the 0.4.1 fleet can't reach it
.228's daemon: `0.3.0-dev (rev 34e00b9f6e)`, both anchor links "connected", but its
ULA is 100% unreachable from 0.4.1 nodes (ping loss 100%). FIPS wire format is not
stable across revs (`docs/HANDOFF-2026-07-23-companion-apk-deploy.md:78`). Everything
to/from .228 rides Tor no matter what else we fix.
**Fix:** fleet fips-version audit + upgrade to v0.4.1 everywhere (in-product updater
exists: `fips/update.rs`; .deb path per `reference_vps2_fips_anchor`). Add a version
check to `fips.status` and surface a "peer daemon outdated" warning.
### RC2 — Direct LAN/endpoint peering is dead code + wrong port + stale seed anchors
Without direct links, all peer traffic hairpins through the vps2 anchor spanning
tree (observed: .116→.198 cold RTT 1.53.5s on the same LAN; also the wedged-anchor
latency-rot incident, `HANDOFF-2026-07-23:141-160`).
- **G1 — `lan_fips_anchors()` has never run.** It needs `PeerRecord.fips_npub`, but
`PeerRegistry::set_fips_npub` (`transport/mod.rs:302`) has **zero callers** — mDNS
TXT records only carry `did`/`pubkey`/`version` (`transport/lan.rs:50-54`). So the
"co-located peers form a direct link" feature (`anchors.rs:294-305`,
`server.rs:761-766`) is a fleet-wide no-op.
- **G2 — wrong UDP port.** `anchors.rs:293` dials `8668`, but the generated
fips.yaml binds UDP **2121** (`fips/config.rs:187`, `fips/mod.rs:130`). Even if G1
ran, it would dial a dead port. `.116`'s live `seed-anchors.json` still carries
`.198@192.168.1.198:8668` — **stale IP (LAN renumbered to 192.168.63.x) AND dead
port**; both manual entries are useless today.
- No Tailscale/alternate endpoint fallback when LAN is unreachable (the .116↔.198
fix of 2026-07-20 was hand-applied per-node config, never productized).
**Fix:** (a) `FIPS_UDP_PORT``crate::fips::PUBLISHED_UDP_PORT` + drift-guard test;
(b) hydrate `fips_npub` into the registry from federation storage (did-keyed join) so
`lan_fips_anchors` goes live with no wire change; (c) advertise the npub in the mDNS
TXT + `set_fips_npub` on resolve as the proper fix; (d) teach the LAN-anchor tick to
also try a peer's Tailscale/last-known-good endpoint when LAN fails (reviewed change
— this area got handoffs wrong twice, per memory).
### RC3 — No fast-fail on the hottest call sites; retry silently doubles every budget
- `content.browse-peer`**the Cloud page** — has NO `fips_timeout`
(`api/rpc/content.rs:363-366`): a cold FIPS path burns up to ~16.6s (8s connect +
600ms + 8s retry) before Tor even starts, against a UI deadline of 30s
(`Cloud.vue:720`) — and the frontend then retries ×3. Users see errors, not
fallback. 12 call sites total lack `fips_timeout` (browse/download/preview-peer,
`/blob`, DWN, node_message, rotation notifies).
- `dial.rs:128-140` runs 2 full-budget attempts, so `fips_timeout(6s)` really means
~12.6s everywhere.
**Fix:** wrap `send_with_retry` in a single `tokio::time::timeout(fips_attempt_timeout())`
(call sites `dial.rs:455`, `dial.rs:488`; halve per-attempt client timeout), then add
`.fips_timeout(...)`: `content.rs:366` (6s), `content.rs:281` (8s), `content.rs:1139`
(6s), `typed_messages.rs:822` (8s), `dwn_sync.rs:188/213/272` (6s),
`node_message.rs:376` (8s), `node_message.rs:412` (4s), `tor/mod.rs:501` (6s),
`federation/handlers.rs:869` (6s). **Skip the three 900s streaming downloads**
(`content.rs:552/870/1061`, `proxy.rs:236`) — `dial.rs:311-319` documents why; the
retry-budget wrap covers their connect phase.
### RC4 — Two features are 100% Tor by construction (allowlist 404)
The peer listener path allowlist (`server.rs:1219-1239`) omits `/blob/<cid>` (mesh
file sharing, `typed_messages.rs:813-822`) and `/dwn/health` (step 1 of DWN sync,
`dwn_sync.rs:186`) → deterministic 404 over FIPS (`dial.rs:44-46` treats 404 as
fall-back) → deterministic Tor, after paying the full FIPS cost. Both endpoints are
already cryptographically gated, so they meet the allowlist's stated criterion.
**Fix:** add `|| path.starts_with("/blob/") || path.starts_with("/dwn/")`; extend the
existing test block at `server.rs:1935-1945` (assert `/blob/abc` + `/dwn/health`
allowed, `/blobber` + `/dwnx` denied).
### RC5 — Inbound listener can't heal; anchor flap = 5-minute Tor window; probe overhead
- `peer_late_bind_loop` returns after first successful bind (`server.rs:1203`) and
`accept_loop` `continue`s on errors forever (`server.rs:1249-1258`): a fips0
teardown/re-key leaves the node inbound-dead until process restart → **every peer**
falls back to Tor against it.
- Nothing reacts to anchor-link drops: anchors re-apply only on the 300s tick
(`server.rs:731`); worst-case 5min Tor-only after a flap (the historic "link dead
timeout 30s" flapping made this chronic).
- `is_service_active()` spawns up to 2 `systemctl` per FIPS attempt *and* per peer
per 25s warm tick (`dial.rs:284-294`); `warm_path` skips peers without
`fips_npub` in federation storage (`fips/mod.rs:88-95`); `anchors::apply` is
serial with unbounded subprocess waits (`anchors.rs:234-283`).
**Fix:** rebindable listener; a ~25s connectivity watcher (reuse
`service::peer_connectivity_summary`, `fips/service.rs:178-207`) that re-applies
anchors immediately on a connected→disconnected edge with bounded backoff; 10s TTL
cache for `is_service_active` (mirror `transport/fips.rs:24-107`); warm the union of
federation+registry peers; make `apply()` concurrent with per-connect timeouts.
### RC6 — Zero observability: fallbacks are invisible, so "uptime" is unfalsifiable
Fallbacks log at `debug!` only (`dial.rs:458,491`); no counters; `last_transport` is
written by only 7 of ~20 call sites and **never read** to influence anything
(`storage.rs:120-147`). The parallel `TransportRouter` system can't even see FIPS
(`FipsTransport` is never constructed — `server.rs:422-442` registers Tor/Mesh/LAN
only).
**Fix:** per-reason fallback counters (F1 no-npub / F2 service-inactive / F3
DNS-fail / F4 connect-fail / F5 404 / F6 5xx) surfaced in `fips.status` + `info!`
logs with a `reason` field; call `record_peer_transport` from all peer-dial sites;
UI: per-peer transport badge on Cloud (the response already carries `transport`
`content.rs:392-400` — Cloud.vue currently throws it away at `:716-721`).
---
## Part A — execution phases
### Phase A0 — fleet triage (no release needed; do first, validates everything)
1. Fleet audit: `fipsctl --version` + `nft list table inet fips` + `ss -tlnp | grep 5679`
on every node (roster: `reference_test_deploy_roster`).
2. Transient `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`
on hardened nodes (already done on .116 + .198, 2026-07-27) — instant fleet-wide
FIPS recovery while the code fix rides the OTA.
3. Upgrade .228 (and any other 0.3.x) fips daemon to v0.4.1.
4. Regenerate/clean stale `seed-anchors.json` on .116 (dead 192.168.1.x + :8668 entries).
5. Baseline measurement: for each node pair, `content.browse-peer` time + transport.
### Phase A1 — P0 code (one commit, mechanical, offline-testable)
1. **nft drop-in: open 5679**`fips/config.rs` (share the constant with
`dial.rs::PEER_PORT`). ← RC0
2. **Allowlist `/blob/`, `/dwn/`**`server.rs:1219-1239` + tests. ← RC4
3. **`FIPS_UDP_PORT` = `PUBLISHED_UDP_PORT` (2121)** — `anchors.rs:293` + drift-guard
test against `render_config_yaml()`. ← RC2-G2
4. **Un-deaden `lan_fips_anchors`** — hydrate `fips_npub` from federation storage in
`server.rs:761-766`; then mDNS TXT `fips` key + `set_fips_npub`
(`transport/lan.rs:50-54`, `lan.rs:96-108`, `LanTransport::new` 4th arg via
`crate::identity::fips_npub(&data_dir.join("identity"))`). ← RC2-G1
5. **Retry-budget wrap + `fips_timeout` on 12 call sites** (list in RC3). ← RC3
Verify: `cd core && cargo test -p archipelago` — watch `test_rendered_yaml_exact_snapshot`
(`config.rs:419`) + `test_render_is_deterministic` (`config.rs:476`); item 3 must
not change rendered output.
### Phase A2 — telemetry BEFORE tuning (second commit)
6. Fallback counters by reason + `fips.status` exposure + `info!` reason logs;
`record_peer_transport` from all sites. ← RC6 (gives the baseline that makes A3
measurable and "100%" falsifiable)
### Phase A3 — resilience (third commit, measured against A2 baseline)
7. `is_service_active` 10s TTL cache; warm-path union + `warm_path_unchecked`.
8. Link-state watcher → immediate anchor re-apply on drop (replaces waiting for the
300s tick); concurrent `apply()` with subprocess timeouts.
9. Rebindable peer listener (`server.rs:1203`, `1249-1258`).
10. (Reviewed, separate PR) endpoint-fallback for direct peering: LAN → Tailscale →
last-known-good, npub-keyed. Mesh-routing area — needs careful review per memory.
### Phase A4 — verification gate (on nodes, before tag)
- On .116/.198/framework-pt/.228: `content.browse-peer` to every peer must return
`transport: "fips"` with sub-second latency (LAN pairs) / <3s (WAN), 20/20 calls.
- Kill the fips daemon on one node → calls fall back to Tor gracefully within the
fast-fail budget (<8s), UI shows partial results, no errors.
- Restart daemon → FIPS recovers within one watcher tick (~25s), verified in
`fips.status` counters.
- Flap the anchor link (drop vps2 route) → direct LAN pairs keep FIPS via their
direct link (G1 fix proof).
- Add these as `tests/multinode/` cases per `docs/multinode-testing-plan.md`; also
fix the known `node_rpc()` missing `--max-time` (tracker item).
---
## Part B — optimistic loading + state management (frontend)
Full audit: Pinia exists but pages fetch-on-mount with `loading=true` spinners;
`Dashboard.vue:89` keys the router-view by `route.path`, so **every navigation
unmounts and refetches everything**; no KeepAlive/onActivated anywhere; no dedup,
no abort, no SWR layer. Four hand-rolled cache implementations already exist and
prove the pattern (`useFleetData.ts:198-231` sessionStorage hydrate;
`homeStatus.ts` sticky-ready loadState; `Home.vue:591-621` wallet localStorage
snapshot; `curatedApps.ts:21-77` TTL cache). `SkeletonCard.vue` exists, imported by
zero files.
### B1 — one shared primitive: `useCachedResource` composable + `resources` Pinia store
Semantics (generalize `homeStatus.ts` + `useFleetData.ts`):
- Keyed resource: `{ data, loadState: idle|loading|ready|error|refreshing, fetchedAt, error }`.
- **Hydrate synchronously** from memory (Pinia, survives navigation) → sessionStorage
snapshot (survives reload) → then revalidate in background.
- Sticky-ready: once `ready`, never regress to `loading`
(`loadState = loadState==='ready' ? 'ready' : 'loading'` — the `homeStatus.ts:80` idiom);
keep-last-known-value on error with a stale badge (age from `fetchedAt`).
- TTL per resource; `revalidateOnFocus` + on WS push (debounced, the
`Home.vue:539-542` pattern); explicit `invalidate(key)` for mutations.
- Optimistic mutation helper: apply → RPC → rollback on error (generalize
`TransportPrefsCard.vue:112-127`).
### B2 — rpc-client upgrades (`src/api/rpc-client.ts`)
- `AbortSignal` in `RPCOptions` (today the AbortController at `:87` is timeout-only)
→ abort-on-unmount for fan-outs.
- In-flight dedup keyed `method+JSON(params)` — collapses duplicate concurrent calls.
- Per-call `maxRetries` override; set `maxRetries: 1` for `content.browse-peer` /
`preview-peer` (retry×3 on a 30s timeout is why one slow peer = 90s spinner).
### B3 — Cloud page conversion (worst offender, the marquee win)
- Move `sectionCounts`, `peerNodes`, `myFiles`, `peerFiles`, `paidItems` out of
`Cloud.vue` component state (`:403,:476,:582,:689,:427`) into the cached store —
instant render on revisit, background refresh.
- **Incremental per-peer fan-in**: render each peer's card as its
`content.browse-peer` resolves (today `Promise.allSettled` at `:708-747` blocks on
the slowest peer). Per-peer states: cached/fresh/loading/unreachable.
- **Surface `transport` per peer** (already in the response, discarded at `:716-721`):
FIPS/Tor badge + latency — this is also the fleet-wide FIPS-uptime dashboard the
user asked for, for free.
- Skeleton cards (revive `SkeletonCard.vue`, copy `FileGrid.vue:3-19` shimmer) instead
of spinners for counts/folders/peer grids.
- Stop `CloudFolder.vue:307-319` calling `cloudStore.reset()` on every folder entry —
cache per-path listings, navigate renders cache + revalidates.
- `PeerFiles.vue`: persist catalog + preview cache in the store; cap the
`preview-peer` fan-out (`:832-841`, currently unbounded) with a small concurrency
queue + abort-on-unmount.
### B4 — roll out to remaining offenders (in audit order)
PeerFiles → Web5 wallet/ecash/LND slices → Monitoring → Lightning channels
(`LightningChannelsPanel.vue:650`) → Federation (already has `{showLoader:false}`
just adopt the store) → Server → Credentials/OpenWrtGateway/ContainerApps.
`Apps.vue`/`Marketplace.vue`/`Fleet.vue` are already good; don't touch.
### B5 — freshness via the existing push channel
`/ws/db` firehose + `sync.ts` JSON-patch already exist. Wire `useCachedResource`
revalidation to relevant WS pushes (debounced 800ms), keep the 30s staleness
reconciliation as backstop. No new backend needed for v1; a per-topic subscribe can
come later.
### Part B verification (on nodes)
- Navigate Cloud → Apps → Cloud: peer files render instantly from cache (0 spinner),
refresh indicator while revalidating, updated data lands without layout jump.
- One unreachable peer: its card shows stale/unreachable state; other peers render
immediately (no 30s all-or-nothing).
- Kill backend mid-view: stale data stays visible with age badge; recovery
revalidates automatically.
- Hard reload: sessionStorage hydrate paints before first RPC completes.
---
## Sequencing for the next release
1. **A0 now** (fleet triage + transient nft rules + .228 daemon upgrade + baseline).
2. **A1 + A2** land together (P0 fixes + telemetry) → deploy to .116/.198 →
Phase A4 checks on the pair → framework-pt → full fleet.
3. **B1 + B2 + B3** (composable + rpc-client + Cloud) in parallel with A-testing —
frontend-only, verifiable against .116 dev (`reference_neode_ui_dev_testing`).
4. **A3** after telemetry baseline exists; **B4/B5** ride the same or next OTA.
5. Gate: Phase A4 checklist green + Part B verification on-device + existing
single-node gate stays green → tag/OTA per ship ritual.
## Success criteria
- `content.browse-peer` transport = fips for ≥99% of calls between healthy 0.4.1
nodes over 24h (measured by the new counters), Tor reserved for genuinely
FIPS-unreachable peers (.116-WiFi-class networks).
- Cloud revisit paints in <100ms from cache; fresh data within one revalidate.
- Fallback counters visible in `fips.status` so regressions are caught on the
dashboard, not by users.

View File

@ -0,0 +1,108 @@
# License Compliance Audit — Open-Source Release
Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/*, Android companion, image-recipe ISO, docker/, app-catalog, reticulum-daemon, demo/) plus the external FIPS source and registry-mirrored images.
**Verdict:** the dependency graph is almost entirely permissive (MIT/Apache/BSD) and compatible with a free open-source release. But the repo is not releasable as-is: it has **no license of its own**, one **LGPL Rust dependency**, several **non-redistributable committed assets** (proprietary fonts, unknown-rights media), and **missing attribution machinery**. Everything below is ordered by severity.
---
## STATUS UPDATE — 2026-07-23
**DONE:**
- MIT adopted. Root `LICENSE` + `NOTICE` added; `license = "MIT"` in all 5 workspace crates (archy-fips-core already had it); `"license": "MIT"` (+ `"private": true`) in all 4 package.json files.
- Deleted: `Courier_New/`, `Benton_Sans/`, `Redacted/` fonts; `wireguard.apk`; `atob.s9pk`; obsolete `test-install.sh` (all git-rm'd; also removed from `web/dist`).
- Media provenance resolved: all demo music/photos/posters, UI sfx, backgrounds, and intro video are the author's original work — recorded in `demo/content/README.md` and `NOTICE`.
- Meshtastic device artwork attributed (`mesh-devices/ATTRIBUTION.md` + NOTICE); icon attribution added (`assets/icon/ATTRIBUTION.md`: game-icons.net CC BY 3.0, pixelarticons MIT).
- Reticulum decision: include + disclose (NOTICE states the Reticulum License restrictions and that it applies only to the optional daemon).
- indeedhub: deferred — partnership in place; license the submodule before/at public release.
- License inventories generated: `core/THIRD-PARTY-LICENSES.md` (649 crates) and `neode-ui/THIRD-PARTY-LICENSES.md` (runtime deps + fonts + vendored).
**REMAINING (code changes, awaiting review — see sections below for detail):**
1. Replace `zbase32` (LGPL-3.0+) with `z32` or original impl — §2.
2. Swap `redis:7.4.8` → Valkey in `scripts/image-versions.sh` and deploys — §3.
3. Delete dead StartOS-derived crates `core/{js-engine,container-init,models,helpers}` — §4.
4. Attribution build integration: cargo-about in CI → ship full license texts in ISO; vite/rollup license plugin (or UI licenses page) for the web bundle; Android OSS-licenses screen — §5.
5. Release-checklist items: per-release Debian source pointer (snapshot.debian.org), catalog `license`/`sourceUrl` fields, restrict ISO image bundling to the audited list — §6.
6. Before repo goes public: purge deleted fonts/APKs from git history (`git filter-repo`), and verify game-icons author credit.
---
## 1. BLOCKER — the project has no license
There is no `LICENSE`/`COPYING` file anywhere in the repo. No crate in `core/` declares a `license` field; none of the four `package.json` files do either (and the three `apps/*` packages aren't even `private: true`). Until fixed, the code is "all rights reserved" — publicly visible, but legally not open source and not usable by anyone.
**Do:**
- [ ] Choose a license. **Recommendation: MIT** — the Bitcoin-ecosystem norm (Bitcoin Core, LND are MIT), maximally compatible with everything found in the graph. (Alternatives: Apache-2.0 adds a patent grant; GPLv3 if copyleft is desired — nothing in the deps prevents any of these.)
- [ ] Add `LICENSE` at repo root with the year and copyright holder.
- [ ] Add `license = "MIT"` to all five workspace member `Cargo.toml`s (archipelago, container, openwrt, performance, security) and `Android/rust/archy-fips-core` (declares MIT but ships no license file — add one).
- [ ] Add `"license": "MIT"` to `neode-ui/package.json` and `apps/{morphos-server,router,did-wallet}/package.json`.
## 2. BLOCKER — copyleft dependency that must be replaced
- [ ] **`zbase32 0.1.2` — LGPL-3.0+** — the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. **Replace with the MIT `z32` crate** or a ~30-line original alphabet-substitution implementation.
No GPL, AGPL, SSPL, or unlicensed crates exist anywhere else in the Rust graph. (`r-efi` and `self_cell` list LGPL/GPL only as options in OR-expressions — elect MIT/Apache, no action.)
## 3. BLOCKER — committed files we may not redistribute
Remove from git (and **purge from history** before the repo goes public — they're in past commits):
- [ ] `neode-ui/public/assets/fonts/Courier_New/` — Monotype proprietary font, no license, **unused in CSS**. Delete.
- [ ] `neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf` — commercial Font Bureau typeface, no license, unused. Delete.
- [ ] `neode-ui/public/packages/wireguard.apk` (17 MB) — official WireGuard Android APK containing GPL-2.0 `libwg` components; redistribution triggers GPL source-offer. **Unreferenced since the FIPS migration** — delete.
- [ ] `neode-ui/public/packages/atob.s9pk` (24 MB) — Start9 service package, unknown license, referenced only by a test script. Delete.
- [ ] `demo/content/music/` (18 full tracks, ~150 MB) and `demo/peer-media/` (17 photos/book covers/film posters) — no recorded rights. If they're your own/AI-generated work, document that in a `demo/content/README`; otherwise remove.
- [ ] `neode-ui/public/assets/video/video-intro.mp4`, `Kratter.MP3`, photographic `bg-*.jpg` backgrounds, UI/arcade sound effects in `assets/audio/` — same: document provenance (user-made per project convention) or replace. `welcome-noderunner.mp3` is ElevenLabs TTS — their commercial-use terms allow this on paid plans; note it.
- [ ] **Registry: `redis:7.4.8`** (`scripts/image-versions.sh` `REDIS_IMAGE`) — Redis ≥ 7.4 is RSALv2/SSPLv1, **not open source**; re-hosting it on your registry is redistribution under a restricted license. **Switch to Valkey** (BSD-3, already mirrored) everywhere.
## 4. VERIFY — unknown/third-party provenance
- [ ] **`neode-ui/public/assets/img/mesh-devices/` (36 SVGs)** — almost certainly Meshtastic project device artwork (meshtastic/web is GPL-3.0). Confirm source; either replace with original art or comply with the upstream license + attribution.
- [ ] **`neode-ui/public/assets/icon/`** — `barbarian.svg`, `batteries.svg` match game-icons.net (**CC BY 3.0 — visible attribution required**); pixel-style icons match pixelarticons (MIT). Confirm and add attribution, or replace.
- [ ] `Redacted/redacted.regular.ttf` — upstream is SIL OFL 1.1 but no license file is shipped. Add `OFL.txt` or delete (unused).
- [ ] **indeedhub** — submodule (private gitea) not checked out; no known license, yet `indeedhub{,-api,-ffmpeg}:1.0.0` images are distributed via registry/ISO. `indeedhub-ffmpeg` implies a bundled FFmpeg (LGPL/GPL → source-offer obligations). Must license the project and audit the ffmpeg build before public release.
- [ ] `minmoto/fmcd` v0.8.0 and `ark-bitcoin/bark` (barkd) — binaries redistributed in your images; verify upstream licenses (bark claims Apache-2.0/MIT dual) and include their notices.
- [ ] **Start9/StartOS heritage**`core/{js-engine,container-init,models,helpers}` are StartOS-derived (embassy paths, s9pk handling). start-os is MIT → attribution required if kept. **Better: delete these four crates** — they are not workspace members, cannot compile (broken `../../patch-db` path dep), and carry an unpinned `yajrc = "*"` git dep on a moving branch. Deleting removes both the attribution question and dead code.
- [ ] **Reticulum (RNS 1.3.5 + LXMF)** — verified: custom "Reticulum License" — MIT-style **plus field-of-use restrictions** (no systems designed to harm humans; no AI/ML training-dataset use). Redistribution is permitted, so shipping the PyInstaller `archy-reticulum-daemon` binary is fine **if** the license text is included with it — but the OS cannot claim to be 100 % OSI-open-source while bundling it. Options: include + disclose (recommended, matches "plan for decentralization" honesty), or make the daemon an optional download.
## 5. REQUIRED — attribution / notice machinery (currently absent)
Nearly every permissive license (MIT/BSD/ISC/Apache) requires reproducing copyright + license text **in distributed binaries** — and right now every distribution channel strips them:
- [ ] **Rust binaries** (649 crates, ~85 % MIT/Apache dual): generate `THIRD-PARTY-LICENSES` with `cargo-about` (or `cargo-license`) in CI; ship it in the ISO at e.g. `/usr/share/doc/archipelago/`. Include **ring's three license files** (LICENSE, LICENSE-BoringSSL, LICENSE-other-bits) and note the system OpenSSL (Apache-2.0) linked via `ssh2`.
- [ ] **Web bundle**: Vite/esbuild strips all `@license` comments from `web/dist`. Add `rollup-plugin-license`/`vite-plugin-license` to emit a third-party attribution file, or add an "Open-source licenses" page in the UI. Runtime deps needing notices: vue/vue-router/pinia/vue-i18n (MIT), d3 (ISC), leaflet (BSD-2), dompurify (elect Apache-2.0 of its MPL/Apache dual), fuse.js (Apache-2.0), qrcode/qr-scanner/qrloop/buffer/fast-json-patch (MIT).
- [ ] **Android APK**: `packaging.excludes` strips `META-INF` license texts and there is no licenses screen. Add an OSS-licenses screen or bundled `licenses.txt` covering AndroidX/Compose/OkHttp/ZXing (Apache-2.0), **fips © 2026 Johnathan Corgan (MIT — the core of the VPN feature)**, tokio/tracing (MIT), subtle (BSD-3), tun (WTFPL — permissive, just list it), secp256k1 family (CC0). Generate the Rust side from the committed `Cargo.lock` with cargo-about.
- [ ] **AIUI demo bundle** (`demo/aiui/` — committed minified build): bundles Mermaid, Cytoscape, KaTeX, D3, Lodash, Workbox (all MIT/BSD). Add a `THIRD-PARTY-LICENSES` file next to it (or rebuild with a license plugin).
- [ ] Keep the intact MIT headers in the two vendored `qrcode.js` copies (docker/lnd-ui, docker/electrs-ui) — already compliant, don't minify them.
- [ ] Fonts kept: Montserrat (OFL.txt present ✓), Open Sans (Apache LICENSE.txt present ✓) — keep license files adjacent to the font files in dist.
## 6. REQUIRED — distribution-level obligations (ISO & registry)
The ISO redistributes a full Debian (trixie) system plus ~29 container image tarballs; the private registry re-hosts upstream images. Re-hosting = redistribution, same obligations as bundling.
- [ ] **GPL source offer for the ISO** — kernel, GRUB, busybox/live-boot, coreutils, nftables, cryptsetup, wireguard-tools, SYSLINUX `isohdpfx.bin`, etc. Easiest compliance: keep `/usr/share/doc/*/copyright` (the build already does ✓) **and** publish, per release, either a mirror of the exact Debian source packages (`apt-get source` snapshot / snapshot.debian.org pointer) or a written offer in the docs. Add this to the release checklist.
- [ ] **AGPLv3 images redistributed** (mempool, Grafana, Vaultwarden, SearXNG, PhotoPrism, Nextcloud, Immich, CryptPad, MinIO): AGPL compliance = make corresponding source available. You ship a **modified** mempool-frontend (`docker/mempool-frontend` entrypoint patch) — the patch is in-repo, so compliance is met once the repo is public; state this in docs. For unmodified images, link upstream sources in the app catalog.
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, AdGuard Home, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
- [ ] **Non-free firmware** (firmware-realtek/iwlwifi/misc/linux-nonfree, intel/amd microcode): redistributable but proprietary — disclose in docs ("includes non-free firmware for hardware support"), like Debian's own non-free-firmware ISOs do.
- [ ] The ISO build's live-server image capture (`podman save` of whatever matches on the dev server) is a compliance hazard — bundle only from the audited image list.
- [ ] FIPS daemon (jmcorgan/fips v0.4.1, MIT ✓) and nostr-rs-relay binary (MIT ✓): include their license texts in the notices bundle.
## 7. Housekeeping (supports compliance)
- [ ] Add lockfiles + pinned versions in `apps/*` (currently floating `^` ranges, violating the project's own pinning rule) — reproducibility is also what makes license audits stay true.
- [ ] `Android` fips dep is pinned to a personal fork rev (`9qeklajc/fips-native@46494a74`) — mirror or vendor it so outside contributors can build.
- [ ] Move `@types/dompurify` to devDeps; refresh stale `neode-ui/node_modules`.
- [ ] Add a `NOTICE` file at root naming: fips (Johnathan Corgan, MIT), Start9 start-os (if any derived code remains), Kazuhiko Arase qrcode.js, font licenses, icon attributions.
- [ ] Consider CI license gating: `cargo-deny` (Rust) + `license-checker` (npm) with an allowlist, so new copyleft deps are caught at PR time.
---
## Quick reference: what's already clean
- All 649 Rust crates except `zbase32`: permissive or dual-licensed.
- All 833 npm packages in neode-ui: no GPL/AGPL anywhere; only dev-tool LGPL (sharp's libvips, never distributed).
- Android Gradle deps: 100 % Apache-2.0, all pinned, no Play Services/telemetry.
- FIPS mesh: MIT (© 2026 Johnathan Corgan) — keep notice.
- js-engine binds deno_core (MIT) as a crate, nothing vendored — moot if dead crates are deleted.
- reticulum-daemon Python is original code; obligations attach only to the PyInstaller binary (see §4).
- Bitcoin Core/Knots, LND, BTCPay, Electrs, Fedimint, core-lightning, Gitea, Home Assistant, Tailscale, Portainer, Uptime-Kuma, filebrowser, ollama, penpot: MIT/Apache/BSD/Zlib/MPL — link + notice is enough.

View File

@ -27,6 +27,75 @@ is GREEN and must stay green. Re-run after any orchestrator/lifecycle change (Ph
especially). All cargo verification uses `--all-features` to match CI. Stage by explicit especially). All cargo verification uses `--all-features` to match CI. Stage by explicit
path, never `git add -A` (shared tree). path, never `git add -A` (shared tree).
## Current local pass status
This branch is replayed on top of `origin/main` as `public-prelaunch`.
Completed locally in this pass:
- Redacted the two tracked Anthropic API key literals from
`scripts/setup-aiui-server.sh` and
`image-recipe/_archived/build-auto-installer-iso.sh`.
- Removed `Android/app/debug.keystore` and `core/.env.production` from the
source tree; copies were preserved in
`~/Desktop/archipelago-sensitive-backup-2026-07-27/`.
- Reworked `scripts/audit-secrets.sh` to scan tracked source more aggressively
and to catch non-example env files and credential file patterns.
- Reworked `scripts/validate-app-manifest.sh` so the current `app:` manifest
schema can be audited without a Python `PyYAML` dependency.
- Updated root/community docs, CI, PR template, app developer notes, and
container/deployment docs toward public contributor expectations.
- Fixed native FIPS activation fallback: nodes that have the packaged
`fips.service` but not `archipelago-fips.service` now start the available
unit instead of repeatedly failing activation against a missing unit. This
now covers startup, supervisor self-heal, manual dashboard start/reconnect,
and post-onboarding activation. The UI now labels the action as `Start`
instead of making native FIPS look like an installable app.
- Fixed the FIPS app-port relay design so it binds relays to the node's FIPS
ULA instead of wildcard `[::]`, avoiding collisions with Podman-published app
ports such as FileBrowser `8083` and Botfights `9100`.
- Added `docs/nostr-git-source-hosting.md`, a NIP-34/ngit/GRASP source hosting
plan using a Bitcoin Core-style maintainer model: public review and easy
forks, with canonical merge rights held by a small signed maintainer set.
Verified locally:
- `./scripts/audit-secrets.sh` passes.
- Full `apps/*/manifest.yml` repository audit passes with warnings only.
- `bash -n` passes for the edited shell scripts.
- Targeted FIPS dashboard vitest passes.
- Targeted Rust tests for FIPS service unit detection and FIPS app relay
address selection pass.
Verified on a Linux Archipelago verification node:
- Native FIPS was restored by starting the already-installed packaged
`fips.service`; the daemon became active and joined the FIPS tree.
- Correct local lifecycle API endpoint is HTTP, not HTTPS
(`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http`).
- Read-only lifecycle run progressed past login and confirmed required
containers, Bitcoin RPC, ElectrumX TCP, and manifest port-drift checks, but
did not complete cleanly: `botfights` and `filebrowser` remained in
`restarting` longer than the matrix window, and the LND `lncli getinfo`
probe hung. Do not run the destructive gate until those live-node issues are
understood.
- After the node updated to `1.7.116-alpha`, `botfights`, `filebrowser`, and
`lnd` were active/running and ports `8083`/`9100` were held by Podman's
`rootlessport` as expected. The packaged `fips.service` remained installed
and enabled but inactive, so the native FIPS service fallback should still
ship before the public launch.
Still required before public publish:
- Rotate/revoke compromised credentials listed in Phase 0.
- Finish Phase 1 password/node/token sanitization beyond the two API keys.
- Publish from fresh history after the sanitized tree is final.
- Run full Rust, frontend, Android, and lifecycle gate verification.
- Resolve the live-node lifecycle blockers above, then rerun the read-only
suite followed by the destructive gate only on an approved verification node.
- Decide the canonical Archipelago maintainer npub and merge-maintainer npub
list before publishing the Nostr Git source-hosting workflow.
--- ---
## Phase 0 — Credential rotation (immediate, independent of the repo) ## Phase 0 — Credential rotation (immediate, independent of the repo)

View File

@ -12,6 +12,33 @@ Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer:
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions. owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
## Checkpoint 2026-07-28 — RNode connect + names FIXED, live-verified E2E (read this first)
The fleet reflash back to RNode firmware exposed a stack of bugs that made Reticulum
unusable on CP2102-bridged boards (Heltec V3 etc.) and left every archy node nameless on
RNS. All fixed in `a8c4694c` (backend) + `3f76b496` (UI), live-verified on archi-dev-box
and archy-x250-dev with a real RNode-to-RNode LXMF message (`transport: "reticulum"` in
mesh-messages) plus a cross-transport reply:
1. **probe_rnode boot race** — serial open pulses DTR/RTS via the USB-UART bridge → ESP32
power-cycles → KISS DETECT written 300ms later is eaten during ~2.5-3s of boot. Fix:
immediate probe (fast path) + drain-until-quiet boot settle + second DETECT window.
2. **configure() was a no-op on a running listener** (only enable/disable restarted it) —
the setup modal's apply/keep-as-is and every rename did nothing until process restart.
3. **Name propagation**`config.advert_name` had no reader; `server.set-name` never
reached mesh; daemon display name fixed at spawn to the "Archy" default; the ARCHY:2
announce blob REPLACED the LXMF name. Now: announces carry msgpack
`[name, stamp_cost, sf, ARCHY-blob]` (Sideband-compatible, blob invisible to stock
clients), daemon has a `set_name` verb, renames bounce the session live.
4. **Daemon-death detection** (was invisible up to the 30-min RX-stall watchdog),
**modal re-trigger loop** (plugged_at used tty mtime → bumps on every open; now
btime/ctime), **ARCHY:2 federation-name clobber**, **mesh.refresh RPC** (Refresh button
now actually re-queries the radio), **Meshtastic mesh.broadcast now sends NodeInfo**.
Still open here: legacy-format peers (old fleet builds) show as `Reticulum <hex4>` until
they OTA; RNode RF params still daemon-hardcoded (EU-868 869.525/125k/SF8/CR5); Phase 4
multi-radio; duty-cycle guard.
## Status at a glance ## Status at a glance
| Phase | What | Status | | Phase | What | Status |

View File

@ -146,7 +146,7 @@ app:
path: /health path: /health
interfaces: interfaces:
main: main:
type: web type: ui
port: 8090 port: 8090
``` ```

View File

@ -110,6 +110,61 @@ lands].
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic). 2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves. 3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
tested on real hardware vs. code-reviewed only as this is run.
1. ❑ From the hot-swap modal's step 1 (device already probed), press
**Flash Firmware…** → new step shows firmware-family + board pickers and
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
family, board, AND the checkbox are all set.
2. ❑ Confirm what's currently on the test stick via the existing probe
BEFORE flashing it — don't flash the only known-good device without a
fallback board on hand.
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
only exercise a primary/in-use stick once the flow is proven safe.
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
tail update live, ends at "Flash complete".
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
the esp32s3 release zip.
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
completes (no raw esptool erase/write step for this family — see
`mesh/flash.rs` doc comment).
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
by vid:pid. Board auto-detect should fail closed for it every time
(manual board selection required, "couldn't confirm automatically"
warning shown) — this is expected steady-state behavior, not a gap to
close later.
8. ❑ After a successful flash, the modal automatically re-probes and shows
the NEW firmware's badge/details — same as unplugging and replugging
(Section E item 3), but without physically touching the cable.
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
or point at a bad cached asset) — confirm the error surfaces in the
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
rnodeconf command) actually get the board back to a flashable state.
10. ❑ Cancel button only appears (and only works) while still in the
"Downloading firmware…" stage — once erasing/writing starts, no cancel
affordance is offered.
11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash
(e.g. kill network access mid-download to force a failure), confirm the
mesh listener does NOT auto-resume — `journalctl -u archipelago` should
show a single `Leaving mesh listener stopped after failed flash` line
and then go quiet for that device, not a repeating `mesh::serial:
Opened serial port... Starting Meshcore handshake` cycle every few
seconds. Reconnect manually via the hot-swap modal afterward and confirm
it connects normally (the board itself should be untouched — the
download fails before esptool/rnodeconf ever runs).
12. ❑ Separately, force a device to flap connected/disconnected a few times
in under 20s each (e.g. a marginal USB connection) and confirm
`reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...)
rather than resetting to 5s on every attempt — see
`STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`.
--- ---
After this passes: fold the batch + other agent's work into the next release After this passes: fold the batch + other agent's work into the next release

View File

@ -74,9 +74,10 @@ archy/
### Prerequisites ### Prerequisites
- **macOS** (development machine): Node.js 20+, npm - Node.js 20+ and npm for frontend development.
- **Linux server** (`192.168.1.228`): Rust toolchain, Podman, Nginx, Debian 13 - Rust stable for backend development.
- SSH key: `~/.ssh/archipelago-deploy` - Linux with Podman, systemd, and Nginx for host integration work.
- Debian 13 is the target runtime for release validation.
### Local Frontend Development ### Local Frontend Development
@ -86,17 +87,18 @@ npm install
npm start # Vite dev server on :8100, mock backend on :5959 npm start # Vite dev server on :8100, mock backend on :5959
``` ```
The dev server at `http://localhost:8100` uses a mock backend. Login with `password123`. The dev server at `http://localhost:8100` uses a mock backend.
### Deploying Changes ### Deploying Changes
**Never build Rust on macOS.** The deploy script rsyncs source to the Linux server and builds there. Release and host-integration builds should run on Linux. The deploy script rsyncs
source to a configured Linux target and builds there.
```bash ```bash
# Deploy to live server (builds backend + frontend, restarts services) # Deploy to the configured primary target (builds backend + frontend, restarts services)
./scripts/deploy-to-target.sh --live ./scripts/deploy-to-target.sh --live
# Deploy to both servers # Deploy to both configured targets
./scripts/deploy-to-target.sh --both ./scripts/deploy-to-target.sh --both
``` ```
@ -114,14 +116,16 @@ The deploy script:
# Frontend tests # Frontend tests
cd neode-ui && npm test cd neode-ui && npm test
# Backend tests (on dev server via SSH) # Backend tests
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \ cd core && cargo test --all-features
"cd ~/archy/core && cargo test --all-features"
# Both # Both
./scripts/run-tests.sh ./scripts/run-tests.sh
``` ```
`scripts/run-tests.sh` can run backend tests on a Linux target when
`ARCHIPELAGO_SSH_HOST` and `ARCHIPELAGO_SSH_KEY` are set.
## Adding a New RPC Endpoint ## Adding a New RPC Endpoint
### 1. Create the Handler ### 1. Create the Handler
@ -200,7 +204,7 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string
```bash ```bash
./scripts/deploy-to-target.sh --live ./scripts/deploy-to-target.sh --live
curl -X POST http://192.168.1.228/rpc/v1 \ curl -X POST http://<node-host>/rpc/v1 \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-b "archipelago_session=YOUR_SESSION" \ -b "archipelago_session=YOUR_SESSION" \
-d '{"method":"mymodule.action","params":{"name":"test"}}' -d '{"method":"mymodule.action","params":{"name":"test"}}'
@ -309,5 +313,5 @@ mod tests {
2. Make changes following the standards above 2. Make changes following the standards above
3. Test locally: `cd neode-ui && npm test` 3. Test locally: `cd neode-ui && npm test`
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live` 4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
5. Verify at `http://192.168.1.228` 5. Verify on your configured development target
6. Commit with conventional format: `feat: add my feature` 6. Commit with conventional format: `feat: add my feature`

View File

@ -0,0 +1,252 @@
# Nostr Git Source Hosting Plan
This plan describes how Archipelago can publish and accept contributions to its
source code through `ngit`, NIP-34, and GRASP while keeping the developer
experience inside Archipelago.
## Goals
- Publish Archipelago source from a sanitized, fresh-history repository.
- Make the in-app registry the primary onboarding path for contributors.
- Let contributors clone, branch, push PR branches, open PRs, and discuss issues
with a Nostr identity from their Archipelago node.
- Follow the Bitcoin Core development model: broad public review and easy forks,
with canonical merge authority held by a small maintainer set.
- Give contributors full read, fork, and proposal rights, but no direct merge
rights on the canonical repository.
- Keep the official maintainer identity and merge authority separate from user
node identities.
## Current Building Blocks
Archipelago already has most of the primitives needed for this:
- App manifests and the app registry already install developer tooling as
rootless Podman apps.
- The `gitea` app provides a conventional fallback Git UI and package registry.
- The app launcher already exposes a consent-gated NIP-07 bridge for launched
apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests.
- The backend exposes node and identity Nostr signing RPC methods.
- FIPS gives nodes a stable mesh identity and private transport path, but repo
announcements and PRs should remain NIP-34 compatible on normal Nostr relays.
- DWN protocol registration exists and can be used later for local contribution
metadata/cache, but should not be required for the first public workflow.
## Protocol Basis
Use existing Nostr Git conventions rather than inventing an Archipelago-only
protocol:
- NIP-34 repository announcement events identify repositories with kind `30617`.
- NIP-34 repository state events publish branch/tag refs with kind `30618`.
- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds
`1617`, `1618`, `1619`, `1621`, and `1630`-`1633`.
- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR
branches.
- GRASP servers provide Git Smart HTTP storage while Nostr events remain the
authority for repository identity, refs, PRs, issues, and maintainer state.
Primary references:
- https://nips.nostr.com/34
- https://docs.rs/crate/ngit/latest/source/README.md
- https://ngit.dev/grasp/
## Recommended Architecture
### Apps
Create two first-party apps:
- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`.
- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34
issues/PRs, opening branches, and submitting PR events.
The `archipelago-source` app should depend on `ngit`. It can also recommend
Gitea for users who want a conventional local web Git UI, but Gitea should not
be the source of truth for public contribution permissions.
### Contributor Onboarding
When the user installs `archipelago-source` from the registry:
1. Show a modal before first launch: "Contribute to Archipelago".
2. Explain that the app will use their Archipelago Nostr identity to clone and
sign contribution events.
3. Display the maintainer repository announcement, clone URL, maintainer npub,
and relay/GRASP endpoints.
4. Ask for consent to:
- fetch repository metadata from configured relays,
- clone source through `nostr://`,
- create local branches,
- sign NIP-34 issue/PR/comment events,
- push PR branches to approved GRASP servers.
5. Store approval per app origin, identity id, repository id, and relay set.
This should build on the existing NIP-07 app-launcher bridge, but use a more
specific permission scope than the generic sign-event approval.
### Identity And Permissions
Use four identity classes:
- `archipelago-maintainer`: an offline or tightly controlled Nostr key that
signs the canonical kind `30617` repo announcement and status/merge events.
- `archipelago-merge-maintainer`: one of the small set of maintainer npubs
allowed to advance canonical refs and publish valid merged/applied status.
- `archipelago-build`: release automation key for signed release artifacts and
CI status events. It must not have merge authority.
- `contributor`: user node or app-specific identity used for PRs, issues, and
comments.
Contributor rights:
- Clone the repository.
- Open issues.
- Push proposal branches using `pr/<npub>/<short-topic>` or `pr/<event-id>`.
- Publish NIP-34 PR/update/comment events.
- Rebase and update their own PR branch.
- Run local validation and attach status evidence.
Contributor restrictions:
- Cannot update `refs/heads/main` or release branches in canonical state.
- Cannot publish maintainer-valid merge/applied status.
- Cannot alter the canonical repository announcement.
- Cannot publish release catalog signatures.
Maintainer rights:
- Publish/update the canonical repo announcement.
- Publish canonical `refs/heads/main` state.
- Mark PRs merged/closed/draft via NIP-34 status events.
- Sign release tags and catalog updates.
Fork rights:
- Any contributor can create their own NIP-34 kind `30617` repository
announcement for a fork.
- Fork announcements should use the NIP-34 `u` tag to point back to the
canonical `archy` repository.
- The source app should make forking a first-class path: "Fork on Nostr", clone
the fork locally, push branches to the contributor's GRASP list, and open PRs
back to canonical Archipelago when they want review.
- Forks can have their own maintainer npubs, relays, policies, and release
cadence, but the app should clearly label them as forks unless signed by the
canonical maintainer set.
The GRASP server policy should enforce this by accepting pushes to maintainer
refs only when backed by signed maintainer state, while allowing contributor PR
refs from their own npubs.
## Repository Layout
Canonical repo announcement:
- repo id: `archy`
- display name: `Archipelago`
- clone URLs:
- `nostr://<maintainer-npub>/<relay-hint>/archy`
- `https://<grasp-host>/<maintainer-npub>/archy.git`
- relays:
- Archipelago-operated relay
- at least two public Nostr relays that support the event load
- GRASP servers:
- Archipelago-operated GRASP instance
- one public GRASP-compatible mirror
Keep the existing HTTP Git remote as a mirror during launch. The docs can
present `nostr://` as the preferred contribution path once the workflow is
proven.
## UI Requirements
The source app should provide:
- A first-run contribution modal with a real Archipelago source graphic, not a
generic text-only dialog.
- Current clone status and local path.
- Branch list, changed files, commit form, and push/open-PR flow.
- PR inbox, issue list, maintainer status, and relay health.
- Explicit identity indicator showing which npub will sign events.
- A merge rights indicator that clearly says contributors can propose changes
but cannot merge them.
- A fork flow that creates a user-owned NIP-34 repo announcement and remote,
then offers "Open PR to Archipelago" from any fork branch.
- Maintainer badges based only on pinned canonical maintainer npubs, not relay
metadata or server-side account names.
- Links to container docs, deployment docs, manifest spec, and open-source
readiness tasks.
## Backend Work
Add an RPC module for source contribution workflow:
- `source.repo-info`: returns canonical announcement, clone URL, relay set,
maintainer npubs, and local clone state.
- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed.
- `source.clone`: clones or updates the local source checkout.
- `source.status`: returns branch, dirty files, ahead/behind, and PR state.
- `source.commit`: creates a local commit from selected files.
- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local
remote.
- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event.
- `source.update-pr`: updates the branch and publishes kind `1619`.
- `source.issue`: publishes a kind `1621` event.
Backend must shell out through a narrow command wrapper, never arbitrary user
commands. The wrapper should set an isolated working tree under
`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and
deny operations outside that path.
## Security Model
- Never expose maintainer private keys to an Archipelago node.
- Prefer app-specific contributor identities over the node's default identity.
- Require per-action consent for first PR push, issue creation, and signing any
event that tags the canonical repository.
- Pin the canonical maintainer npub in the app manifest and backend config.
- Keep the canonical merge-maintainer allow list signed by the
`archipelago-maintainer` key; never infer merge rights from GRASP server
accounts.
- Verify the canonical kind `30617` event signature before displaying clone
instructions.
- Treat GRASP servers as untrusted storage; verify Git refs against signed
Nostr state.
- Do not use destructive git operations from the UI without an explicit modal.
- Store local clones and generated patches outside app container writable roots
unless the user exports them.
## MVP
1. Package `ngit` as a first-party app.
2. Stand up one Archipelago-operated GRASP server and one Nostr relay.
3. Publish sanitized fresh-history `archy` through `ngit init`.
4. Add a simple `archipelago-source` app that clones source and links out to the
preferred Nostr Git browser.
5. Add app-launcher consent scopes for repository-specific NIP-34 signing.
6. Allow issues and PR branch submission from contributor npubs.
7. Add a one-click fork flow that publishes a contributor-owned fork
announcement referencing canonical Archipelago.
8. Keep maintainer merge/status publication manual.
## Later
- Native PR review UI with file diffs and inline comments.
- CI status events signed by the build identity.
- FIPS-first source sync between trusted Archipelago nodes.
- Private prerelease repositories using NIP-42 allow lists and/or protected
events if the ecosystem support is mature enough.
- Multi-maintainer policy with threshold signatures or explicit maintainer-list
rotation events.
## Open Questions
- Which maintainer npub should become canonical for `archy`?
- Should contributor identities be node-default or app-specific by default?
- Which GRASP implementation should be deployed first: `ngit-grasp` or another
NIP-34/GRASP-compatible relay?
- Should the source app include a full web Git UI in v1, or launch Gitea/ngit
browser links for review while keeping signing/submission native?
- What exact license and contribution certificate should contributors accept
before submitting PR events?

View File

@ -443,6 +443,86 @@ free -h
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade` - Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
- If on WiFi, try wired Ethernet for more stable connectivity - If on WiFi, try wired Ethernet for more stable connectivity
### 21. LoRa radio firmware flash failed / board unresponsive
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
an error, or the radio no longer enumerates as a serial device after a flash
attempt.
**Diagnosis**:
```bash
# Poll the flash job's last-known stage/error directly
curl -s http://localhost:5678/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"mesh.flash-status","params":{}}'
# Confirm the board is still enumerating at all
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
# esptool/rnodeconf binaries present?
which esptool; ls -la /usr/local/bin/archy-rnodeconf
```
**Solutions**:
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
this is expected risk of the "always erase first" default, not a bug.
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
state esptool can always talk to, regardless of what firmware (if any) is
currently on it.
- With the board in bootloader mode, a manual recovery flash can be run
directly over SSH without the UI:
```bash
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
```
- For Reticulum/RNode boards, the equivalent manual recovery is
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
sequence the UI triggers.
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
installed by the last `self-update.sh` run — check
`sudo journalctl -u archipelago-update` for install failures, or install
`esptool` via `sudo apt-get install esptool` directly.
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
for the next detection poll) — the hot-swap modal re-probes automatically
and shows whatever firmware is actually on the board now.
**Known incident (2026-07-23) — reconnect storm / device boot-loop after a
failed flash**: a real Heltec V3 got stuck cycling "connect → partial
handshake → drop" every 5-15s for 5+ minutes after a `mesh.flash-device`
attempt failed with `Reading firmware download stream`. Root cause was two
compounding issues, both now fixed:
1. `spawn_mesh_listener`'s reconnect backoff (`core/archipelago/src/mesh/listener/mod.rs`)
reset to its 5s minimum any time the prior session had been `device_connected`
at all, even for under a second — so a device that connects-then-drops
repeatedly never actually backed off. Every retry's `open()` toggles
DTR/RTS, which resets many ESP32 boards' MCU (native-USB *and*
CP2102/CH340 auto-reset-circuit boards), so the aggressive retries were
themselves *causing* the boot loop, not just observing one. Fixed by only
resetting backoff when a session ran for at least `STABLE_SESSION_THRESHOLD`
(20s) — see that constant's doc comment.
2. `mesh::flash::start_flash_job`'s post-completion handler auto-resumed the
listener unconditionally, even after a *failed* flash, immediately
re-entering the reconnect loop above with no cooldown. Fixed: on failure
the listener is now deliberately left stopped (reconnect manually via the
UI once the board is confirmed alive); on success there's a 5s settle
delay before resuming, so the board finishes booting from the flash
tool's own reset before Archipelago starts probing it again.
3. Separately, the download itself was failing because `mesh::flash`'s HTTP
client had a blanket 30s request timeout that covered the *entire*
download (including streaming a 170MB Meshtastic zip), not just
connection setup — fixed with a per-chunk stall timeout instead of a
fixed total-transfer cap.
If this symptom recurs (rapid repeating `mesh::serial: Opened serial
port... Starting Meshcore handshake` lines in `journalctl -u archipelago`
without a `LoRa firmware flash` job in progress), it's a NEW instance of the
same class of bug, not the one above — check whether backoff is actually
escalating (`Mesh session error: ... (retry in Xs)` — X should grow past 5s
within a few cycles) before assuming it's flashing-related.
--- ---
## General Maintenance ## General Maintenance

View File

@ -365,6 +365,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
ca-certificates \ ca-certificates \
openssl \ openssl \
chrony \ chrony \
iputils-ping \
esptool \
python3-venv \
binutils \
libpython3.13 \
locales \ locales \
console-setup \ console-setup \
keyboard-configuration \ keyboard-configuration \
@ -1179,6 +1184,19 @@ else
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded" echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
fi fi
# archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware
# (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node
# imaged without it fails every RNode flash with "No such file or directory"
# (framework-pt, 2026-07-29, v1.7.117).
RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}"
if [ -f "$RNODECONF" ]; then
cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf"
chmod +x "$ARCH_DIR/bin/archy-rnodeconf"
echo " ✅ rnodeconf bundled ($(du -h "$ARCH_DIR/bin/archy-rnodeconf" | cut -f1))"
else
echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded"
fi
if [ "$BACKEND_CAPTURED" = "0" ]; then if [ "$BACKEND_CAPTURED" = "0" ]; then
if [ "$BUILD_FROM_SOURCE" != "1" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then
echo " ⚠️ Could not capture from live server, building from source..." echo " ⚠️ Could not capture from live server, building from source..."
@ -2834,7 +2852,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
Environment=ANTHROPIC_API_KEY=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 Environment=ANTHROPIC_API_KEY=
ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py
Restart=always Restart=always
RestartSec=5 RestartSec=5

View File

@ -0,0 +1,39 @@
# Third-Party Licenses — neode-ui (web frontend)
Runtime dependencies bundled into the distributed web UI. Verified 2026-07-23.
Dev-only tooling (Vite, Playwright, TypeScript, etc.) is not distributed and
is not listed here. Note: the production bundler strips license header
comments, so this file (and the fonts' adjacent license files) constitute the
attribution shipped with the bundle; a build-time license-aggregation step is
planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
| Package | Version | License |
|---|---|---|
| vue | 3.5.x | MIT |
| vue-router | 4.6.x | MIT |
| vue-i18n | 11.3.x | MIT |
| pinia | 3.0.x | MIT |
| d3 | 7.9.x | ISC |
| leaflet | 1.9.x | BSD-2-Clause |
| @vue-leaflet/vue-leaflet | 0.10.x | MIT |
| dompurify | 3.4.x | MPL-2.0 OR Apache-2.0 (used under Apache-2.0) |
| buffer | 6.0.x | MIT |
| fast-json-patch | 3.1.x | MIT |
| fuse.js | 7.1.x | Apache-2.0 |
| qrcode | 1.5.x | MIT |
| qr-scanner | 1.4.x | MIT |
| qrloop | 1.4.x | MIT |
## Fonts
| Font | License | License file |
|---|---|---|
| Montserrat | SIL OFL 1.1 | public/assets/fonts/Montserrat/OFL.txt |
| Open Sans | Apache-2.0 | public/assets/fonts/Open_Sans/LICENSE.txt |
## Vendored
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
see ATTRIBUTION.md in that directory.

View File

@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.115-alpha", "version": "1.7.118-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.115-alpha", "version": "1.7.118-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",

View File

@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.116-alpha", "version": "1.7.118-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",

View File

@ -0,0 +1,11 @@
# Icon Attribution
- `barbarian.svg`, `batteries.svg` — from **game-icons.net**
(https://game-icons.net), by Lorc, Delapouite & contributors,
licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/).
- Pixel-style icons (`save.svg`, `paint-bucket.svg`, `fill-half.svg`,
`cloud-moon.svg`, `debug-off.svg`, `cloud-done.svg`) — from
**pixelarticons** by Gerrit Halfmann (https://github.com/halfmage/pixelarticons),
MIT License.
- All other icons are original Archipelago artwork (MIT, see repository
LICENSE).

View File

@ -0,0 +1,13 @@
# Device Artwork Attribution
The device illustrations in this directory are from the
**Meshtastic® project** — https://meshtastic.org
(https://github.com/meshtastic), © Meshtastic contributors,
licensed under GPL-3.0.
Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
identify compatible hardware. No endorsement by the Meshtastic project is
implied.
Any modifications to these images made for Archipelago are likewise available
under GPL-3.0 as part of this public repository.

View File

@ -4,6 +4,16 @@ export interface RPCOptions {
method: string method: string
params?: Record<string, unknown> params?: Record<string, unknown>
timeout?: number timeout?: number
/** Abort the call (and any pending retries) from the outside pass a
* component-scoped controller's signal so fan-outs stop on unmount. */
signal?: AbortSignal
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
* own timeout/fallback UX retry×3 on a slow peer is how one unreachable
* node turns a 30s timeout into a 90s spinner. */
maxRetries?: number
/** Collapse concurrent identical calls (same method + params) into one
* request. Opt-in: only safe for reads. */
dedup?: boolean
} }
export interface RPCResponse<T> { export interface RPCResponse<T> {
@ -74,18 +84,35 @@ function getCsrfToken(): string | null {
class RPCClient { class RPCClient {
private static _sessionExpiredRedirecting = false private static _sessionExpiredRedirecting = false
private baseUrl: string private baseUrl: string
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
private inflight = new Map<string, Promise<unknown>>()
constructor(baseUrl: string = '/rpc/v1') { constructor(baseUrl: string = '/rpc/v1') {
this.baseUrl = baseUrl this.baseUrl = baseUrl
} }
async call<T>(options: RPCOptions): Promise<T> { async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000 } = options if (options.dedup) {
const maxRetries = 3 const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
const existing = this.inflight.get(key)
if (existing) return existing as Promise<T>
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
this.inflight.set(key, p)
return p
}
return this.callInner<T>(options)
}
private async callInner<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000, signal: external } = options
const maxRetries = Math.max(1, options.maxRetries ?? 3)
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
if (external?.aborted) throw new Error('Aborted')
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout) const timeoutId = setTimeout(() => controller.abort(), timeout)
const onExternalAbort = () => controller.abort()
external?.addEventListener('abort', onExternalAbort, { once: true })
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -105,6 +132,7 @@ class RPCClient {
}) })
clearTimeout(timeoutId) clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (!response.ok) { if (!response.ok) {
// Session expired — debounced redirect to login // Session expired — debounced redirect to login
@ -167,8 +195,11 @@ class RPCClient {
return data.result as T return data.result as T
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
// Caller-initiated abort is final — never retried.
if (external?.aborted) throw new Error('Aborted')
const timeoutErr = new Error('Request timeout') const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1) const delay = 600 * (attempt + 1)

View File

@ -377,8 +377,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTxExplorer } from '@/composables/useTxExplorer' import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>() defineProps<{ compact?: boolean }>()
@ -462,11 +463,41 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
{ key: 'custom', label: 'Custom' }, { key: 'custom', label: 'Custom' },
] ]
const loading = ref(true) // Cached: revisits paint the channel lists instantly and revalidate behind
const error = ref<string | null>(null) // them. Open and closed history are separate entries so a closed-history
const channels = ref<Channel[]>([]) // failure keeps its last list without touching the main channel view.
const closedChannels = ref<ClosedChannel[]>([]) interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
const summary = ref({ total_inbound: 0, total_outbound: 0 }) const channelsRes = useCachedResource<ChannelsData>({
key: 'lnd.channels',
fetcher: async (signal) => {
const result = await rpcClient.call<ChannelsData>({
method: 'lnd.listchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
})
return {
channels: result?.channels || [],
total_inbound: result?.total_inbound || 0,
total_outbound: result?.total_outbound || 0,
}
},
})
const closedRes = useCachedResource<ClosedChannel[]>({
key: 'lnd.closed-channels',
fetcher: async (signal) => {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
})
return closed?.channels || []
},
})
const loading = computed(() =>
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
const error = computed(() => channelsRes.error.value)
const channels = computed(() => channelsRes.data.value?.channels ?? [])
const closedChannels = computed(() => closedRes.data.value ?? [])
const summary = computed(() => ({
total_inbound: channelsRes.data.value?.total_inbound ?? 0,
total_outbound: channelsRes.data.value?.total_outbound ?? 0,
}))
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet. // Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats. // Channel limits: min 150,000 / max 1,500,000 sats.
@ -538,37 +569,10 @@ function capacityPercent(amount: number, capacity: number): number {
return Math.round((amount / capacity) * 100) return Math.round((amount / capacity) * 100)
} }
async function loadChannels() { function loadChannels(): Promise<void> {
const hadChannels = channels.value.length > 0 const main = channelsRes.refresh()
loading.value = true void closedRes.refresh()
error.value = null return main
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
// Closed history is a separate RPC a failure here keeps the previous
// list rather than blanking the main channel view.
try {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels',
timeout: 15000,
})
closedChannels.value = closed.channels || []
} catch {
/* keep previous closed list */
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
} }
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null { function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
@ -647,7 +651,5 @@ async function closeChannel() {
} }
} }
onMounted(loadChannels)
defineExpose({ channels, loadChannels }) defineExpose({ channels, loadChannels })
</script> </script>

View File

@ -3,8 +3,10 @@
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. --> syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close"> <BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury <!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. --> on-chain/Lightning rows; chips keep the standard txs reachable.
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap"> Sticky inside the modal's scroll region so the tabs stay pinned
while rows blur past underneath. -->
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-black/70 backdrop-blur-md">
<button <button
v-for="f in filters" v-for="f in filters"
:key="f.key" :key="f.key"
@ -26,7 +28,12 @@
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p> <p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div> </div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5"> <!-- Must NOT be its own scroll container: BaseModal's slot wrapper is the
scroller, and modal-scroll-locked gives every .overflow-y-auto inside
the overlay overscroll-behavior:contain a nested scroller with no
overflow then swallows touch scrolling entirely (phones only; wheel
latches onto the scrollable ancestor and never sees the bug). -->
<div v-else class="-mx-2 px-2 divide-y divide-white/5">
<div <div
v-for="tx in filteredTransactions" v-for="tx in filteredTransactions"
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp" :key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"

View File

@ -1,7 +1,7 @@
<template> <template>
<BaseModal <BaseModal
:show="show" :show="show"
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'" :title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Set Recommended' : 'Flash Firmware'"
max-width="max-w-lg" max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto" content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss" @close="dismiss"
@ -35,9 +35,17 @@
<!-- What's currently flashed / configured on it --> <!-- What's currently flashed / configured on it -->
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3"> <div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1"> <div v-if="probing" class="py-1">
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span> <div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
Reading what's on the radio <span>{{ probeStage }}</span>
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
:style="{ width: probeProgress + '%' }"
></div>
</div>
</div> </div>
<template v-else-if="probe"> <template v-else-if="probe">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -94,21 +102,123 @@
:disabled="!!connecting" :disabled="!!connecting"
@click="step = 2" @click="step = 2"
> >
Set Up with Archipelago Settings Set Recommended
</button> </button>
</div> </div>
<p class="text-white/40 text-[11px] mt-3"> <p class="text-white/40 text-[11px] mt-3">
"Keep As Is" uses the radio exactly as it is nothing on it is changed, "Keep As Is" uses the radio exactly as it is nothing on it is changed,
and you can hot-swap radios any time. and you can hot-swap radios any time.
</p> </p>
<button
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
:disabled="!!connecting"
@click="openFlashStep"
>
Flash Firmware
</button>
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p> <p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
</div> </div>
<!-- Step 3: erase + reflash destructive, opt-in only -->
<div v-else-if="step === 'flash'">
<!-- Once a job exists (started via startFlash), ALWAYS show the
progress/result view below including on failure. The old
condition (`!active && stage !== 'done'`) was also true for a
FAILED job (active:false, stage:'failed'), which silently sent
the user back to this picker instead of showing the error. -->
<template v-if="!flashJob">
<p class="text-white/60 text-xs mb-3">
Downloads the latest firmware from upstream and writes it to
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
<select v-model="flashFamily" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum RNode</option>
</select>
</div>
<div>
<label class="block text-sm text-white/80 mb-1">Board</label>
<select v-model="flashBoard" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically double check before flashing.
Flashing the wrong board's image can brick it.
</p>
</div>
</div>
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
<label class="flex items-start gap-2 text-xs text-red-300">
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
<span>
This <strong>erases the entire chip</strong>, including any existing
keys, identity, and contacts. This cannot be undone.
</span>
</label>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
@click="startFlash"
>
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
</button>
</div>
</template>
<!-- Progress -->
<template v-else>
<div class="text-center py-2">
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full bg-orange-400 transition-all"
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
></div>
</div>
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
</div>
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
</div>
<div class="flex gap-2 mt-4">
<button
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
class="glass-button px-4 py-2 rounded-lg text-sm"
@click="cancelFlash"
>
Cancel
</button>
<button
v-if="!flashJob?.active"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="closeFlashStep"
>
Done
</button>
</div>
</template>
</div>
<!-- Step 2: our latest parameters, shown before anything is written --> <!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else> <div v-else>
<p class="text-white/60 text-xs mb-3"> <p class="text-white/60 text-xs mb-3">
These are the latest Archipelago settings nothing is written to the These are the recommended Archipelago settings nothing is written to
radio until you confirm. the radio until you confirm.
</p> </p>
<!-- Summary of what will be applied --> <!-- Summary of what will be applied -->
@ -190,7 +300,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh' import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions' import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages' import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
@ -199,14 +309,38 @@ const mesh = useMeshStore()
const appStore = useAppStore() const appStore = useAppStore()
const router = useRouter() const router = useRouter()
const step = ref<1 | 2>(1) const step = ref<1 | 2 | 'flash'>(1)
const connecting = ref<false | 'keep' | 'setup'>(false) const connecting = ref<false | 'keep' | 'setup'>(false)
const error = ref('') const error = ref('')
const probing = ref(false) const probing = ref(false)
const probe = ref<MeshDeviceProbe | null>(null) const probe = ref<MeshDeviceProbe | null>(null)
const probeError = ref('') const probeError = ref('')
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '') // Time-driven probe progress: the probe RPC is a single opaque call that can
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
// advances on a clock toward 92% and snaps to 100% when the result lands.
const probeProgress = ref(0)
const probeStage = ref('Waiting for the radio to boot…')
let probeTicker: ReturnType<typeof setInterval> | null = null
function startProbeProgress() {
stopProbeProgress()
probeProgress.value = 0
probeStage.value = 'Waiting for the radio to boot…'
const startedAt = Date.now()
probeTicker = setInterval(() => {
const elapsed = (Date.now() - startedAt) / 1000
// ~92% at 30s, decelerating never looks stuck, never lies "done".
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
}, 400)
}
function stopProbeProgress(done = false) {
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
if (done) probeProgress.value = 100
}
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value) const show = computed(() => !!devicePath.value)
const imageFailed = ref(false) const imageFailed = ref(false)
const deviceImage = computed(() => const deviceImage = computed(() =>
@ -264,7 +398,27 @@ const rfPreset = computed(() => {
// (Re)probe + (re)apply presets each time a new device surfaces the modal // (Re)probe + (re)apply presets each time a new device surfaces the modal
watch([show, devicePath], async ([visible]) => { watch([show, devicePath], async ([visible]) => {
if (!visible) return if (!visible) {
stopFlashPoll()
return
}
if (mesh.flashFlowPath) {
// Manual "Flash LoRa" entry: jump straight to the flash step. Skip the
// read-only probe the port is usually the live session's, and a second
// opener on the tty corrupts the running connection; status already
// knows the firmware kind for the connected radio.
probe.value = null
probeError.value = ''
probing.value = false
stopProbeProgress()
imageFailed.value = false
openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
flashFamily.value = t as FlashFirmwareFamily
}
return
}
step.value = 1 step.value = 1
error.value = '' error.value = ''
imageFailed.value = false imageFailed.value = false
@ -275,6 +429,7 @@ watch([show, devicePath], async ([visible]) => {
probe.value = null probe.value = null
probeError.value = '' probeError.value = ''
probing.value = true probing.value = true
startProbeProgress()
const path = devicePath.value const path = devicePath.value
try { try {
const res = await mesh.probeDevice(path) const res = await mesh.probeDevice(path)
@ -284,11 +439,16 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = e instanceof Error ? e.message : String(e) probeError.value = e instanceof Error ? e.message : String(e)
} }
} finally { } finally {
stopProbeProgress(true)
if (devicePath.value === path) probing.value = false if (devicePath.value === path) probing.value = false
} }
}, { immediate: false }) }, { immediate: false })
function dismiss() { function dismiss() {
if (mesh.flashFlowPath) {
mesh.closeFlashFlow()
return
}
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value) if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
} }
@ -349,6 +509,123 @@ async function applySetup() {
connecting.value = false connecting.value = false
} }
} }
// Step 3: erase + reflash
const flashFamily = ref<FlashFirmwareFamily | ''>('')
const flashBoard = ref<FlashBoard | ''>('')
const flashConfirmed = ref(false)
const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
case 'done': return 'Flash complete'
case 'failed': return 'Flash failed'
default: return ''
}
})
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value
flashConfirmed.value = false
flashJob.value = null
error.value = ''
step.value = 'flash'
}
function stopFlashPoll() {
if (flashPollTimer) {
clearInterval(flashPollTimer)
flashPollTimer = null
}
}
async function pollFlashStatus() {
try {
const status = await mesh.flashStatus()
flashJob.value = status
if (!status.active) {
stopFlashPoll()
if (status.done && !status.error) {
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
// card reflects whatever firmware is actually on the board now.
const path = devicePath.value
probing.value = true
try {
probe.value = await mesh.probeDevice(path)
} catch {
probe.value = null
} finally {
probing.value = false
}
}
}
} catch {
stopFlashPoll()
}
}
async function startFlash() {
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
starting.value = true
error.value = ''
try {
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
stopFlashPoll()
flashPollTimer = setInterval(pollFlashStatus, 1500)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
} finally {
starting.value = false
}
}
async function cancelFlash() {
try {
await mesh.flashCancel()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to cancel'
}
}
function closeFlashStep() {
stopFlashPoll()
if (mesh.flashFlowPath) {
// Manual entry has no detection step to go back to close the modal.
mesh.closeFlashFlow()
return
}
step.value = 1
}
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,107 @@
// Stale-while-revalidate resource hook over the shared resources store.
//
// Usage:
// const files = useCachedResource<CloudFile[]>({
// key: 'cloud.my-files',
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
// ttlMs: 30_000,
// })
// // template: files.data renders instantly on revisit (cache), while
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
//
// Behavior:
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
// snapshot (survives reload) → fetch.
// - Sticky-ready: never regresses ready → loading; refreshes are
// 'refreshing' so content stays on screen.
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
// background refresh runs only if the TTL has lapsed (or never fetched).
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
// stale (debounced by TTL, so focus-flapping is free).
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
// the last subscribed component unmounts.
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
export interface CachedResourceOptions<T> {
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
key: string
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
fetcher: (signal: AbortSignal) => Promise<T>
/** Data older than this triggers a background revalidate (default 30s). */
ttlMs?: number
/** Snapshot to sessionStorage so reloads paint instantly (default true).
* Disable for large payloads. */
persist?: boolean
/** Revalidate (if stale) when the window regains focus (default true). */
revalidateOnFocus?: boolean
/** Fetch on first use (default true). Set false for lazy resources. */
immediate?: boolean
}
export interface CachedResource<T> {
entry: ResourceEntry<T>
/** Convenience computed views over the entry. */
data: ComputedRef<T | null>
loadState: ComputedRef<ResourceLoadState>
error: ComputedRef<string | null>
/** True when data exists but is older than the TTL (drive an age badge). */
isStale: ComputedRef<boolean>
ageMs: ComputedRef<number | null>
/** Force a refresh now (deduped with any in-flight one). */
refresh: () => Promise<void>
/** Mark stale + debounce-refresh all mounted users of this key. */
invalidate: () => void
/** Optimistically update cached data; returns rollback for RPC failure. */
optimistic: (update: (current: T | null) => T) => () => void
}
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
const store = useResourcesStore()
const ttlMs = opts.ttlMs ?? 30_000
const persist = opts.persist ?? true
const entry = store.entry<T>(opts.key, persist)
const aborter = new AbortController()
const fetcher = () => opts.fetcher(aborter.signal)
const refresh = () => store.refresh(opts.key, fetcher, { persist })
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
const refreshIfStale = () => {
if (stale()) void refresh()
}
// Register as a live revalidator so invalidate(key) reaches us.
const unsubscribe = store.subscribe(opts.key, () => void refresh())
const onFocus = () => refreshIfStale()
if (opts.revalidateOnFocus ?? true) {
window.addEventListener('focus', onFocus)
}
// Tied to the owning effect scope (component setup or manual scope);
// outside any scope (tests, module init) there's nothing to dispose.
if (getCurrentScope()) {
onScopeDispose(() => {
unsubscribe()
window.removeEventListener('focus', onFocus)
aborter.abort()
})
}
if (opts.immediate ?? true) refreshIfStale()
return {
entry,
data: computed(() => entry.data),
loadState: computed(() => entry.loadState),
error: computed(() => entry.error),
isStale: computed(() => entry.data !== null && stale()),
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
refresh,
invalidate: () => store.invalidate(opts.key),
optimistic: (update) => store.optimistic<T>(opts.key, update),
}
}

View File

@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResourcesStore } from '../resources'
import { useCachedResource } from '@/composables/useCachedResource'
describe('resources store — stale-while-revalidate semantics', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('first fetch goes idle → loading → ready with data', async () => {
const store = useResourcesStore()
const e = store.entry<string>('k1')
expect(e.loadState).toBe('idle')
const p = store.refresh('k1', async () => 'hello')
expect(e.loadState).toBe('loading')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe('hello')
expect(e.fetchedAt).not.toBeNull()
})
it('sticky-ready: refresh never regresses ready → loading', async () => {
const store = useResourcesStore()
await store.refresh('k2', async () => 1)
const e = store.entry<number>('k2')
const p = store.refresh('k2', async () => 2)
expect(e.loadState).toBe('refreshing')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe(2)
})
it('keeps last-known data on refresh error (ready + error set)', async () => {
const store = useResourcesStore()
await store.refresh('k3', async () => 'good')
const e = store.entry<string>('k3')
await store.refresh('k3', async () => {
throw new Error('boom')
})
expect(e.data).toBe('good')
expect(e.loadState).toBe('ready')
expect(e.error).toBe('boom')
})
it('errors with no prior data land in error state', async () => {
const store = useResourcesStore()
await store.refresh('k4', async () => {
throw new Error('down')
})
const e = store.entry('k4')
expect(e.loadState).toBe('error')
expect(e.data).toBeNull()
})
it('dedups concurrent refreshes for the same key', async () => {
const store = useResourcesStore()
const fetcher = vi.fn(async () => 'once')
const p1 = store.refresh('k5', fetcher)
const p2 = store.refresh('k5', fetcher)
await Promise.all([p1, p2])
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('hydrates a new entry from the sessionStorage snapshot', async () => {
const store = useResourcesStore()
await store.refresh('k6', async () => ({ n: 42 }))
// Fresh pinia = fresh memory cache, same sessionStorage.
setActivePinia(createPinia())
const store2 = useResourcesStore()
const e = store2.entry<{ n: number }>('k6')
expect(e.loadState).toBe('ready')
expect(e.data).toEqual({ n: 42 })
})
it('optimistic update applies immediately and rollback restores', async () => {
const store = useResourcesStore()
await store.refresh('k7', async () => ['a'])
const e = store.entry<string[]>('k7')
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
expect(e.data).toEqual(['a', 'b'])
rollback()
expect(e.data).toEqual(['a'])
})
it('invalidate marks stale and debounce-runs subscribers', async () => {
const store = useResourcesStore()
await store.refresh('k8', async () => 1)
const revalidate = vi.fn()
store.subscribe('k8', revalidate)
store.invalidate('k8')
expect(store.entry('k8').fetchedAt).toBeNull()
expect(revalidate).not.toHaveBeenCalled()
vi.advanceTimersByTime(900)
expect(revalidate).toHaveBeenCalledTimes(1)
})
})
describe('useCachedResource composable', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
it('fetches immediately when stale and exposes reactive views', async () => {
const fetcher = vi.fn(async () => 'data')
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
await r.refresh()
expect(fetcher).toHaveBeenCalled()
expect(r.data.value).toBe('data')
expect(r.loadState.value).toBe('ready')
expect(r.isStale.value).toBe(false)
})
it('does not refetch within TTL (instant render from cache)', async () => {
const fetcher = vi.fn(async () => 'v1')
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
await r1.refresh()
// Second component using the same key inside the TTL: no new fetch.
const fetcher2 = vi.fn(async () => 'v2')
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
expect(r2.data.value).toBe('v1')
expect(fetcher2).not.toHaveBeenCalled()
})
})

View File

@ -8,6 +8,11 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
// Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>()
// Last-wins guard for overlapping navigations (fast folder hopping).
let navSeq = 0
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean) const parts = currentPath.value.split('/').filter(Boolean)
@ -36,7 +41,22 @@ export const useCloudStore = defineStore('cloud', () => {
} }
async function navigate(path: string): Promise<void> { async function navigate(path: string): Promise<void> {
loading.value = true const seq = ++navSeq
const apply = (p: string, result: FileBrowserItem[]) => {
pathCache.set(p, result)
if (seq !== navSeq) return // a newer navigation superseded this one
items.value = result
currentPath.value = p
}
// Stale-while-revalidate: show the cached listing for this path
// immediately (no spinner), then refresh it underneath.
const cached = pathCache.get(path)
if (cached) {
items.value = cached
currentPath.value = path
} else {
loading.value = true
}
error.value = null error.value = null
try { try {
if (!authenticated.value) { if (!authenticated.value) {
@ -47,9 +67,7 @@ export const useCloudStore = defineStore('cloud', () => {
} }
} }
try { try {
const result = await fileBrowserClient.listDirectory(path) apply(path, await fileBrowserClient.listDirectory(path))
items.value = result
currentPath.value = path
} catch { } catch {
// Directory may not exist — try to create it, then retry // Directory may not exist — try to create it, then retry
if (path !== '/') { if (path !== '/') {
@ -57,23 +75,20 @@ export const useCloudStore = defineStore('cloud', () => {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/' const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1) const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName) await fileBrowserClient.createFolder(parentPath, dirName)
const result = await fileBrowserClient.listDirectory(path) apply(path, await fileBrowserClient.listDirectory(path))
items.value = result
currentPath.value = path
} catch { } catch {
// Fall back to root // Fall back to root
const result = await fileBrowserClient.listDirectory('/') apply('/', await fileBrowserClient.listDirectory('/'))
items.value = result
currentPath.value = '/'
} }
} else { } else {
throw new Error('Failed to list root directory') throw new Error('Failed to list root directory')
} }
} }
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load files' // Keep showing the cached listing on a failed revalidate.
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally { } finally {
loading.value = false if (seq === navSeq) loading.value = false
} }
} }
@ -112,6 +127,7 @@ export const useCloudStore = defineStore('cloud', () => {
items.value = [] items.value = []
loading.value = false loading.value = false
error.value = null error.value = null
pathCache.clear()
} }
return { return {

View File

@ -57,6 +57,23 @@ export interface MeshDeviceProbe {
max_contacts: number | null max_contacts: number | null
} }
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
/** Live progress for the one flash job that can run at a time. */
export interface FlashJobStatus {
active: boolean
board?: FlashBoard
family?: FlashFirmwareFamily
path?: string
stage?: FlashStage
percent?: number | null
log_tail?: string[]
done?: boolean
error?: string | null
}
/** Params accepted by mesh.configure (superset of the status fields). */ /** Params accepted by mesh.configure (superset of the status fields). */
export interface MeshConfigureParams { export interface MeshConfigureParams {
enabled?: boolean enabled?: boolean
@ -264,8 +281,35 @@ export const useMeshStore = defineStore('mesh', () => {
// Track unread message counts per peer (contact_id -> count) // Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({}) const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread) // Durable seen-state: highest message id the user has actually seen, per
const viewingChatId = ref<number | null>(null) // contact. Without this, every page load replayed ALL historical received
// messages into unreadCounts (the in-memory store starts empty, so every
// old message looked "new") — badges came back on every visit. Message ids
// are safe watermarks: the backend allocates them monotonically and
// restores the counter as max(persisted)+1 across restarts.
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
const lastSeenId = ref<Record<number, number>>(
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
)
// First run after this feature ships: treat existing history as seen so
// nobody gets a wall of phantom badges for months-old messages.
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
function persistLastSeen() {
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
}
function advanceLastSeen(contactId: number, msgId: number): boolean {
if ((lastSeenId.value[contactId] ?? 0) >= msgId) return false
lastSeenId.value[contactId] = msgId
return true
}
// Contact ids of the chat currently on screen — ALL twins of the merged
// conversation, not just the clicked row's id, since the unread badge sums
// across every underlying contact_id (a message can land on any twin).
const viewingChatIds = ref<number[]>([])
// Whether the open chat is scrolled to (near) the bottom. New messages only
// auto-clear as read while the latest messages are actually in view —
// standard chat-app semantics; scrolled-up-in-history still counts unread.
const viewingAtBottom = ref(true)
// Total unread count for nav badge // Total unread count for nav badge
const totalUnread = computed(() => const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0) Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
@ -274,12 +318,16 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchStatus() { async function fetchStatus() {
try { try {
loading.value = true loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' }) const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res status.value = res
trackDetectedDevices(res) trackDetectedDevices(res)
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status' // Don't clobber a user-action error (broadcast/configure/send) — this
// runs on a 5s poll, and the old `error.value = null` on entry meant
// any real error banner survived at most one poll tick.
if (!error.value) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -350,6 +398,16 @@ export const useMeshStore = defineStore('mesh', () => {
} }
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value)) localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
} }
// Manual "Flash LoRa" entry (Mesh header button): forces the global setup
// modal open at the flash step for this port — including the live session's
// port, which detection-driven opening deliberately excludes.
const flashFlowPath = ref<string | null>(null)
function openFlashFlow(path: string) {
flashFlowPath.value = path
}
function closeFlashFlow() {
flashFlowPath.value = null
}
/** Read-only firmware/config probe of a detected port (hot-swap modal). */ /** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> { async function probeDevice(path: string): Promise<MeshDeviceProbe> {
return rpcClient.call<MeshDeviceProbe>({ return rpcClient.call<MeshDeviceProbe>({
@ -358,6 +416,32 @@ export const useMeshStore = defineStore('mesh', () => {
timeout: 45000, // serial probes are slow (multi-firmware handshakes) timeout: 45000, // serial probes are slow (multi-firmware handshakes)
}) })
} }
/** Available firmware version(s) for a family v1 only ever returns
* ["latest"], since firmware is always fetched from upstream at flash
* time rather than pinned/bundled. */
async function flashListFirmware(family: FlashFirmwareFamily): Promise<string[]> {
const res = await rpcClient.call<{ versions: string[] }>({
method: 'mesh.flash-list-firmware',
params: { family },
})
return res.versions
}
/** Erase + reflash a detected radio. `board` is optional omit it to let
* the backend auto-resolve from the port's USB vid:pid; if that fails
* (e.g. Heltec V4 not yet in the vid:pid table), it errors and the UI
* must ask the user to pick the board explicitly. Always erases first. */
async function flashDevice(path: string, family: FlashFirmwareFamily, board?: FlashBoard): Promise<void> {
await rpcClient.call({
method: 'mesh.flash-device',
params: board ? { path, family, board } : { path, family },
})
}
async function flashStatus(): Promise<FlashJobStatus> {
return rpcClient.call<FlashJobStatus>({ method: 'mesh.flash-status' })
}
async function flashCancel(): Promise<void> {
await rpcClient.call({ method: 'mesh.flash-cancel' })
}
let globalDetectTimer: ReturnType<typeof setInterval> | null = null let globalDetectTimer: ReturnType<typeof setInterval> | null = null
/** App-wide light poll so the detected-device modal works on every page /** App-wide light poll so the detected-device modal works on every page
* (the Mesh view's own 5s poll takes over while it is mounted). */ * (the Mesh view's own 5s poll takes over while it is mounted). */
@ -392,16 +476,35 @@ export const useMeshStore = defineStore('mesh', () => {
method: 'mesh.messages', method: 'mesh.messages',
params: limit ? { limit } : {}, params: limit ? { limit } : {},
}) })
// Detect new incoming messages and increment unread counts if (seedLastSeenFromHistory && res.messages.length > 0) {
for (const m of res.messages) {
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
}
persistLastSeen()
seedLastSeenFromHistory = false
}
// Detect new incoming messages and increment unread counts. "New" means
// past the durable seen watermark, not merely absent from the in-memory
// store — otherwise a page reload re-badges the whole history.
const newMsgs = res.messages.filter( const newMsgs = res.messages.filter(
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id) m =>
m.direction === 'received' &&
m.id > (lastSeenId.value[m.peer_contact_id] ?? 0) &&
!messages.value.some(existing => existing.id === m.id)
) )
let seenDirty = false
for (const msg of newMsgs) { for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat // Don't count as unread if we're currently viewing that chat AND the
if (msg.peer_contact_id !== viewingChatId.value) { // bottom (latest messages) is in view — i.e. the user actually sees it.
const seenLive =
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
if (seenLive) {
seenDirty = advanceLastSeen(msg.peer_contact_id, msg.id) || seenDirty
} else {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1 unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
} }
} }
if (seenDirty) persistLastSeen()
messages.value = res.messages messages.value = res.messages
// Extract node positions from coordinate messages // Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages) updateNodePositionsFromMessages(res.messages)
@ -526,13 +629,25 @@ export const useMeshStore = defineStore('mesh', () => {
federatedPositions.value = next federatedPositions.value = next
} }
function markChatRead(contactId: number) { function markChatRead(contactId: number | number[]) {
viewingChatId.value = contactId const ids = Array.isArray(contactId) ? contactId : [contactId]
delete unreadCounts.value[contactId] viewingChatIds.value = ids
let seenDirty = false
for (const id of ids) {
delete unreadCounts.value[id]
// Persist the watermark so the seen-state survives reloads.
for (const m of messages.value) {
if (m.direction === 'received' && m.peer_contact_id === id) {
seenDirty = advanceLastSeen(id, m.id) || seenDirty
}
}
}
if (seenDirty) persistLastSeen()
} }
function clearViewingChat() { function clearViewingChat() {
viewingChatId.value = null viewingChatIds.value = []
viewingAtBottom.value = true
} }
async function sendMessage(contactId: number, message: string) { async function sendMessage(contactId: number, message: string) {
@ -677,6 +792,8 @@ export const useMeshStore = defineStore('mesh', () => {
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible' tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
est_seconds: number est_seconds: number
has_tor: boolean has_tor: boolean
has_fips: boolean
last_transport: string | null
reason: string reason: string
size: number size: number
mesh_auto_max: number mesh_auto_max: number
@ -975,6 +1092,18 @@ export const useMeshStore = defineStore('mesh', () => {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()]) await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
} }
/** Ask the backend to actively re-query the radio's contact table (and by
* extension re-drain daemon events for Reticulum) the server-side half
* of the Refresh button; refreshAll() alone only re-reads caches. */
async function refreshRadio(): Promise<boolean> {
try {
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
return !!res.refreshed
} catch {
return false
}
}
return { return {
status, status,
peers, peers,
@ -983,6 +1112,7 @@ export const useMeshStore = defineStore('mesh', () => {
error, error,
sending, sending,
unreadCounts, unreadCounts,
viewingAtBottom,
totalUnread, totalUnread,
nodePositions, nodePositions,
federatedPositions, federatedPositions,
@ -993,7 +1123,14 @@ export const useMeshStore = defineStore('mesh', () => {
fetchStatus, fetchStatus,
undismissedDetectedDevices, undismissedDetectedDevices,
dismissDetectedDevice, dismissDetectedDevice,
flashFlowPath,
openFlashFlow,
closeFlashFlow,
probeDevice, probeDevice,
flashListFirmware,
flashDevice,
flashStatus,
flashCancel,
startGlobalDetection, startGlobalDetection,
fetchPeers, fetchPeers,
fetchMessages, fetchMessages,
@ -1002,6 +1139,7 @@ export const useMeshStore = defineStore('mesh', () => {
broadcastIdentity, broadcastIdentity,
configure, configure,
refreshAll, refreshAll,
refreshRadio,
markChatRead, markChatRead,
clearViewingChat, clearViewingChat,
sendInvoice, sendInvoice,

View File

@ -0,0 +1,166 @@
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
//
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
// keys its router-view by route.path, so each visit unmounted and refetched
// everything. This store is the single place resource state lives instead:
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
// snapshot), and `useCachedResource` renders them instantly while
// revalidating in the background.
//
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
// hand-rolled versions):
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
// refreshes show as 'refreshing' so the UI keeps the data visible.
// - keep-last-known-value on error: a failed revalidate leaves data in place
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
import { defineStore } from 'pinia'
import { reactive } from 'vue'
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
export interface ResourceEntry<T = unknown> {
data: T | null
loadState: ResourceLoadState
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
fetchedAt: number | null
error: string | null
}
const SNAPSHOT_PREFIX = 'resource:'
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
try {
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
} catch {
/* corrupt/absent snapshot — fall through to a fresh fetch */
}
return null
}
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
try {
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
} catch {
/* quota exceeded or unserializable — memory cache still works */
}
}
export const useResourcesStore = defineStore('resources', () => {
const entries = reactive(new Map<string, ResourceEntry>())
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
const inflight = new Map<string, Promise<void>>()
const revalidators = new Map<string, Set<() => void>>()
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** Get (or create) the reactive entry for a key, hydrating from the
* sessionStorage snapshot on first sight so revisits after a reload paint
* before any RPC completes. Pass `persist: false` to skip snapshots. */
function entry<T>(key: string, persist = true): ResourceEntry<T> {
let e = entries.get(key)
if (!e) {
const snap = persist ? readSnapshot<T>(key) : null
e = reactive<ResourceEntry>({
data: snap ? snap.data : null,
loadState: snap ? 'ready' : 'idle',
fetchedAt: snap ? snap.fetchedAt : null,
error: null,
})
entries.set(key, e)
}
return e as ResourceEntry<T>
}
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
* Concurrent calls for the same key share one in-flight fetch. */
function refresh<T>(
key: string,
fetcher: () => Promise<T>,
opts: { persist?: boolean } = {},
): Promise<void> {
const existing = inflight.get(key)
if (existing) return existing
const e = entry<T>(key, opts.persist ?? true)
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
const p = (async () => {
try {
const data = await fetcher()
e.data = data
e.error = null
e.fetchedAt = Date.now()
e.loadState = 'ready'
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
} catch (err) {
e.error = err instanceof Error ? err.message : String(err)
// Keep last-known data visible; only 'error' when we have nothing.
e.loadState = e.data !== null ? 'ready' : 'error'
} finally {
inflight.delete(key)
}
})()
inflight.set(key, p)
return p
}
/** Mark a key stale and (debounced) re-run every mounted subscriber's
* fetcher. Call after a mutation or on a relevant WS push. */
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
const e = entries.get(key)
if (e) e.fetchedAt = null
const subs = revalidators.get(key)
if (!subs || subs.size === 0) return
const t = invalidateTimers.get(key)
if (t) clearTimeout(t)
invalidateTimers.set(
key,
setTimeout(() => {
invalidateTimers.delete(key)
for (const fn of subs) fn()
}, opts.debounceMs ?? 800),
)
}
/** Register a live revalidator for a key (used by useCachedResource);
* returns an unsubscribe fn. */
function subscribe(key: string, revalidate: () => void): () => void {
let subs = revalidators.get(key)
if (!subs) {
subs = new Set()
revalidators.set(key, subs)
}
subs.add(revalidate)
return () => {
subs.delete(revalidate)
}
}
/** Optimistically apply `update` to the cached value; returns a rollback.
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
const e = entry<T>(key)
const before = e.data
const beforeState = e.loadState
e.data = update(before)
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
return () => {
e.data = before
e.loadState = beforeState
}
}
/** Drop a key entirely (memory + snapshot). */
function evict(key: string): void {
entries.delete(key)
try {
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
} catch {
/* noop */
}
}
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
})

View File

@ -2,9 +2,38 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { DataModel } from '../types/api' import type { DataModel, PatchOperation } from '../types/api'
import { wsClient, applyDataPatch } from '../api/websocket' import { wsClient, applyDataPatch } from '../api/websocket'
import { rpcClient } from '../api/rpc-client' import { rpcClient } from '../api/rpc-client'
import { useResourcesStore } from './resources'
/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
function pointerSegment(path: string, prefix: string): string {
const seg = path.slice(prefix.length).split('/')[0] ?? ''
return seg.replace(/~1/g, '/').replace(/~0/g, '~')
}
/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
* maps to the resource keys whose backing data it changes; invalidate()
* debounces (800ms) and only refetches keys with mounted subscribers, so a
* patch storm costs one revalidation per key. The 30s staleness
* reconciliation stays as the backstop for anything unmapped. */
function invalidateResourcesForPatch(patch: PatchOperation[]): void {
const resources = useResourcesStore()
for (const op of patch) {
const path = op.path ?? ''
if (path.startsWith('/peer-health/')) {
// A peer flipping reachability changes both its browse result and the
// federation node list's online state.
const onion = pointerSegment(path, '/peer-health/')
if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
resources.invalidate('federation.nodes')
} else if (path.startsWith('/package-data/')) {
// App installs/uninstalls add or remove their tor services.
resources.invalidate('server.tor-services')
}
}
}
export const useSyncStore = defineStore('sync', () => { export const useSyncStore = defineStore('sync', () => {
// State // State
@ -108,6 +137,7 @@ export const useSyncStore = defineStore('sync', () => {
try { try {
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown') if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch) data.value = applyDataPatch(data.value, update.patch)
invalidateResourcesForPatch(update.patch)
// Mark as connected once we receive any valid patch // Mark as connected once we receive any valid patch
if (!isConnected.value) { if (!isConnected.value) {
isConnected.value = true isConnected.value = true

View File

@ -194,7 +194,7 @@
Open Federation Open Federation
</RouterLink> </RouterLink>
</div> </div>
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm"> <div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }} {{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
@ -216,6 +216,13 @@
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span> <span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
</button> </button>
</div> </div>
<p v-if="peerFilesPending > 0" class="text-[11px] text-white/35 text-center mt-3 flex items-center justify-center gap-2">
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}
</p>
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3"> <p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered. {{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered.
</p> </p>
@ -301,12 +308,23 @@
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span> <span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
{{ peer.trust_level }} {{ peer.trust_level }}
</span> </span>
<span class="text-white/30">Peer Node</span> <!-- Live transport badge which route actually served the last
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
<span
v-if="peerTransport(peer.onion)"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
>
<span class="w-1.5 h-1.5 rounded-full" :class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
{{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s
</span>
<span v-else class="text-white/30">Peer Node</span>
</div> </div>
</div> </div>
<div <div
v-if="peersLoading && peerNodes.length > 0" v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2" class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
> >
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
@ -388,6 +406,8 @@ import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud' import { useCloudStore } from '../stores/cloud'
import { useResourcesStore } from '../stores/resources'
import { useCachedResource } from '../composables/useCachedResource'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client' import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType' import { getFileCategory } from '../composables/useFileType'
@ -399,9 +419,19 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter() const router = useRouter()
const store = useAppStore() const store = useAppStore()
const cloudStore = useCloudStore() const cloudStore = useCloudStore()
const resources = useResourcesStore()
const audioPlayer = useAudioPlayer() const audioPlayer = useAudioPlayer()
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false) // Section counts cached: revisits render the last-known counts instantly
// and refresh in the background (sticky-ready never regresses to "Loading").
const countsResource = useCachedResource<Record<string, number>>({
key: 'cloud.section-counts',
fetcher: fetchCounts,
ttlMs: 30_000,
immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch
})
const sectionCounts = computed(() => countsResource.entry.data ?? {})
const countsLoading = computed(() => countsResource.entry.loadState === 'loading')
// Tabs / categories / search state // Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers' | 'paid' type TabId = 'folders' | 'mine' | 'peers' | 'paid'
@ -424,14 +454,18 @@ const activeTab = ref<TabId>('folders')
// Paid Files tab // Paid Files tab
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string } interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
const paidItems = ref<PaidItem[]>([]) const paidResource = useCachedResource<PaidItem[]>({
const paidLoading = ref(false) key: 'cloud.paid-items',
async function loadPaidItems() { fetcher: async (signal) => {
paidLoading.value = true const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
try { return (res.items || []).slice().reverse()
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' }) },
paidItems.value = (res.items || []).slice().reverse() immediate: false, // loaded when the Paid tab is opened
} catch { paidItems.value = [] } finally { paidLoading.value = false } })
const paidItems = computed(() => paidResource.entry.data ?? [])
const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
function loadPaidItems() {
return paidResource.refresh()
} }
async function viewPaidItem(it: PaidItem) { async function viewPaidItem(it: PaidItem) {
try { try {
@ -473,8 +507,21 @@ interface PeerNode {
trust_level: string trust_level: string
} }
const peerNodes = ref<PeerNode[]>([]) // Federation peers cached so the Folders tab's peer cards paint instantly
const peersLoading = ref(true) // on revisit while the list revalidates behind them.
const peersResource = useCachedResource<PeerNode[]>({
key: 'cloud.peer-nodes',
fetcher: async (signal) => {
const result = await rpcClient.federationListNodes()
void signal
return result?.nodes ?? []
},
ttlMs: 30_000,
immediate: false, // kicked from onMounted (keeps the legacy load order)
})
const peerNodes = computed(() => peersResource.entry.data ?? [])
const peersLoading = computed(() => peersResource.entry.loadState === 'loading')
const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing')
const loadError = ref('') const loadError = ref('')
const APP_ALIASES: Record<string, string[]> = { const APP_ALIASES: Record<string, string[]> = {
@ -579,42 +626,51 @@ function formatSize(bytes: number): string {
} }
// My Files (flat list of every own file across the sections) // My Files (flat list of every own file across the sections)
const myFiles = ref<FileBrowserItem[]>([]) // Cached: revisiting the tab renders the last walk instantly and re-walks in
const myFilesLoading = ref(false) // the background only when stale.
const myFilesLoaded = ref(false) const myFilesResource = useCachedResource<FileBrowserItem[]>({
key: 'cloud.my-files',
fetcher: fetchMyFiles,
ttlMs: 60_000,
immediate: false, // loaded when the My Files tab (or search) needs it
})
const myFiles = computed(() => myFilesResource.entry.data ?? [])
const myFilesLoading = computed(() => myFilesResource.entry.loadState === 'loading')
/** Depth-limited walk of the section folders; flat file list, capped. */ /** Depth-limited walk of the section folders; flat file list, capped. */
async function loadMyFiles(force = false) { async function fetchMyFiles(): Promise<FileBrowserItem[]> {
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return if (!fileBrowserRunning.value) return []
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return } const ok = await cloudStore.init()
myFilesLoading.value = true if (!ok) return []
try { const out: FileBrowserItem[] = []
const ok = await cloudStore.init() for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (!ok) return if (sectionId === 'files') continue // '/' would double-visit the sections
const out: FileBrowserItem[] = [] const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) { while (queue.length > 0 && out.length < 500) {
if (sectionId === 'files') continue // '/' would double-visit the sections const { path, depth } = queue.shift()!
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }] let items: FileBrowserItem[]
while (queue.length > 0 && out.length < 500) { try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
const { path, depth } = queue.shift()! for (const item of items) {
let items: FileBrowserItem[] const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
try { items = await fileBrowserClient.listDirectory(path) } catch { continue } if (item.isDir) {
for (const item of items) { if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}` } else {
if (item.isDir) { out.push({ ...item, path: itemPath })
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else {
out.push({ ...item, path: itemPath })
}
} }
} }
} }
out.sort((a, b) => a.name.localeCompare(b.name))
myFiles.value = out
myFilesLoaded.value = true
} finally {
myFilesLoading.value = false
} }
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
/** Load if never fetched or stale; `force` always re-walks. */
function loadMyFiles(force = false): Promise<void> {
if (force) return myFilesResource.refresh()
if (myFilesResource.entry.data === null || myFilesResource.isStale.value) {
return myFilesResource.refresh()
}
return Promise.resolve()
} }
const filteredMyFiles = computed(() => const filteredMyFiles = computed(() =>
@ -668,7 +724,8 @@ function handlePreview(path: string, context: FileBrowserItem[]) {
async function handleDelete(path: string) { async function handleDelete(path: string) {
try { try {
await cloudStore.deleteItem(path) await cloudStore.deleteItem(path)
myFiles.value = myFiles.value.filter(f => f.path !== path) // Delete confirmed update the cache in place (no rollback needed).
myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path))
searchResults.value = searchResults.value.filter(r => r.item?.path !== path) searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
} catch (e) { } catch (e) {
loadError.value = e instanceof Error ? e.message : 'Delete failed' loadError.value = e instanceof Error ? e.message : 'Delete failed'
@ -686,11 +743,6 @@ interface PeerFileEntry {
peerOnion: string peerOnion: string
} }
const peerFiles = ref<PeerFileEntry[]>([])
const peerFilesLoading = ref(false)
const peerFilesLoaded = ref(false)
const peerFilesErrors = ref(0)
interface CatalogItem { interface CatalogItem {
id: string id: string
filename: string filename: string
@ -704,46 +756,96 @@ function priceOf(access: CatalogItem['access']): number {
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0 return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
} }
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */ // Per-peer browse results live as individual cached entries so (a) each
async function loadPeerFiles(force = false) { // peer's card/rows render the moment THAT peer answers no more blocking on
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return // the slowest peer via Promise.allSettled and (b) revisits paint from
peerFilesLoading.value = true // cache. The response's `transport` (fips/tor) + measured latency ride
peerFilesErrors.value = 0 // along, giving every peer a live transport badge.
try { interface PeerBrowse {
if (peerNodes.value.length === 0) await loadPeers() items: CatalogItem[]
const results = await Promise.allSettled( transport: string | null
peerNodes.value.map(async (peer) => { latencyMs: number
const res = await rpcClient.call<{ items?: CatalogItem[] }>({ }
method: 'content.browse-peer',
params: { onion: peer.onion }, const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}`
timeout: 30000,
}) function browsePeer(peer: PeerNode): Promise<void> {
return { peer, items: res?.items ?? [] } return resources.refresh<PeerBrowse>(peerBrowseKey(peer.onion), async () => {
}), const t0 = Date.now()
) const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
const merged: PeerFileEntry[] = [] method: 'content.browse-peer',
for (const r of results) { params: { onion: peer.onion },
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue } timeout: 30000,
const { peer, items } = r.value // One slow/unreachable peer must cost its timeout ONCE, not ×3
const peerName = peer.name || peerDisplayName(peer.did) // the retry loop is why one dead peer meant a 90s spinner.
for (const item of items) { maxRetries: 1,
merged.push({ dedup: true,
key: `${peer.onion}:${item.id}`, })
filename: item.filename, return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
sizeBytes: item.size_bytes, })
priceSats: priceOf(item.access), }
category: categoryOf(item.mime_type || item.filename),
peerName, function peerBrowseEntry(onion: string) {
peerOnion: peer.onion, return resources.entry<PeerBrowse>(peerBrowseKey(onion))
}) }
}
/** Transport badge data for a peer (null until its first browse resolves). */
function peerTransport(onion: string): { transport: string; latencyMs: number } | null {
const e = peerBrowseEntry(onion)
if (!e.data?.transport) return null
return { transport: e.data.transport, latencyMs: e.data.latencyMs }
}
/** Aggregated peer files, incrementally updated as each peer resolves. */
const peerFiles = computed<PeerFileEntry[]>(() => {
const merged: PeerFileEntry[] = []
for (const peer of peerNodes.value) {
const e = peerBrowseEntry(peer.onion)
if (!e.data) continue
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of e.data.items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
} }
merged.sort((a, b) => a.filename.localeCompare(b.filename))
peerFiles.value = merged
peerFilesLoaded.value = true
} finally {
peerFilesLoading.value = false
} }
merged.sort((a, b) => a.filename.localeCompare(b.filename))
return merged
})
/** Peers still on their first in-flight browse (nothing cached yet). */
const peerFilesPending = computed(() =>
peerNodes.value.filter(p => {
const s = peerBrowseEntry(p.onion).loadState
return s === 'loading' || s === 'idle'
}).length,
)
/** Peers whose browse failed with no cached data to show. */
const peerFilesErrors = computed(() =>
peerNodes.value.filter(p => peerBrowseEntry(p.onion).loadState === 'error').length,
)
/** All-or-nothing spinner ONLY when nothing has ever been cached. */
const peerFilesLoading = computed(() =>
peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length,
)
/** Fan out content.browse-peer; each peer renders as it resolves. */
async function loadPeerFiles(force = false) {
if (peerNodes.value.length === 0) await loadPeers()
const targets = peerNodes.value.filter(p => {
if (force) return true
const e = peerBrowseEntry(p.onion)
const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000
return e.loadState === 'idle' || e.loadState === 'error' ? true : stale
})
// Fire-and-collect: the computed aggregation updates per resolution.
await Promise.allSettled(targets.map(p => browsePeer(p)))
} }
const filteredPeerFiles = computed(() => const filteredPeerFiles = computed(() =>
@ -821,47 +923,50 @@ const searchMineItems = computed(() =>
) )
// Existing counts / peers loading // Existing counts / peers loading
async function loadCounts() { async function fetchCounts(): Promise<Record<string, number>> {
if (!fileBrowserRunning.value) return if (!fileBrowserRunning.value) return {}
countsLoading.value = true const ok = await fileBrowserClient.login()
try { if (!ok) throw new Error('File Browser login failed')
const ok = await fileBrowserClient.login() const counts: Record<string, number> = {}
if (!ok) return for (const section of contentSections) {
for (const section of contentSections) { const path = SECTION_PATHS[section.id]
const path = SECTION_PATHS[section.id] if (!path) continue
if (!path) continue try {
try { counts[section.id] = (await fileBrowserClient.listDirectory(path)).length
const items = await fileBrowserClient.listDirectory(path) } catch {
sectionCounts.value[section.id] = items.length counts[section.id] = 0
} catch {
sectionCounts.value[section.id] = 0
}
} }
} catch (e) { }
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts' return counts
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e) }
} finally {
countsLoading.value = false function loadCounts() {
if (countsResource.entry.data === null || countsResource.isStale.value) {
void countsResource.refresh()
} }
} }
onMounted(() => { onMounted(async () => {
loadCounts() loadCounts()
loadPeers() await loadPeers()
// Warm the per-peer browse cache in the background: peer cards get their
// FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so
// quick revisits don't refetch.
void loadPeerFiles()
})
// File Browser can finish its startup scan after we mount pick counts up
// the moment it becomes available instead of showing a permanent blank.
watch(fileBrowserRunning, (running) => {
if (running) loadCounts()
}) })
async function loadPeers() { async function loadPeers() {
const hadPeers = peerNodes.value.length > 0 await peersResource.refresh()
peersLoading.value = true // Surface refresh failures in the banner the cached peer list stays
try { // visible either way (keep-last-known-value).
const result = await rpcClient.federationListNodes() const e = peersResource.entry
peerNodes.value = result?.nodes ?? [] if (e.error) loadError.value = e.error
} catch (e) {
if (!hadPeers) peerNodes.value = []
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
} finally {
peersLoading.value = false
}
} }
function peerDisplayName(did: string): string { function peerDisplayName(did: string): string {

View File

@ -306,12 +306,12 @@ const backLabel = computed(() => {
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder' return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
}) })
// Initialize native file browser when entering a native-UI section // Initialize native file browser when entering a native-UI section.
// No reset() here: navigate() serves the per-path cache instantly and
// revalidates underneath resetting wiped the listing and forced a
// spinner on every folder entry.
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => { watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
if (native && sec) { if (native && sec) {
if (cloudStore.currentPath !== path) {
cloudStore.reset()
}
const ok = await cloudStore.init() const ok = await cloudStore.init()
if (ok) { if (ok) {
await cloudStore.navigate(path) await cloudStore.navigate(path)

View File

@ -199,8 +199,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
interface Identity { interface Identity {
@ -228,9 +229,30 @@ interface Credential {
status: string status: string
} }
const identities = ref<Identity[]>([]) // Cached: revisits paint identities/credentials instantly and revalidate
const credentials = ref<Credential[]>([]) // behind them (errors keep the last-known lists).
const loadingCreds = ref(false) const identitiesRes = useCachedResource<Identity[]>({
key: 'credentials.identities',
fetcher: async (signal) => {
const result = await rpcClient.call<{ identities: Identity[] }>({
method: 'identity.list', params: {}, signal, dedup: true, maxRetries: 1,
})
return result?.identities || []
},
})
const credentialsRes = useCachedResource<Credential[]>({
key: 'credentials.list',
fetcher: async (signal) => {
const result = await rpcClient.call<{ credentials: Credential[] }>({
method: 'identity.list-credentials', params: {}, signal, dedup: true, maxRetries: 1,
})
return result?.credentials || []
},
})
const identities = computed(() => identitiesRes.data.value ?? [])
const credentials = computed(() => credentialsRes.data.value ?? [])
const loadingCreds = computed(() =>
credentialsRes.loadState.value === 'loading' || credentialsRes.loadState.value === 'refreshing')
const selectedCredential = ref<Credential | null>(null) const selectedCredential = ref<Credential | null>(null)
const credCopied = ref(false) const credCopied = ref(false)
const revoking = ref(false) const revoking = ref(false)
@ -280,31 +302,10 @@ function formatClaims(subject: Record<string, unknown>): string {
return JSON.stringify(claims, null, 2) return JSON.stringify(claims, null, 2)
} }
async function loadIdentities() {
try {
const result = await rpcClient.call<{ identities: Identity[] }>({
method: 'identity.list',
params: {},
})
identities.value = result.identities || []
} catch (e) {
identities.value = []
if (import.meta.env.DEV) console.warn('Failed to load identities:', e)
}
}
async function loadCredentials() { async function loadCredentials() {
loadingCreds.value = true await credentialsRes.refresh()
try { if (credentialsRes.error.value) {
const result = await rpcClient.call<{ credentials: Credential[] }>({ showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
method: 'identity.list-credentials',
params: {},
})
credentials.value = result.credentials || []
} catch (e) {
showToast(`Failed to load credentials: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
} finally {
loadingCreds.value = false
} }
} }
@ -404,9 +405,8 @@ async function copyCredentialJson() {
setTimeout(() => { credCopied.value = false }, 2000) setTimeout(() => { credCopied.value = false }, 2000)
} }
onMounted(async () => { // Both resources fetch themselves on first use (skipping the fetch entirely
await Promise.all([loadIdentities(), loadCredentials()]) // when the cached value is fresh).
})
defineExpose({ credentials, loadCredentials }) defineExpose({ credentials, loadCredentials })
</script> </script>

View File

@ -229,6 +229,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport' import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync' import { useSyncStore } from '@/stores/sync'
@ -250,8 +251,15 @@ const transportStore = useTransportStore()
const appStore = useAppStore() const appStore = useAppStore()
const syncStore = useSyncStore() const syncStore = useSyncStore()
const nodes = ref<FederatedNode[]>([]) // Cached: revisits paint the node list instantly; the 5s poll and mutation
const loading = ref(true) // refreshes revalidate behind it. `loading` is initial-load only (background
// refreshes keep content on screen the old showLoader:false semantics).
const nodesRes = useCachedResource<FederatedNode[]>({
key: 'federation.nodes',
fetcher: async () => (await rpcClient.federationListNodes()).nodes,
})
const nodes = computed(() => nodesRes.data.value ?? [])
const loading = computed(() => nodesRes.loadState.value === 'loading')
const error = ref('') const error = ref('')
const selectedNode = ref<FederatedNode | null>(null) const selectedNode = ref<FederatedNode | null>(null)
const inviteType = ref<'trusted' | 'observer'>('trusted') const inviteType = ref<'trusted' | 'observer'>('trusted')
@ -320,7 +328,12 @@ const mapLinks = computed(() => {
})) }))
}) })
const dwnStatus = ref<DwnStatus | null>(null) const dwnStatusRes = useCachedResource<DwnStatus>({
key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
immediate: false,
})
const dwnStatus = computed(() => dwnStatusRes.data.value)
const dwnSyncing = ref(false) const dwnSyncing = ref(false)
const dwnSyncDotClass = computed(() => { const dwnSyncDotClass = computed(() => {
@ -500,25 +513,13 @@ function isOnlineCheck(node: FederatedNode): boolean {
return lastSeen > tenMinutesAgo return lastSeen > tenMinutesAgo
} }
/** Explicit reload (mutations, retry): surfaces a load failure in the error
* banner. The background poll calls nodesRes.refresh() directly and stays
* silent, like the old surfaceErrors:false path. */
async function loadNodes() { async function loadNodes() {
return loadNodesWithOptions() await nodesRes.refresh()
} if (nodesRes.error.value) error.value = nodesRes.error.value
else error.value = ''
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
const showLoader = options.showLoader ?? nodes.value.length === 0
const surfaceErrors = options.surfaceErrors ?? true
try {
if (showLoader) loading.value = true
const result = await rpcClient.federationListNodes()
nodes.value = result.nodes
error.value = ''
} catch (e) {
if (surfaceErrors) {
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
}
} finally {
if (showLoader) loading.value = false
}
} }
function handleGenerateInvite(type: 'trusted' | 'observer') { function handleGenerateInvite(type: 'trusted' | 'observer') {
@ -610,13 +611,8 @@ async function deployApp(did: string, appId: string) {
} }
} }
async function loadDwnStatus() { function loadDwnStatus() {
try { return dwnStatusRes.refresh()
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
dwnStatus.value = result
} catch {
dwnStatus.value = null
}
} }
async function triggerDwnSync() { async function triggerDwnSync() {
@ -681,7 +677,6 @@ async function rotateDid(password: string) {
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => { onMounted(async () => {
loadNodesWithOptions({ showLoader: true })
loadDwnStatus() loadDwnStatus()
loadDiscoveryState() loadDiscoveryState()
loadPendingRequests() loadPendingRequests()
@ -694,7 +689,7 @@ onMounted(async () => {
// Self DID not available // Self DID not available
} }
autoRefreshTimer = setInterval(() => { autoRefreshTimer = setInterval(() => {
loadNodesWithOptions({ showLoader: false, surfaceErrors: false }) void nodesRes.refresh()
loadPendingRequests() loadPendingRequests()
}, 5000) }, 5000)
}) })

View File

@ -21,6 +21,18 @@ const mesh = useMeshStore()
const transport = useTransportStore() const transport = useTransportStore()
const route = useRoute() const route = useRoute()
// "Flash LoRa" header button target: the connected radio wins, else any
// detected-but-unconnected stick. Opens the global setup modal at its
// flash step (backend stops the listener and frees the port itself).
const flashLoraPath = computed(() =>
(mesh.status?.device_connected && mesh.status.device_path) ||
mesh.status?.detected_devices?.[0] ||
''
)
function openFlashLora() {
if (flashLoraPath.value) mesh.openFlashFlow(flashLoraPath.value)
}
// Responsive layout breakpoints // Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536) const isWideDesktop = ref(window.innerWidth >= 1536)
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200) const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
@ -38,6 +50,8 @@ const activeChatChannel = ref<{ index: number; name: string } | null>(null)
const messageText = ref('') const messageText = ref('')
const sendError = ref('') const sendError = ref('')
const broadcasting = ref(false) const broadcasting = ref(false)
const broadcastResult = ref<string | null>(null) // 'ok' | error message
const refreshing = ref(false)
const configuring = ref(false) const configuring = ref(false)
const connectingDevice = ref<string | null>(null) const connectingDevice = ref<string | null>(null)
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue). // Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
@ -383,12 +397,22 @@ onMounted(async () => {
archPollInterval = setInterval(loadArchMessages, 15000) archPollInterval = setInterval(loadArchMessages, 15000)
} }
if (!pollInterval) { if (!pollInterval) {
let tick = 0
pollInterval = setInterval(() => { pollInterval = setInterval(() => {
mesh.fetchStatus() mesh.fetchStatus()
mesh.fetchPeers() mesh.fetchPeers()
mesh.fetchMessages() mesh.fetchMessages()
mesh.fetchDeadmanStatus() mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders() mesh.fetchBlockHeaders()
// Contacts/aliases, federation nodes and the outbox badge previously
// loaded ONCE at mount and went permanently stale new federation
// peers or renames never appeared without a full page reload. Every
// 6th tick (~30s) keeps them fresh without adding per-5s load.
if (++tick % 6 === 0) {
void refreshContacts()
void refreshFederationNodes()
void refreshOutboxCount()
}
}, 5000) }, 5000)
} }
@ -884,8 +908,14 @@ function openChat(peer: MeshPeer) {
messageText.value = '' messageText.value = ''
activeTab.value = 'chat' activeTab.value = 'chat'
mobileShowChat.value = true mobileShowChat.value = true
mesh.markChatRead(peer.contact_id) nextTick(() => {
nextTick(() => scrollChatToBottom()) // Clear unread only after the chat has rendered and auto-scrolled to the
// latest messages ("you've actually seen them"), and clear ALL twins of
// the merged conversation the badge sums across every contact_id, so
// clearing just the clicked row's id left the badge stuck.
scrollChatToBottom()
mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id)
})
} }
function openChannelChat(channel: { index: number; name: string }) { function openChannelChat(channel: { index: number; name: string }) {
@ -997,9 +1027,25 @@ async function handleSendMessage() {
function scrollChatToBottom() { function scrollChatToBottom() {
if (chatScrollEl.value) { if (chatScrollEl.value) {
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
mesh.viewingAtBottom = true
} }
} }
// Scroll-position read tracking: while the latest messages are in view the
// open conversation stays "read" (new arrivals don't badge); scrolled up into
// history, incoming messages accumulate unread like any normal chat app
// scrolling back down clears them.
function onChatScroll() {
const el = chatScrollEl.value
if (!el) return
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
mesh.viewingAtBottom = nearBottom
if (nearBottom && activeMergedPeer.value) {
mesh.markChatRead(activeMergedPeer.value.contact_ids)
}
scheduleReadReceipt()
}
// Keep the compose field focused after a send so the user can keep typing // Keep the compose field focused after a send so the user can keep typing
// without re-clicking it (Enter re-focuses natively, but the Send button // without re-clicking it (Enter re-focuses natively, but the Send button
// click otherwise leaves focus on the button). // click otherwise leaves focus on the button).
@ -1021,7 +1067,37 @@ function onChatWheel(e: WheelEvent) {
async function handleBroadcast() { async function handleBroadcast() {
broadcasting.value = true broadcasting.value = true
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false } broadcastResult.value = null
try {
await mesh.broadcastIdentity()
broadcastResult.value = 'ok'
} catch (e) {
broadcastResult.value = e instanceof Error ? e.message : 'Broadcast failed'
} finally {
broadcasting.value = false
setTimeout(() => { broadcastResult.value = null }, 4000)
}
}
async function handleRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
// Backend first: re-query the radio's contact table (mesh.refresh), then
// re-read EVERYTHING the list is built from peers, contacts/aliases,
// federation nodes, outbox not just the mesh caches.
await Promise.allSettled([
mesh.refreshRadio(),
mesh.refreshAll(),
refreshContacts(),
refreshFederationNodes(),
refreshOutboxCount(),
])
// Radio contact refresh is async on the backend pick up its result.
await mesh.fetchPeers()
} finally {
refreshing.value = false
}
} }
async function handleToggleEnabled() { async function handleToggleEnabled() {
@ -1094,14 +1170,15 @@ interface PendingReply {
} }
const pendingReply = ref<PendingReply | null>(null) const pendingReply = ref<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(null) const actionMenuForId = ref<number | null>(null)
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
function openActionMenu(msgId: number, ev?: Event) { function openActionMenu(msgId: number, ev?: Event) {
ev?.stopPropagation() ev?.stopPropagation()
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
reactionPickerForId.value = null
} }
function closeActionMenu() { function closeActionMenu() {
actionMenuForId.value = null actionMenuForId.value = null
reactionPickerForId.value = null
} }
function handleDocClickForMenu(ev: MouseEvent) { function handleDocClickForMenu(ev: MouseEvent) {
if (actionMenuForId.value === null) return if (actionMenuForId.value === null) return
@ -1211,6 +1288,43 @@ function scheduleReadReceipt() {
}, 400) }, 400)
} }
// Hop visualization modal click a message's transport pill to see how it
// traveled: radio hops + signal quality for LoRa transports, overlay/circuit
// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link,
// not stored per-message) labelled as such in the modal.
const hopVizMsg = ref<MeshMessage | null>(null)
const hopVizPeer = computed<MeshPeer | null>(() => {
const m = hopVizMsg.value
if (!m) return null
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
})
function hopVizHops(): number | null {
const p = hopVizPeer.value
if (!p || p.hops == null || p.hops === 0xff) return null
return p.hops
}
function signalQualityLabel(snr: number | null, rssi: number | null): string {
if (snr == null && rssi == null) return 'signal unknown'
if (snr != null) {
if (snr > 5) return 'excellent signal'
if (snr > 0) return 'good signal'
if (snr > -10) return 'fair signal'
return 'weak signal'
}
if (rssi != null) {
if (rssi > -80) return 'excellent signal'
if (rssi > -100) return 'good signal'
if (rssi > -115) return 'fair signal'
return 'weak signal'
}
return 'signal unknown'
}
// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so
// the action menu stays compact and a larger emoji palette fits.
const reactionPickerForId = ref<number | null>(null)
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
async function reactTo(msg: MeshMessage, emoji: string) { async function reactTo(msg: MeshMessage, emoji: string) {
const key = messageKeyFor(msg) const key = messageKeyFor(msg)
@ -1398,6 +1512,14 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
const imageQualityChoice = ref<{ file: File } | null>(null) const imageQualityChoice = ref<{ file: File } | null>(null)
const imageQualityEstimates = ref<Map<string, string>>(new Map()) const imageQualityEstimates = ref<Map<string, string>>(new Map())
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
// Transport pills at the bottom of the image modal: when the peer is also a
// federation node (FIPS overlay and/or Tor), the user can send the image that
// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor'
// route via the blob/content-ref path (the dial layer prefers FIPS with Tor
// fallback the pills are capability labels, defaulted to whichever of the
// two actually worked last for this peer).
const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora')
const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false })
function formatEstSeconds(seconds: number): string { function formatEstSeconds(seconds: number): string {
if (seconds < 60) return `~${seconds}s` if (seconds < 60) return `~${seconds}s`
@ -1407,6 +1529,8 @@ function formatEstSeconds(seconds: number): string {
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> { async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
imageQualityChoice.value = { file } imageQualityChoice.value = { file }
imageQualityEstimates.value = new Map() imageQualityEstimates.value = new Map()
imageSendTransport.value = 'lora'
imageTransportOptions.value = { fips: false, tor: false }
// Fire off estimates for all presets in parallel each preset's nominal // Fire off estimates for all presets in parallel each preset's nominal
// target size (or the real file size for 'original') against the SAME // target size (or the real file size for 'original') against the SAME
// mesh.transport-advice RPC the non-image attach flow already uses. // mesh.transport-advice RPC the non-image attach flow already uses.
@ -1418,6 +1542,7 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis
const label = const label =
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds) advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label) imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor }
} catch { } catch {
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?') imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
} }
@ -1546,12 +1671,18 @@ async function handleAttachFile(ev: Event) {
attaching.value = true attaching.value = true
attachError.value = null attachError.value = null
try { try {
let forcedTransport: 'fips' | 'tor' | null = null
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const preset = await openImageQualityDialog(file, peer.contact_id) const preset = await openImageQualityDialog(file, peer.contact_id)
if (!preset) return // user cancelled if (!preset) return // user cancelled
if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value
file = await compressImage(file, preset) file = await compressImage(file, preset)
} }
if (!(await sendFileViaBestTransport(file, peer))) return if (forcedTransport) {
// User picked a FIPS/Tor pill in the image modal skip the LoRa
// advice/chooser flow entirely and ship via the content-ref path.
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
} else if (!(await sendFileViaBestTransport(file, peer))) return
messageText.value = '' messageText.value = ''
nextTick(() => scrollChatToBottom()) nextTick(() => scrollChatToBottom())
} catch (e) { } catch (e) {
@ -1741,14 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span> <span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span>
</p> </p>
</div> </div>
<a <button
href="https://flasher.meshcore.co.uk/"
target="_blank"
rel="noopener noreferrer"
class="glass-button mesh-flasher-btn" class="glass-button mesh-flasher-btn"
:disabled="!flashLoraPath"
:title="flashLoraPath ? '' : 'Plug in a LoRa radio first'"
@click="openFlashLora"
> >
Flash Meshcore <span class="mesh-flasher-sep">|</span> Choose Companion USB Flash LoRa
</a> </button>
</div> </div>
<!-- Error banner --> <!-- Error banner -->
@ -1830,8 +1961,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled"> <button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }} {{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
</button> </button>
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast"> <button
{{ broadcasting ? 'Sending...' : 'Broadcast' }} class="glass-button mesh-action-btn"
:class="broadcastResult === 'ok' ? 'mesh-action-ok' : ''"
:disabled="!mesh.status?.device_connected || broadcasting"
:title="broadcastResult && broadcastResult !== 'ok' ? broadcastResult : 'Announce this node so nearby radios learn about it'"
@click="handleBroadcast"
>
{{ broadcasting ? 'Sending…' : broadcastResult === 'ok' ? 'Sent ✓' : broadcastResult ? 'Failed ✕' : 'Broadcast' }}
</button> </button>
<button <button
class="glass-button mesh-action-btn" class="glass-button mesh-action-btn"
@ -1841,7 +1978,12 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
> >
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }} {{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
</button> </button>
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button> <!-- While refreshing show ONLY the spinner spinner + "Refreshing…"
together overflow the button's fixed width. -->
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
<template v-if="!refreshing">Refresh</template>
</button>
</div> </div>
<!-- Peers list --> <!-- Peers list -->
@ -1871,7 +2013,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>&times;</button> >&times;</button>
</div> </div>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty"> <!-- Only claim "no peers" when the MERGED list (radio + federation)
is truly empty with no radio attached the federation rows and
the two channel rows must still render. -->
<div v-if="displayedPeers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
No peers discovered yet. No peers discovered yet.
</div> </div>
@ -2028,7 +2173,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span> <span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div> </div>
</div> </div>
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel"> <div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages"> <div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello! No messages yet. Say hello!
</div> </div>
@ -2140,8 +2285,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button <button
class="mesh-typed-content-download-btn" class="mesh-typed-content-download-btn"
title="Download" title="Download"
aria-label="Download image"
@click="downloadAttachment(msg.typed_payload as any)" @click="downloadAttachment(msg.typed_payload as any)"
>&#x2B07;</button> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
<path d="M5 19h14" />
</svg>
</button>
</div> </div>
<audio <audio
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')" v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
@ -2165,10 +2316,19 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
@click="openMeshLightbox(msg.typed_payload as any)" @click="openMeshLightbox(msg.typed_payload as any)"
/> />
<button <button
class="btn" class="mesh-typed-content-fetch-btn"
:disabled="fetchingCids.has(msg.typed_payload.cid)" :disabled="fetchingCids.has(msg.typed_payload.cid)"
@click="handleFetchContent(msg.typed_payload as any)" @click="handleFetchContent(msg.typed_payload as any)"
> >
<span
v-if="fetchingCids.has(msg.typed_payload.cid)"
class="mesh-refresh-spinner"
aria-hidden="true"
></span>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
<path d="M5 19h14" />
</svg>
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }} {{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
</button> </button>
</template> </template>
@ -2183,7 +2343,8 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Default: plain text --> <!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div> <div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta"> <div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span> <span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
<button class="mesh-chat-more-btn" title="How this message traveled" aria-label="Show message route" @click.stop="hopVizMsg = msg"></button>
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span> <span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span> <span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span> <span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
@ -2214,17 +2375,25 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button> <button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button> <button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
<button <button
v-for="emoji in QUICK_REACTIONS" class="mesh-chat-action-btn"
:key="emoji" :class="{ active: reactionPickerForId === msg.id }"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null" :disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)" @click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
> >😀 React </button>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button> <button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button>
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
<button
v-for="emoji in REACTION_PALETTE"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2363,7 +2532,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
</Teleport> </Teleport>
<!-- Transport chooser modal: shown when attachment size fits both mesh <!-- Transport chooser modal: shown when attachment size fits both mesh
(inline-chunked) and Tor. User picks which path to send it over. --> (inline-chunked) and Tor. User picks which path to send it over.
Teleported to body so the fixed backdrop covers the FULL viewport
rendered in place it sits inside a transformed/filtered glass panel,
which traps position:fixed to just the right chat panel. -->
<Teleport to="body">
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')"> <div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
<div class="glass-card mesh-transport-modal"> <div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">📎 How should I send this?</h3> <h3 class="mesh-transport-title">📎 How should I send this?</h3>
@ -2392,10 +2565,13 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button> <button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
</div> </div>
</div> </div>
</Teleport>
<!-- Image quality-picker modal: shown before sending an image attachment. <!-- Image quality-picker modal: shown before sending an image attachment.
Each preset shows its nominal size target + a transfer-time estimate Each preset shows its nominal size target + a transfer-time estimate
from the same mesh.transport-advice RPC the file-attach flow uses. --> from the same mesh.transport-advice RPC the file-attach flow uses.
Teleported to body see the transport chooser above. -->
<Teleport to="body">
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)"> <div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
<div class="glass-card mesh-transport-modal"> <div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">🖼 Choose Image Quality</h3> <h3 class="mesh-transport-title">🖼 Choose Image Quality</h3>
@ -2412,12 +2588,94 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
> >
<span class="mesh-transport-icon">🖼</span> <span class="mesh-transport-icon">🖼</span>
<span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span> <span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span>
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span> <span class="mesh-transport-meta">{{
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
}}</span>
</button> </button>
</div> </div>
<!-- Transport pills: LoRa is always available (it's a mesh chat);
FIPS/Tor appear when the peer is a reachable federation node,
letting the user send the image that way instead. -->
<div
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
class="mesh-image-transport-row"
>
<span class="mesh-image-transport-caption">Send via</span>
<button
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'lora' }"
@click="imageSendTransport = 'lora'"
>📡 LoRa</button>
<button
v-if="imageTransportOptions.fips"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'fips' }"
@click="imageSendTransport = 'fips'"
> FIPS</button>
<button
v-if="imageTransportOptions.tor"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'tor' }"
@click="imageSendTransport = 'tor'"
>🧅 Tor</button>
</div>
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button> <button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
</div> </div>
</div> </div>
</Teleport>
<!-- Hop visualization modal: click a message's transport pill to see how
it traveled radio hops + live signal for LoRa transports, overlay/
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
<Teleport to="body">
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) || 'Message' }} route</h3>
<p class="mesh-transport-sub">
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
</p>
<div class="mesh-hopviz-chain">
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
</div>
<template v-if="hopVizMsg.transport === 'tor'">
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
</template>
<template v-else-if="hopVizMsg.transport === 'fips'">
<div class="mesh-hopviz-link"> FIPS overlay · direct peer-to-peer</div>
</template>
<template v-else-if="!hopVizMsg.transport">
<div class="mesh-hopviz-link">🛰 transport wasn't recorded for this message</div>
</template>
<template v-else>
<div class="mesh-hopviz-link">
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node"></span>
</span>
</div>
</template>
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
</div>
</div>
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
</div>
<div class="mesh-hopviz-signal">
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
</div>
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
</div>
</div>
</Teleport>
<!-- The "mesh device detected" setup flow is now the global <!-- The "mesh device detected" setup flow is now the global
MeshDeviceSetupModal mounted in App.vue (fires on every page, MeshDeviceSetupModal mounted in App.vue (fires on every page,

View File

@ -218,6 +218,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useHomeStatusStore } from '@/stores/homeStatus' import { useHomeStatusStore } from '@/stores/homeStatus'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue' import LineChart from '@/components/LineChart.vue'
@ -291,11 +292,51 @@ const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboa
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5')) const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
const homeStatus = useHomeStatusStore() const homeStatus = useHomeStatusStore()
const current = ref<MetricSnapshot | null>(null) // Cached: revisits paint the last snapshot/chart/alerts instantly and the 5s
const history = ref<MetricSnapshot[]>([]) // poll revalidates behind them; errors keep the last-known values.
const containers = ref<ContainerMetrics[]>([]) const currentRes = useCachedResource<MetricSnapshot>({
const alerts = ref<FiredAlert[]>([]) key: 'monitoring.current',
const alertRules = ref<AlertRule[]>([]) fetcher: async (signal) => {
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
method: 'monitoring.current', signal, dedup: true, maxRetries: 1,
})
if (!data || !('system' in data)) throw new Error('metrics not ready')
return data
},
})
const historyRes = useCachedResource<MetricSnapshot[]>({
key: 'monitoring.history.minute60',
fetcher: async (signal) => {
const data = await rpcClient.call<HistoryResponse>({
method: 'monitoring.history', params: { resolution: 'minute', count: 60 },
signal, dedup: true, maxRetries: 1,
})
return data?.data ?? []
},
})
const alertsRes = useCachedResource<FiredAlert[]>({
key: 'monitoring.alerts',
fetcher: async (signal) => {
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
method: 'monitoring.alerts', params: { count: 50 }, signal, dedup: true, maxRetries: 1,
})
return (data?.alerts ?? []).reverse()
},
})
const alertRulesRes = useCachedResource<AlertRule[]>({
key: 'monitoring.alert-rules',
fetcher: async (signal) => {
const data = await rpcClient.call<{ rules: AlertRule[] }>({
method: 'monitoring.alert-rules', signal, dedup: true, maxRetries: 1,
})
return data?.rules ?? []
},
})
const current = computed(() => currentRes.data.value)
const history = computed(() => historyRes.data.value ?? [])
const containers = computed<ContainerMetrics[]>(() => currentRes.data.value?.containers ?? [])
const alerts = computed(() => alertsRes.data.value ?? [])
const alertRules = computed(() => alertRulesRes.data.value ?? [])
const showAlertConfig = ref(false) const showAlertConfig = ref(false)
const chartWidth = ref(380) const chartWidth = ref(380)
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
@ -464,66 +505,10 @@ async function exportMetrics(format: 'csv' | 'json') {
} }
} }
async function fetchCurrent() {
try {
await homeStatus.refreshSystemStats()
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
method: 'monitoring.current',
})
if (data && 'system' in data) {
current.value = data
containers.value = data.containers ?? []
}
} catch {
// Silently retry on next poll
}
}
async function fetchHistory() {
try {
const data = await rpcClient.call<HistoryResponse>({
method: 'monitoring.history',
params: { resolution: 'minute', count: 60 },
})
if (data?.data) {
history.value = data.data
}
} catch {
// Silently retry on next poll
}
}
async function fetchAlerts() {
try {
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
method: 'monitoring.alerts',
params: { count: 50 },
})
if (data?.alerts) {
alerts.value = data.alerts.reverse()
}
} catch {
// Silently retry on next poll
}
}
async function fetchAlertRules() {
try {
const data = await rpcClient.call<{ rules: AlertRule[] }>({
method: 'monitoring.alert-rules',
})
if (data?.rules) {
alertRules.value = data.rules
}
} catch {
// Non-critical
}
}
async function toggleAlertRule(kind: string, enabled: boolean) { async function toggleAlertRule(kind: string, enabled: boolean) {
try { try {
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } }) await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
await fetchAlertRules() await alertRulesRes.refresh()
} catch { } catch {
// Non-critical // Non-critical
} }
@ -534,7 +519,7 @@ async function updateThreshold(kind: string, value: string) {
if (isNaN(threshold) || threshold <= 0) return if (isNaN(threshold) || threshold <= 0) return
try { try {
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } }) await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
await fetchAlertRules() await alertRulesRes.refresh()
} catch { } catch {
// Non-critical // Non-critical
} }
@ -543,7 +528,7 @@ async function updateThreshold(kind: string, value: string) {
async function acknowledgeAlert(id: string) { async function acknowledgeAlert(id: string) {
try { try {
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } }) await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
await fetchAlerts() await alertsRes.refresh()
} catch { } catch {
// Non-critical // Non-critical
} }
@ -556,18 +541,18 @@ function updateChartWidth() {
} }
} }
onMounted(async () => { onMounted(() => {
updateChartWidth() updateChartWidth()
window.addEventListener('resize', updateChartWidth) window.addEventListener('resize', updateChartWidth)
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts(), fetchAlertRules()]) // The cached resources fetch themselves on first use; the poll keeps the
// live view fresh (refreshes dedup in the store).
pollTimer = setInterval(async () => { void homeStatus.refreshSystemStats()
try { pollTimer = setInterval(() => {
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts()]) void homeStatus.refreshSystemStats()
} catch { void currentRes.refresh()
// Background poll ignore transient errors void historyRes.refresh()
} void alertsRes.refresh()
}, 5000) }, 5000)
}) })

View File

@ -594,10 +594,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, reactive, watch, onMounted } from 'vue' import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import { useAudioPlayer } from '@/composables/useAudioPlayer' import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip' import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
@ -625,16 +626,33 @@ interface CatalogItem {
access: string | { paid: { price_sats: number } } access: string | { paid: { price_sats: number } }
} }
const loading = ref(true) const resources = useResourcesStore()
const currentPeer = ref<PeerNode | null>(null) const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null) const downloading = ref<string | null>(null)
const playing = ref<string | null>(null) const playing = ref<string | null>(null)
const purchaseError = ref<string | null>(null) const purchaseError = ref<string | null>(null)
// The catalog is the SAME cached entry Cloud.vue's per-peer fan-in fills
// (`cloud.peer-browse:<onion>`): arriving here from the Cloud page paints
// the file list instantly from cache and revalidates behind it.
interface PeerBrowse {
items: CatalogItem[]
transport: string | null
latencyMs: number
}
const peerOnion = computed(() => props.peerId || currentPeer.value?.onion || '')
function browseEntry() {
return resources.entry<PeerBrowse>(`cloud.peer-browse:${peerOnion.value}`)
}
const catalogItems = computed(() => browseEntry().data?.items ?? [])
const catalogError = computed(() => browseEntry().error ?? '')
const loading = computed(() => {
const s = browseEntry().loadState
return s === 'loading' || s === 'refreshing' || (s === 'idle' && !!peerOnion.value)
})
// Transport actually used to reach this peer (returned by content.browse-peer) // Transport actually used to reach this peer (returned by content.browse-peer)
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21). // so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
const transport = ref<string | null>(null) const transport = computed(() => browseEntry().data?.transport ?? null)
const transportPill = computed(() => { const transportPill = computed(() => {
switch (transport.value) { switch (transport.value) {
case 'fips': case 'fips':
@ -807,44 +825,62 @@ onMounted(async () => {
loadCatalog(), loadCatalog(),
loadOwned(), loadOwned(),
]) ])
} else {
loading.value = false
} }
// No peerId peerOnion is empty and `loading` stays false on its own.
}) })
async function loadCatalog() { function loadCatalog(): Promise<void> {
const onion = props.peerId || currentPeer.value?.onion const onion = peerOnion.value
if (!onion) return if (!onion) return Promise.resolve()
const hadItems = catalogItems.value.length > 0 return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
loading.value = true const t0 = Date.now()
catalogError.value = ''
try {
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({ const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer', method: 'content.browse-peer',
params: { onion }, params: { onion },
timeout: 30000, timeout: 30000,
// The caller has its own timeout UX; retry×3 turned one slow peer
// into a 90s spinner.
maxRetries: 1,
dedup: true,
})
return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 }
})
}
// Load visual previews for image and video items when catalog loads.
// Audio files don't need visual thumbnails they show a waveform icon.
// The fan-out is capped (3 concurrent) and aborts on unmount it used to
// fire one 30s RPC per media item all at once, unbounded.
const previewAborter = new AbortController()
onUnmounted(() => previewAborter.abort())
const previewQueued = new Set<string>()
let previewQueue: CatalogItem[] = []
let previewWorkers = 0
const PREVIEW_CONCURRENCY = 3
function pumpPreviews(onion: string) {
while (previewWorkers < PREVIEW_CONCURRENCY && previewQueue.length > 0) {
const item = previewQueue.shift()!
previewWorkers++
void loadPreview(onion, item).finally(() => {
previewWorkers--
pumpPreviews(onion)
}) })
catalogItems.value = result?.items ?? []
transport.value = result?.transport ?? null
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
if (!hadItems) catalogItems.value = []
} finally {
loading.value = false
} }
} }
// Load visual previews for image and video items when catalog loads watch(catalogItems, (items) => {
// Audio files don't need visual thumbnails they show a waveform icon const onion = peerOnion.value
watch(catalogItems, async (items) => {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return if (!onion) return
for (const item of items) { for (const item of items) {
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) { const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
loadPreview(onion, item) if (isVisual && !previewUrls[item.id] && !previewQueued.has(item.id)) {
previewQueued.add(item.id)
previewQueue.push(item)
} }
} }
}) pumpPreviews(onion)
}, { immediate: true })
async function loadPreview(onion: string, item: CatalogItem) { async function loadPreview(onion: string, item: CatalogItem) {
try { try {
@ -852,6 +888,8 @@ async function loadPreview(onion: string, item: CatalogItem) {
method: 'content.preview-peer', method: 'content.preview-peer',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 30000, timeout: 30000,
maxRetries: 1,
signal: previewAborter.signal,
}) })
if (result?.data) { if (result?.data) {
const mime = result.content_type || item.mime_type const mime = result.content_type || item.mime_type

View File

@ -407,6 +407,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import QuickActionsCard from './server/QuickActionsCard.vue' import QuickActionsCard from './server/QuickActionsCard.vue'
import TorServicesCard from './server/TorServicesCard.vue' import TorServicesCard from './server/TorServicesCard.vue'
@ -434,18 +435,51 @@ const torStatusColor = computed(() => {
const autoSyncEnabled = ref(true) const autoSyncEnabled = ref(true)
const logCount = ref(0) const logCount = ref(0)
// Network data // Network data a cached aggregate over four RPCs (allSettled: a failing
const networkLoading = ref(true) // one keeps that slice's previous values). Revisits paint instantly.
const networkRefreshing = ref(false) interface NetworkData {
const networkHasLoaded = ref(false) wifiCount: string; wifiSsid: string | null; torConnected: boolean; forwardCount: string
const networkData = ref({ vpnConnected: boolean; vpnProvider: string; vpnIp: string; wgIp: string; wgPubkey: string
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A', vpnHostname: string; vpnPeers: number
dnsProvider: string; dnsServers: string[]; dnsDoH: boolean
}
const defaultNetworkData = (): NetworkData => ({
wifiCount: 'N/A', wifiSsid: null, torConnected: false, forwardCount: 'N/A',
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0, vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false, dnsProvider: 'system', dnsServers: [], dnsDoH: false,
}) })
// immediate:false the fetcher merges onto the previous value via
// networkRes, so it must not run during this initializer (onMounted loads
// it). The explicit annotation breaks the self-referential inference cycle.
const networkRes: CachedResource<NetworkData> = useCachedResource<NetworkData>({
key: 'server.network-summary',
immediate: false,
fetcher: async () => {
const next = { ...(networkRes.data.value ?? defaultNetworkData()) }
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
rpcClient.vpnStatus(),
rpcClient.dnsStatus(),
])
if (diagRes.status === 'fulfilled') { next.torConnected = diagRes.value.tor_connected; next.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; next.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; next.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
if (vpnRes.status === 'fulfilled') { next.vpnConnected = vpnRes.value.connected; next.vpnProvider = vpnRes.value.provider ?? ''; next.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); next.wgIp = vpnRes.value.wg_ip ?? ''; next.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
if (dnsRes.status === 'fulfilled') { next.dnsProvider = dnsRes.value.provider; next.dnsServers = dnsRes.value.resolv_conf_servers ?? []; next.dnsDoH = dnsRes.value.doh_enabled }
return next
},
})
const networkData = computed(() => networkRes.data.value ?? defaultNetworkData())
const networkLoading = computed(() => networkRes.loadState.value === 'loading')
const networkRefreshing = computed(() => networkRes.loadState.value === 'refreshing')
// FIPS status row for the Local Network card. Full FIPS card lives below. // FIPS status row for the Local Network card. Full FIPS card lives below.
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null) const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({
key: 'server.fips-summary',
immediate: false,
fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
})
const fipsSummary = computed(() => fipsSummaryRes.data.value)
const fipsRowLabel = computed(() => { const fipsRowLabel = computed(() => {
const s = fipsSummary.value const s = fipsSummary.value
if (!s) return '…' if (!s) return '…'
@ -467,32 +501,12 @@ const fipsRowTextClass = computed(() => {
if (s.anchor_connected === false) return 'text-orange-400' if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400' return 'text-green-400'
}) })
async function loadFipsSummary() { function loadFipsSummary() {
try { return fipsSummaryRes.refresh()
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
} catch { /* backend too old */ }
} }
async function loadNetworkData() { function loadNetworkData() {
const initialLoad = !networkHasLoaded.value return networkRes.refresh()
networkLoading.value = initialLoad
networkRefreshing.value = !initialLoad
try {
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
rpcClient.vpnStatus(),
rpcClient.dnsStatus(),
])
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
} catch { /* keep existing/default values */ } finally {
networkHasLoaded.value = true
networkLoading.value = false
networkRefreshing.value = false
}
} }
// VPN peer management // VPN peer management
@ -507,13 +521,18 @@ const sanitizedPeerQrSvg = computed(() =>
) )
const peerError = ref('') const peerError = ref('')
const copiedConfig = ref(false) const copiedConfig = ref(false)
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([]) const vpnPeersRes = useCachedResource<{ name: string; ip: string; type?: string; npub?: string }[]>({
key: 'server.vpn-peers',
immediate: false,
fetcher: async (signal) => {
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers', signal, dedup: true, maxRetries: 1 })
return res.peers || []
},
})
const vpnPeers = computed(() => vpnPeersRes.data.value ?? [])
async function loadVpnPeers() { function loadVpnPeers() {
try { return vpnPeersRes.refresh()
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
vpnPeers.value = res.peers || []
} catch { /* no peers */ }
} }
async function createPeer() { async function createPeer() {
@ -557,7 +576,7 @@ async function removePeer(name: string) {
removingPeer.value = name removingPeer.value = name
try { try {
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } }) await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name) vpnPeersRes.optimistic(cur => (cur ?? []).filter(p => p.name !== name))
} catch { /* ignore */ } } catch { /* ignore */ }
finally { removingPeer.value = '' } finally { removingPeer.value = '' }
} }
@ -583,10 +602,17 @@ async function copyPeerConfig() {
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] } interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
interface WifiNetwork { ssid: string; signal: number; security: string } interface WifiNetwork { ssid: string; signal: number; security: string }
const interfacesLoading = ref(true) const interfacesRes = useCachedResource<NetworkInterface[]>({
const interfacesRefreshing = ref(false) key: 'server.interfaces',
const interfacesHaveLoaded = ref(false) immediate: false,
const allInterfaces = ref<NetworkInterface[]>([]) fetcher: async (signal) => {
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, maxRetries: 1 })
return res.interfaces
},
})
const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading')
const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing')
const allInterfaces = computed(() => interfacesRes.data.value ?? [])
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')) const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi')) const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
@ -637,19 +663,19 @@ async function applyDnsConfig(customServers: string) {
const res = await rpcClient.configureDns(params) const res = await rpcClient.configureDns(params)
// Never trust the response shape: an undefined `servers` used to reach the // Never trust the response shape: an undefined `servers` used to reach the
// dnsDisplayLabel computed and crash the whole page render on `.length`. // dnsDisplayLabel computed and crash the whole page render on `.length`.
networkData.value.dnsProvider = res?.provider ?? provider // Write-through to the cached aggregate (the RPC already succeeded).
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? []) networkRes.optimistic(cur => ({
networkData.value.dnsDoH = !!res?.doh_enabled ...(cur ?? defaultNetworkData()),
dnsProvider: res?.provider ?? provider,
dnsServers: Array.isArray(res?.servers) ? res.servers : (params.servers ?? []),
dnsDoH: !!res?.doh_enabled,
}))
showDnsModal.value = false showDnsModal.value = false
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false } } catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
} }
async function loadInterfaces() { function loadInterfaces() {
const initialLoad = !interfacesHaveLoaded.value return interfacesRes.refresh()
const hadInterfaces = allInterfaces.value.length > 0
interfacesLoading.value = initialLoad
interfacesRefreshing.value = !initialLoad
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
} }
async function toggleWifiRadio(iface: NetworkInterface) { async function toggleWifiRadio(iface: NetworkInterface) {
@ -731,9 +757,18 @@ function formatBytes(bytes: number): string {
} }
// Tor Services // Tor Services
const torServices = ref<TorServiceInfo[]>([]) const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({
const torServicesLoading = ref(false) key: 'server.tor-services',
const torDaemonRunning = ref(false) immediate: false,
fetcher: async (signal) => {
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services', signal, dedup: true, maxRetries: 1 })
return { services: res.services || [], tor_running: res.tor_running ?? false }
},
})
const torServices = computed(() => torServicesRes.data.value?.services ?? [])
const torServicesLoading = computed(() =>
torServicesRes.loadState.value === 'loading' || torServicesRes.loadState.value === 'refreshing')
const torDaemonRunning = computed(() => torServicesRes.data.value?.tor_running ?? false)
const torRestarting = ref(false) const torRestarting = ref(false)
const torRotating = ref<string | false>(false) const torRotating = ref<string | false>(false)
const torDeleting = ref<string | false>(false) const torDeleting = ref<string | false>(false)
@ -750,11 +785,8 @@ const availableAppsForTor = computed(() => {
.sort((a, b) => a.title.localeCompare(b.title)) .sort((a, b) => a.title.localeCompare(b.title))
}) })
async function loadTorServices() { function loadTorServices() {
const hadServices = torServices.value.length > 0 return torServicesRes.refresh()
torServicesLoading.value = true
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
} }
async function copyTorAddress(address: string) { async function copyTorAddress(address: string) {
@ -798,14 +830,18 @@ async function createService(name: string, port: number | null) {
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() }) onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
// Poll VPN status every 15s so IP updates after pairing // Poll VPN status every 15s so IP updates after pairing (write-through to
// the cached aggregate without refetching the other three RPCs)
const vpnPollInterval = setInterval(async () => { const vpnPollInterval = setInterval(async () => {
try { try {
const vpnRes = await rpcClient.vpnStatus() const vpnRes = await rpcClient.vpnStatus()
networkData.value.vpnConnected = vpnRes.connected networkRes.optimistic(cur => ({
networkData.value.vpnProvider = vpnRes.provider ?? '' ...(cur ?? defaultNetworkData()),
networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '') vpnConnected: vpnRes.connected,
networkData.value.wgIp = vpnRes.wg_ip ?? '' vpnProvider: vpnRes.provider ?? '',
vpnIp: (vpnRes.ip_address ?? '').replace(/\/\d+$/, ''),
wgIp: vpnRes.wg_ip ?? '',
}))
} catch { /* ignore */ } } catch { /* ignore */ }
}, 15000) }, 15000)
onUnmounted(() => clearInterval(vpnPollInterval)) onUnmounted(() => clearInterval(vpnPollInterval))
@ -829,8 +865,11 @@ async function restartServices() {
async function checkTorStatus() { async function checkTorStatus() {
checkingTor.value = true; torStatusLabel.value = 'checking' checkingTor.value = true; torStatusLabel.value = 'checking'
try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' } try {
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false } await torServicesRes.refresh()
if (torServicesRes.error.value) torStatusLabel.value = 'stopped'
else torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped'
} finally { checkingTor.value = false }
} }
const logsToast = ref('') const logsToast = ref('')

View File

@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Credentials from '../Credentials.vue' import Credentials from '../Credentials.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@ -42,6 +43,8 @@ describe('Credentials', () => {
const wrapper = mount(Credentials, { const wrapper = mount(Credentials, {
global: { global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
mocks: { mocks: {
$router: { push: vi.fn() }, $router: { push: vi.fn() },
}, },

View File

@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue' import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@ -55,6 +56,8 @@ describe('PeerFiles', () => {
const wrapper = mount(PeerFiles, { const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' }, props: { peerId: 'peer.onion' },
global: { global: {
// The shared peer-browse cache lives in the Pinia resources store.
plugins: [createPinia()],
stubs: { stubs: {
Teleport: true, Teleport: true,
}, },

View File

@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Server from '../Server.vue' import Server from '../Server.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@ -29,6 +30,8 @@ function deferred<T>() {
function mountServer(options: { renderTorServices?: boolean } = {}) { function mountServer(options: { renderTorServices?: boolean } = {}) {
return mount(Server, { return mount(Server, {
global: { global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
stubs: { stubs: {
QuickActionsCard: true, QuickActionsCard: true,
TorServicesCard: options.renderTorServices ? false : true, TorServicesCard: options.renderTorServices ? false : true,

View File

@ -98,6 +98,36 @@ describe('appsConfig service filtering', () => {
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false) expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
}) })
it('never offers Launch for an unknown container with a bare exposed port', () => {
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
// has a runtime lan-address — but no manifest-declared or probed UI. It
// must classify as a service and must NOT get a Launch button.
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
expect(canLaunch(selfDeployed)).toBe(false)
})
it('offers Launch for an unknown container once the backend confirms a UI', () => {
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(confirmedUi)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(service)).toBe(false)
})
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
const known = makePkg('jellyfin', 'Jellyfin', 'media')
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(known)).toBe(true)
})
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => { it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
const pkg = makePkg('fedimint', 'Fedimint', 'money') const pkg = makePkg('fedimint', 'Fedimint', 'money')
pkg.state = PackageState.Starting pkg.state = PackageState.Starting

View File

@ -255,9 +255,17 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean { export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true if (isWebOnlyApp(pkg.manifest.id)) return true
// Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address'] const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id) const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl // A bare runtime address is only a launch signal for curated apps: the
// backend now sets interfaces.main.ui strictly for confirmed web UIs
// (manifest declaration or HTTP probe), so an unknown container with an
// exposed non-UI port must not become launchable just for having one.
const hasUI = pkg.manifest.interfaces?.main?.ui
|| hasKnownLaunchUrl
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) { if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
} }

View File

@ -142,12 +142,15 @@ async function saveSettings() {
lora_region: form.value.region, lora_region: form.value.region,
device_kind: form.value.deviceKind, device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago', channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}), // Always sent: an empty string CLEARS the custom mesh name (backend
// maps "" -> None -> fall back to the server name). The old omit-when-
// empty made clearing impossible once a name was ever set.
advert_name: form.value.name.trim(),
broadcast_identity: form.value.broadcastIdentity, broadcast_identity: form.value.broadcastIdentity,
...(rfParams ? { lora_radio_params: rfParams } : {}), ...(rfParams ? { lora_radio_params: rfParams } : {}),
}) })
saveDone.value = true saveDone.value = true
setTimeout(() => { saveDone.value = false }, 3000) setTimeout(() => { saveDone.value = false }, 5000)
} catch (e) { } catch (e) {
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings' saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
} finally { } finally {
@ -286,7 +289,7 @@ async function saveSettings() {
> >
{{ saving ? 'Saving…' : 'Save Settings' }} {{ saving ? 'Saving…' : 'Save Settings' }}
</button> </button>
<span v-if="saveDone" class="text-xs text-green-400">Saved applies on next radio session</span> <span v-if="saveDone" class="text-xs text-green-400">Saved applying to the radio now</span>
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span> <span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
</div> </div>
</div> </div>

View File

@ -28,6 +28,7 @@
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; } .mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; } .mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); } .mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; } .mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; } .mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
@ -83,6 +84,19 @@
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; } .mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; } .mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; } .mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
.mesh-refresh-spinner {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 4px;
border-radius: 9999px;
border: 2px solid rgba(251, 146, 60, 0.7);
border-top-color: transparent;
animation: mesh-refresh-spin 0.8s linear infinite;
vertical-align: -1px;
}
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; } .mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; } .mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; } .mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
@ -361,13 +375,35 @@
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; } .mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
.mesh-typed-content-image-wrap { position: relative; display: inline-block; } .mesh-typed-content-image-wrap { position: relative; display: inline-block; }
.mesh-typed-content-download-btn { .mesh-typed-content-download-btn {
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem; position: absolute; bottom: 8px; right: 8px;
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15); width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem; border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
display: flex; align-items: center; justify-content: center; cursor: pointer; display: flex; align-items: center; justify-content: center; cursor: pointer;
backdrop-filter: blur(6px); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
transition: background 0.15s ease, transform 0.15s ease;
} }
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; } .mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
replaced collapsed to its text width inside the narrow mobile bubble and
looked squashed this is a full-width glass pill in the house style. */
.mesh-typed-content-fetch-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
transition: background 0.15s ease, border-color 0.15s ease;
}
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
}
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; } .mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; } .mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); } .mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
@ -546,3 +582,64 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); } .mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; } .mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
.mesh-transport-cancel:hover { color: #fff; } .mesh-transport-cancel:hover { color: #fff; }
/* Transport pills at the bottom of the image quality modal pick LoRa vs
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
/* Hop visualization modal + clickable transport pill */
.mesh-chat-transport-clickable { cursor: pointer; }
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
/* Animated route reveal: endpoints and link fade/slide in sequence, then a
pulse travels the link continuously to show the direction of travel. */
.mesh-hopviz-chain > * { opacity: 0; animation: mesh-hopviz-appear 0.4s ease forwards; }
.mesh-hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
.mesh-hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
.mesh-hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
@keyframes mesh-hopviz-appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.mesh-hopviz-link { position: relative; overflow: hidden; }
.mesh-hopviz-link::before {
content: '';
position: absolute;
top: -2px;
left: -18%;
width: 18%;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(251,146,60,0.95), transparent);
animation: mesh-hopviz-travel 1.6s linear infinite;
animation-delay: 1s;
}
@keyframes mesh-hopviz-travel { from { left: -18%; } to { left: 100%; } }
.mesh-hopviz-dot { animation: mesh-hopviz-blink 1.6s ease-in-out infinite; }
.mesh-hopviz-dot:nth-child(2) { animation-delay: 0.25s; }
.mesh-hopviz-dot:nth-child(3) { animation-delay: 0.5s; }
.mesh-hopviz-dot:nth-child(4) { animation-delay: 0.75s; }
.mesh-hopviz-dot:nth-child(5) { animation-delay: 1s; }
.mesh-hopviz-dot:nth-child(6) { animation-delay: 1.25s; }
@keyframes mesh-hopviz-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.mesh-hopviz-chain > * { animation: none; opacity: 1; }
.mesh-hopviz-link::before { animation: none; display: none; }
.mesh-hopviz-dot { animation: none; }
}
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
.mesh-hopviz-icon { font-size: 1.8rem; }
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
/* Per-message "more" button opens the route/hops modal (same target as the
transport pill, but always visible and discoverable). */
.mesh-chat-more-btn { background: none; border: none; color: rgba(255,255,255,0.45); font-size: 0.9rem; line-height: 1; padding: 0 4px; cursor: pointer; border-radius: 6px; }
.mesh-chat-more-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
/* Reaction dropdown inside the message action menu */
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }

View File

@ -113,7 +113,7 @@
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div> <div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0"> <div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Installing' : 'Activate' }}</button> <button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Starting' : 'Start' }}</button>
</div> </div>
</div> </div>
</template> </template>
@ -121,6 +121,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { safeClipboardWrite } from '@/views/web5/utils' import { safeClipboardWrite } from '@/views/web5/utils'
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue' import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
@ -136,7 +137,14 @@ interface FipsStatus {
anchor_connected?: boolean anchor_connected?: boolean
} }
const status = ref<FipsStatus>({ // Shares `server.fips-summary` with the Local Network card's FIPS row, so
// both paint from the same cache instantly on revisit and never disagree.
const statusRes = useCachedResource<FipsStatus>({
key: 'server.fips-summary',
ttlMs: 15_000,
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
})
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
installed: false, installed: false,
version: null, version: null,
service_state: 'unknown', service_state: 'unknown',
@ -199,22 +207,15 @@ function flash(msg: string, isError = false) {
setTimeout(() => { statusMessage.value = '' }, 6000) setTimeout(() => { statusMessage.value = '' }, 6000)
} }
async function loadStatus() {
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
} catch (e) {
if (import.meta.env.DEV) console.warn('fips.status failed', e)
}
}
async function installAndActivate() { async function installAndActivate() {
installing.value = true installing.value = true
try { try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' }) const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
flash('FIPS installed and activated') statusRes.optimistic(() => next) // confirmed server state, not a guess
flash('FIPS started')
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
flash(`Install failed: ${msg}`, true) flash(`Start failed: ${msg}`, true)
} finally { } finally {
installing.value = false installing.value = false
} }
@ -235,7 +236,7 @@ async function reconnectAnchor() {
}>({ method: 'fips.reconnect', timeout: 60_000 }) }>({ method: 'fips.reconnect', timeout: 60_000 })
// Update the card with the post-reconnect status returned by the // Update the card with the post-reconnect status returned by the
// backend avoids an extra status fetch race. // backend avoids an extra status fetch race.
status.value = { ...status.value, ...res.after } statusRes.optimistic((cur) => ({ ...(cur ?? status.value), ...res.after }))
if (res.recovered) { if (res.recovered) {
flash('Anchor reconnected.') flash('Anchor reconnected.')
} else if (res.likely_cause === 'connected') { } else if (res.likely_cause === 'connected') {
@ -258,8 +259,10 @@ async function reconnectAnchor() {
// stuck showing whatever anchor state existed at mount time forever. // stuck showing whatever anchor state existed at mount time forever.
let statusInterval: ReturnType<typeof setInterval> | null = null let statusInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
loadStatus() statusInterval = setInterval(() => {
statusInterval = setInterval(loadStatus, 15000) if (document.hidden) return
void statusRes.refresh()
}, 15000)
}) })
onUnmounted(() => { onUnmounted(() => {
if (statusInterval) clearInterval(statusInterval) if (statusInterval) clearInterval(statusInterval)

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