Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

mesh tests: 114/114 pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

FIPS unit suite: 33/33 pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #74

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:49:12 -04:00
archipelagoandClaude Fable 5 9eadec6936 chore: sign v1.7.104-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:27:55 -04:00
archipelago 6b0a84e710 chore: release v1.7.104-alpha 2026-07-19 02:17:45 -04:00
archipelagoandClaude Fable 5 fd361fb35e test(update): serialize the apply_update regression tests
Both tests take the global single-flight UPDATE_OP_LOCK; run concurrently
by the test harness, one saw the other's lock and failed with 'another
update operation is already running' instead of its expected refusal.
A shared test mutex makes them mutually exclusive deterministically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:01:58 -04:00
archipelagoandClaude Fable 5 8bb61a51e2 style: cargo fmt update.rs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:32:57 -04:00
archipelagoandClaude Fable 5 17d225190a docs: v1.7.104-alpha changelog + What's New sync
Demo images / Build & push demo images (push) Successful in 2m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:31:12 -04:00
archipelagoandClaude Fable 5 d08c0d29c7 fix(ui): ElectrumX sync screen shows the app's icon instead of a generic glyph
Demo images / Build & push demo images (push) Has been cancelled
The pre-UI sync overlay used a hardcoded orange box-glyph SVG; it now renders
the same resolved app icon the loading screen uses (with the shared image-error
fallback), so the wait page reads as ElectrumX at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:29:46 -04:00
archipelagoandClaude Fable 5 a93bd70c5a fix(electrumx): probe bitcoin-knots/bitcoin-core for the daemon host instead of hardcoding knots
The manifest baked bitcoin-knots:8332 into DAEMON_URL. Nodes whose
backend runs under the bitcoin-core name (and any future variant
without a knots DNS alias) left electrumx permanently disconnected —
'connection problem' forever and a block index stuck at 0. The
startup script now picks the first backend name that resolves on
archy-net and falls back to bitcoin-knots.

Catalog regenerated (catalog manifests override disk ones fleet-wide);
this regen also embeds the recently-merged barkd/Ark manifest for the
first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:55:06 -04:00
archipelagoandClaude Fable 5 25162ee846 feat(update): auto-rollback OTA binaries that crash-loop before they can self-verify
.198 crash-looped 236x on a truncated OTA binary with a good backup
sitting in update-backup/ — verify_pending_update() can't help when
the new binary never runs. New scripts/ota-crash-guard.sh runs as
root from ExecStartPre: while the post-OTA pending-verify marker
exists it counts start attempts, and after 5 failures restores the
backup binary (atomic rename), replaces the marker with an
update-rolled-back.json tombstone, and lets the service come back
on the previous version.

Wiring: fresh ISOs get the ExecStartPre line in the unit file;
existing nodes get a drop-in installed by apply_update's runtime
component step on their next OTA. '+-' prefix so a missing or
failing guard can never block the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:42:54 -04:00
archipelagoandClaude Fable 5 837cfdfd1f fix(update): never apply unverified staging — close the OTA truncated-binary race
Root cause of .198 bricking on the 1.7.103 OTA: two concurrent
update.download RPCs shared one staging file, cancel_download wiped
staging mid-flight, a third download began re-filling it, and
apply_update mv'd the 3-second-old 17MB partial of the 49MB binary
into /usr/local/bin -> SEGV boot loop (236 restarts, no rollback).

- Single-flight UPDATE_OP_LOCK across download/apply; concurrent
  callers get an explicit 'already running' error.
- apply_update now requires the .download-complete marker AND
  re-verifies every staged component (size + SHA-256 + BLAKE3)
  against the manifest before touching the system.
- cancel_download only wipes staging when no operation holds the
  lock; otherwise it just flags the in-flight loop to bail.
- Fixed the 'file already complete' path in
  download_component_resumable, which skipped verification and fell
  through to the bogus 'download failed without a captured error'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:52 -04:00
archipelagoandClaude Fable 5 573b469191 chore: sign v1.7.103-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m53s
Also folds the Cargo.lock version bump that create-release.sh missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:48:56 -04:00
archipelago 401f92a24f chore: release v1.7.103-alpha 2026-07-18 07:30:25 -04:00
archipelagoandClaude Fable 5 dc0adbef70 docs: v1.7.103-alpha changelog + What's New sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:01:45 -04:00
archipelagoandClaude Fable 5 537c9fa70b fix(demo): suppress the PWA update prompt + reload on the public demo
The SW-update modal is noise on the demo (nothing to update to) and both
accept-paths end in a reload that replays the intro from scratch. With
skipWaiting/clientsClaim off, ignoring the waiting worker is safe — the
new build activates on the visitor's next session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:01:45 -04:00
DorianandClaude Fable 5 b6468ebf3c fix(android): let Vue re-render before auto-login submits the password
Dispatch the Enter keydown two frames after the input event so the login
button is enabled when it arrives. Keeps auto-login working against nodes
running older web builds where controller-nav's Enter-in-input pattern
would otherwise click Replay Intro (see the matching web-ui fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:54:12 +01:00
DorianandClaude Fable 5 70587210fb fix(ui): stop controller-nav Enter from clicking Replay Intro on auth inputs
The companion's auto-login fills the password and dispatches Enter in the
same tick, before Vue re-enables the disabled submit button. Controller-nav's
'Enter in input clicks the next enabled button' pattern then hit the Replay
Intro button — clearing neode_intro_seen and hard-navigating to /, so every
app connect replayed the intro cinematic in a loop. The auth inputs all have
their own Enter handlers, so they opt out via data-controller-no-submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:54:12 +01:00
109 changed files with 8676 additions and 2839 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 18
versionName = "0.4.14"
versionCode = 19
versionName = "0.4.15"
vectorDrawables {
useSupportLibrary = true
@@ -1,10 +1,13 @@
package com.archipelago.app.ui.screens
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -12,6 +15,9 @@ import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -146,6 +152,20 @@ fun WebViewScreen(
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
// Web-page camera access (wallet QR scanner). The WebView's default
// WebChromeClient silently denies getUserMedia, so grant video capture —
// asking for the app-level CAMERA permission first when needed.
val webViewContext = LocalContext.current
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
pendingWebPermission?.let { req ->
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
}
pendingWebPermission = null
}
// A node app that refused iframing, opened in a local WebView overlay.
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
// while this is shown, so closing it returns instantly with no reload.
@@ -371,6 +391,25 @@ fun WebViewScreen(
loadProgress = newProgress
}
// Wallet QR scanner: grant the page camera access.
// Only video capture is granted — anything else the
// page asks for is denied as before.
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
request.deny()
return
}
val hasCamera = ContextCompat.checkSelfPermission(
webViewContext, Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
if (hasCamera) {
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
pendingWebPermission = request
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// window.open() — e.g. the kiosk's "Open in new tab"
// for an app that can't be iframed. Capture the target
// URL via a throwaway WebView and route it ourselves.
@@ -516,6 +555,18 @@ private fun InAppBrowser(
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// Same camera bridge as the main WebView — node apps opened in the overlay
// (e.g. anything with a QR scanner) get getUserMedia too.
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
pendingWebPermission?.let { req ->
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
}
pendingWebPermission = null
}
// Seed the loading-screen icon immediately from a best-effort favicon
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
// loader shows an icon right away instead of staying blank until the page
@@ -565,6 +616,22 @@ private fun InAppBrowser(
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) favicon = icon
}
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
request.deny()
return
}
val hasCamera = ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
if (hasCamera) {
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
pendingWebPermission = request
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
webViewClient = object : WebViewClient() {
@@ -741,7 +808,16 @@ private fun buildAutoLoginScript(password: String): String {
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, pw);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
// Let Vue re-render before submitting: a synchronous Enter arrives
// while the login button is still disabled, and the web UI's
// controller-nav "Enter in input clicks the next enabled button"
// pattern then hits Replay Intro instead — restarting the intro
// cinematic on every connect (two frames = value flush + render).
requestAnimationFrame(function () {
requestAnimationFrame(function () {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
});
}, 1500);
})();
""".trimIndent()
+71
View File
@@ -1,5 +1,76 @@
# Changelog
## v1.7.111-alpha (2026-07-22)
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.)
- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.
- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.
- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.
- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.
- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.
- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.
- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.
- On the phone home screen, the wallet card moved up to sit right under My Apps.
- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably.
## v1.7.110-alpha (2026-07-21)
- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.
- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.
- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.
- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.
- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).
- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.
- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."
- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.
## v1.7.109-alpha (2026-07-21)
- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.
- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.
- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.
## v1.7.108-alpha (2026-07-20)
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.
- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
## v1.7.106-alpha (2026-07-20)
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.
- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.
- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.
- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.
## v1.7.105-alpha (2026-07-20)
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
- The companion phone app no longer suggests installing the companion app from inside itself.
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
## v1.7.104-alpha (2026-07-19)
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
## v1.7.103-alpha (2026-07-18)
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
## v1.7.102-alpha (2026-07-17)
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
+13 -2
View File
@@ -351,12 +351,12 @@
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1.0",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -370,6 +370,17 @@
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
+5 -2
View File
@@ -35,6 +35,9 @@ app:
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -43,9 +46,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+5 -2
View File
@@ -35,6 +35,9 @@ app:
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -43,9 +46,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+8 -1
View File
@@ -10,9 +10,16 @@ app:
network: archy-net
data_uid: "1000:1000"
entrypoint: ["sh", "-lc"]
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
# on which version the node runs (multi-version switch) — probe which name
# resolves on archy-net instead of hardcoding knots, which left electrumx
# permanently disconnected (block index 0) on core nodes.
custom_args:
- >-
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
for h in bitcoin-knots bitcoin-core; do
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
done;
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
exec electrumx_server
secret_env:
- key: BITCOIN_RPC_PASS
+1 -1
View File
@@ -1,5 +1,5 @@
# Home Assistant - uses official image
FROM homeassistant/home-assistant:2024.1
FROM homeassistant/home-assistant:2026.7.3
# Default configuration is in the image
# No additional setup needed
+2 -2
View File
@@ -1,11 +1,11 @@
app:
id: homeassistant
name: Home Assistant
version: 2024.1.0
version: 2026.7.3
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present
network: pasta
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) so the orchestrator adopts a matching running container instead of
# recreating it.
container_name: pine-openwakeword
container:
image: docker.io/rhasspy/wyoming-openwakeword:2.1.0
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-openwakeword]
# The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock
# "ok nabu" model; /custom is where a trained custom model (yo_archy)
# drops in later — the engine picks up new .tflite files on restart.
custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"]
dependencies:
- storage: 512Mi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10400 (the Wyoming integration endpoint).
- host: 10400
container: 10400
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-openwakeword
target: /custom
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10400
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-openwakeword
repo: https://github.com/rhasspy/wyoming-openwakeword
license: MIT
tags:
- home
- voice
- wake-word
- wyoming
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.2.2"
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded voices under /data
# preserved).
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
# picks the voice (mirrors the pine ha-stack.yml compose command).
custom_args: ["--voice", "en_GB-alba-medium"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the voice into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10200 (the Wyoming integration endpoint).
- host: 10200
container: 10200
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-piper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10200
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the voice
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-piper
repo: https://github.com/rhasspy/wyoming-piper
license: MIT
tags:
- home
- voice
- text-to-speech
- wyoming
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-whisper
name: Pine Whisper (STT)
version: "3.4.1"
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded models under /data
# preserved).
container_name: pine-whisper
container:
image: docker.io/rhasspy/wyoming-whisper:3.4.1
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-whisper]
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
# pick the model + language (mirrors the pine ha-stack.yml compose command).
custom_args: ["--model", "base-int8", "--language", "en"]
dependencies:
- storage: 2Gi
resources:
memory_limit: 2Gi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the whisper model into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10300 (the Wyoming integration endpoint).
- host: 10300
container: 10300
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-whisper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10300
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the model
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-faster-whisper
repo: https://github.com/rhasspy/wyoming-faster-whisper
license: MIT
tags:
- home
- voice
- speech-to-text
- wyoming
+395
View File
@@ -0,0 +1,395 @@
app:
id: pine
name: Pine
version: "1.3.0"
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home
# The user-facing launcher (app_id + container both "pine", matching the
# runtime references + the live container so the orchestrator adopts it). A
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
# stack members.
container_name: pine
container:
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
# (https or localhost), so the launcher terminates TLS with a self-signed
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
# secure-context fix (#15).
generated_certs:
- crt: /var/lib/archipelago/pine/tls.crt
key: /var/lib/archipelago/pine/tls.key
dependencies:
- app_id: pine-whisper
- app_id: pine-piper
- app_id: pine-openwakeword
- storage: 128Mi
resources:
memory_limit: 64Mi
security:
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
# workers) binds :443 inside the container — needs the worker-drop caps +
# NET_BIND_SERVICE for the privileged port.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
# 10380 (http) is the Open target — the UI launches apps as
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
# listener on 10381, so the new tab lands on a secure context where
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
- host: 10380
container: 80
protocol: tcp
- host: 10381
container: 443
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/tls.crt
target: /etc/nginx/tls.crt
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/tls.key
target: /etc/nginx/tls.key
options: [ro]
- type: bind
source: /var/lib/archipelago/pine/index.html
target: /usr/share/nginx/html/index.html
options: [ro]
environment: []
files:
- path: /var/lib/archipelago/pine/nginx.conf
overwrite: true
content: |
server {
listen 80;
server_name _;
return 301 https://$host:10381$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
root /usr/share/nginx/html;
index index.html;
# Live node facts for the status card — proxied to the node's
# public status tier so the (https) page can fetch same-origin.
location = /node-status {
proxy_pass http://host.containers.internal:80/api/pine/status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
}
location / { try_files $uri $uri/ /index.html; }
}
- path: /var/lib/archipelago/pine/index.html
overwrite: true
content: |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pine — connect your speaker</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
background: #0f1512; color: #e7efe9; }
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
h1 { font-size: 26px; margin: 0; }
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
.card { background: #16201b; border: 1px solid #24332b;
border-radius: 14px; padding: 20px; margin: 20px 0; }
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
.status .row b { min-width: 84px; color: #9fd3b6; }
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
font-size: 13px; color: #cfe9d9; }
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
outline: none; }
input:focus { border-color: #4fae82; }
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
border-radius: 10px; cursor: pointer; transition: filter .15s; }
button:hover:not(:disabled) { filter: brightness(1.08); }
button:disabled { opacity: .45; cursor: default; }
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
font-family: ui-monospace, monospace; white-space: pre-wrap;
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
.ha b { color: #9fbfad; }
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
color: #e8c878; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
<rect x="236" y="396" width="40" height="72" rx="6"/>
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g></svg>
<div><h1>Pine</h1>
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
</header>
<div class="card status">
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
</div>
<div class="card status">
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
</div>
<div class="card insecure" id="insecure">
This page isnt running over HTTPS, so the browser blocks Bluetooth.
Open it via its <b>https://…:10380</b> address (accept the self-signed
certificate) and the button below will work.
</div>
<div class="card">
<label for="ssid">WiFi network (SSID)</label>
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
<input id="pass" type="password" autocomplete="off">
<button id="go">Connect Pine to WiFi</button>
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
</div>
<p class="ha">After WiFi joins, one manual step remains — pair the
speaker in Home Assistant: <b>Settings → Devices &amp; services →
Add Wyoming Protocol</b>, host = the speakers IP, port
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
are wired up automatically when Pine installs. Wake word:
<b>“Hey Jarvis.”</b> Ask node things like <i>“whats the block
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
synced?”</i> or <i>“whats my lightning balance?”</i> — and when a
Claude API key is set on the node, anything else gets answered by
Claude. New mesh messages are announced on the speaker too.</p>
<p class="ha">Troubleshooting: if it hears you (LED reacts) but answers
are silent, unplug and replug the speaker — an interrupted answer can
wedge its audio output until it reboots.</p>
</div>
<script>
"use strict";
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
const SVC = "00467768-6228-2272-4663-277478268000";
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
4:"not authorized — press the button on the device",5:"bad hostname",
255:"unknown device error"};
const logEl = document.getElementById("log");
const log = (msg, cls) => { const l = document.createElement("div");
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
logEl.scrollTop = logEl.scrollHeight; };
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
.map(b => b.toString(16).padStart(2, "0")).join(" ");
const btn = document.getElementById("go");
if (!navigator.bluetooth) {
document.getElementById("insecure").style.display = "block";
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
}
btn.addEventListener("click", async () => {
const ssid = document.getElementById("ssid").value.trim();
const pass = document.getElementById("pass").value;
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
btn.disabled = true; logEl.textContent = "";
let device;
try {
log("Opening Bluetooth device chooser…");
device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
optionalServices: [SVC],
});
log(`Selected: ${device.name || "(unnamed)"}`);
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
log("Connecting…");
const gatt = await device.gatt.connect();
const svc = await gatt.getPrimaryService(SVC);
const stateChar = await svc.getCharacteristic(CHAR_STATE);
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
let done, fail;
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
let authorize; const authorized = new Promise(res => { authorize = res; });
let state = -1;
const onState = (s, via = "") => {
if (s === state) return; state = s;
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
if (s >= 2) authorize();
if (s === 4) done(null);
};
stateChar.addEventListener("characteristicvaluechanged",
e => onState(e.target.value.getUint8(0)));
errorChar.addEventListener("characteristicvaluechanged", e => {
const c = e.target.value.getUint8(0);
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
resultChar.addEventListener("characteristicvaluechanged", e => {
const v = e.target.value; log(`RPC result: ${hex(v)}`);
if (v.byteLength > 2 && v.getUint8(1) > 0) {
const n = v.getUint8(2); const b = new Uint8Array(n);
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
done(new TextDecoder().decode(b)); } });
await stateChar.startNotifications();
await errorChar.startNotifications();
await resultChar.startNotifications();
onState((await stateChar.readValue()).getUint8(0));
const poller = setInterval(async () => {
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
const ec = (await errorChar.readValue()).getUint8(0);
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
let nextUrl;
try {
if (state === 1) {
log("Authorization required — press the centre button on the speaker now.", "warn");
await Promise.race([authorized, outcome,
new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out waiting for the button press")), 60000))]);
log("Authorized.", "ok");
}
const enc = new TextEncoder();
const sb = enc.encode(ssid), pb = enc.encode(pass);
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
const pkt = new Uint8Array([1, data.length, ...data, 0]);
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
log(`Sending WiFi credentials for “${ssid}”…`);
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
else await rpcChar.writeValue(pkt);
log("Credentials delivered — speaker acknowledged.", "ok");
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
const timeout = new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
nextUrl = await Promise.race([outcome, timeout]);
} finally { clearInterval(poller); }
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
try { gatt.disconnect(); } catch {}
} catch (err) {
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
if (err.name === "NotFoundError")
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
+ "Hold the dot button 15s to reset.", "warn");
if (err.name === "NetworkError")
log("BLE link dropped — move closer, and make sure the Mac isn't already "
+ "paired to the speaker (it can hold the link exclusively).", "warn");
try { device?.gatt?.disconnect(); } catch {}
} finally { btn.disabled = false; }
});
// Live node status card — public tier of /api/pine/status via the
// same-origin /node-status proxy. Best-effort: failures just show
// "unavailable" and retry on the next tick.
const nsNode = document.getElementById("ns-node");
const nsBtc = document.getElementById("ns-btc");
const nsPeers = document.getElementById("ns-peers");
async function refreshNodeStatus() {
try {
const r = await fetch("/node-status", { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
const s = await r.json();
const up = Math.floor((s.uptime_seconds || 0) / 3600);
nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`;
if (s.bitcoin && s.bitcoin.height != null) {
const pct = s.bitcoin.sync_percent;
nsBtc.textContent = `block ${s.bitcoin.height}` +
(pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : "");
} else {
nsBtc.textContent = "not running";
}
const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?";
const meshPeers = s.mesh ? s.mesh.peers : 0;
nsPeers.textContent = `${btcPeers} bitcoin` +
(s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : "");
} catch {
nsNode.textContent = "node status unavailable";
nsBtc.textContent = "—";
nsPeers.textContent = "—";
}
}
refreshNodeStatus();
setInterval(refreshNodeStatus, 30000);
</script>
</body>
</html>
health_check:
type: tcp
endpoint: localhost:443
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
interfaces:
main:
name: Pine
description: Connect your speaker to WiFi and check the voice assistant
type: ui
port: 10380
protocol: http
path: /
metadata:
author: Archipelago
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming
repo: https://github.com/rhasspy/wyoming
license: MIT
category: home
launch:
open_in_new_tab: true
tags:
- home
- voice
- assistant
- privacy
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.102-alpha"
version = "1.7.111-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.102-alpha"
version = "1.7.111-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+20
View File
@@ -580,6 +580,26 @@ impl ApiHandler {
self.handle_app_catalog_proxy().await
}
// Pine node status — public tier (version/uptime/height/sync/peer
// counts) is unauthenticated like /bitcoin-status; Lightning
// balances + latest mesh message additionally require the bearer
// token the pine/HA seeder minted (or a valid session).
(Method::GET, "/api/pine/status") => {
let bearer = headers
.get(hyper::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
let authorized = self.rpc_handler.pine_status_token_ok(bearer).await
|| self.is_authenticated(&headers).await;
let body = self.rpc_handler.pine_status_json(authorized).await;
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
// LND connect info — nginx validates session cookie (presence check),
// backend is bound to 127.0.0.1 so only nginx can reach it.
// No backend auth check here because the LND UI iframe fetches this
@@ -456,6 +456,8 @@ impl RpcHandler {
"system.factory-reset" => self.handle_system_factory_reset(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
// Opt-in anonymous analytics
"analytics.get-status" => self.handle_analytics_get_status().await,
+8 -1
View File
@@ -28,13 +28,20 @@ impl RpcHandler {
));
}
// Zero-amount invoices need the amount supplied by the payer; LND's
// REST API takes it as an `amt` string alongside the payment request.
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
info!("Paying Lightning invoice");
let (client, macaroon_hex) = self.lnd_client().await?;
let pay_body = serde_json::json!({
let mut pay_body = serde_json::json!({
"payment_request": payment_request,
});
if let Some(amt) = amount_sats {
pay_body["amt"] = serde_json::json!(amt.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
@@ -158,6 +158,29 @@ impl RpcHandler {
anyhow::bail!("Unknown LoRa region: {trimmed}");
}
}
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
// mesh::LoraRadioParams). Validated against the firmware's accepted
// ranges here so a bad value errors at the API instead of being sent
// to the radio and rejected on-device. `null` clears the setting.
if let Some(rp) = params.get("lora_radio_params") {
if rp.is_null() {
config.lora_radio_params = None;
} else {
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
anyhow::ensure!(
(150_000..=2_500_000).contains(&parsed.freq_khz),
"freq_khz out of range (150000..=2500000)"
);
anyhow::ensure!(
(7_000..=500_000).contains(&parsed.bw_hz),
"bw_hz out of range (7000..=500000)"
);
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
config.lora_radio_params = Some(parsed);
}
}
// Firmware pin: probe only the named firmware on the port ("auto"/""
// clears the pin and restores strict-probe auto-detect).
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
@@ -41,6 +41,10 @@ impl RpcHandler {
// live radio-reported `region`): the configured LoRa region and
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
obj.insert("lora_region".into(), config.lora_region.clone().into());
obj.insert(
"lora_radio_params".into(),
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
);
obj.insert(
"device_kind".into(),
config
+8 -1
View File
@@ -27,6 +27,7 @@ mod nostr;
mod openwrt;
mod package;
mod peers;
mod pine_status;
mod response;
mod router;
mod security;
@@ -438,7 +439,13 @@ impl RpcHandler {
}
}
Err(e) => {
error!("RPC error on {}: {}", rpc_req.method, e);
// `{:#}` renders the whole anyhow context chain. Logging only the
// outermost context threw away the actual cause: a peer-files
// failure logged just "Failed to connect to peer", with the real
// error (Tor SOCKS failure, FIPS resolve, timeout) discarded — so
// the logs couldn't distinguish a dead peer from a slow circuit.
// The client-facing message below stays `{}` so internals aren't leaked.
error!("RPC error on {}: {:#}", rpc_req.method, e);
let user_message = sanitize_error_message(&e.to_string());
RpcResponse {
result: None,
@@ -496,6 +496,13 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"netbird-dashboard".into(),
"netbird-server".into(),
],
// Pine voice-assistant stack: launcher + the two Wyoming engines.
"pine" => vec![
"pine".into(),
"pine-whisper".into(),
"pine-piper".into(),
"pine-openwakeword".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -595,6 +595,10 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
| "nbxplorer"
| "fedimint"
| "fedimint-gateway"
| "pine"
| "pine-whisper"
| "pine-piper"
| "pine-openwakeword"
)
}
@@ -626,6 +630,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -294,6 +294,9 @@ impl RpcHandler {
if package_id == "netbird" {
return self.install_netbird_stack().await;
}
if package_id == "pine" {
return self.install_pine_stack().await;
}
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
@@ -1560,6 +1563,15 @@ autopilot.active=false\n",
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
async fn run_post_install_hooks(&self, package_id: &str) {
if matches!(package_id, "homeassistant" | "home-assistant")
&& super::pine_ha::pine_engines_installed().await
{
// Pine was installed first: wire HA to its Wyoming engines now,
// then restart so the seeded storage is what HA loads.
if super::pine_ha::seed_home_assistant_pine_defaults().await {
super::pine_ha::restart_home_assistant_if_running().await;
}
}
if package_id == "filebrowser" {
// Generate a random password (32 bytes, hex-encoded)
let mut buf = [0u8; 32];
@@ -3,6 +3,7 @@ mod config;
mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
mod progress;
mod runtime;
mod set_config;
@@ -0,0 +1,803 @@
//! Home Assistant <-> Pine voice-stack provisioning.
//!
//! The Pine app ships the Wyoming engines (pine-whisper :10300, pine-piper
//! :10200, pine-openwakeword :10400) as plain containers. Home Assistant only
//! uses them once a `wyoming` config entry exists for each, and once an
//! Assist pipeline points at the resulting entities. Out of the box that
//! meant clicking through HA's integration UI and building a pipeline by
//! hand — so seed all of it directly into HA's `.storage` when both apps are
//! present:
//!
//! - a `wyoming` config entry per engine,
//! - an Assist pipeline wired to whisper + piper,
//! - "ask Archy" voice intents backed by REST sensors on the node's
//! `/api/pine/status` endpoint (block height, peers, sync, balances),
//! - a Claude conversation agent (HA's `anthropic` integration) using the
//! node's shared `secrets/claude-api-key`, set as the pipeline's brain with
//! `prefer_local_intents: true` so node questions stay local/free,
//! - an automation announcing new mesh messages on any Assist satellite.
//!
//! Everything here is best-effort (warn, never fail an install): HA migrates
//! the minimal store shapes we write to its current schema on boot, and skips
//! entries that already exist, so re-running is idempotent.
use serde_json::{json, Value};
use tracing::{info, warn};
/// Timestamp in the exact format HA writes to `.storage`
/// (`2026-07-22T08:29:35.123456+00:00` — micros, `+00:00` offset), safe for
/// Python's `datetime.fromisoformat`.
fn ha_now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, false)
}
const HA_CONFIG_DIR: &str = "/var/lib/archipelago/home-assistant";
const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage";
const NODE_SECRETS_DIR: &str = "/var/lib/archipelago/secrets";
/// The Wyoming engines the Pine stack publishes on the host.
/// HA runs under pasta, so the host is reachable as host.containers.internal.
const PINE_ENGINES: [(&str, &str, u16); 3] = [
("faster-whisper", "host.containers.internal", 10300),
("piper", "host.containers.internal", 10200),
("openwakeword", "host.containers.internal", 10400),
];
/// Entity id the Anthropic conversation subentry produces (device name
/// "Claude conversation" -> slug). Deterministic on a fresh registry.
const CLAUDE_CONVERSATION_ENTITY: &str = "conversation.claude_conversation";
/// Seed Home Assistant with the Pine voice defaults. Called after the Pine
/// stack installs and after Home Assistant installs (each side no-ops when
/// the other is missing). HA picks the new entries up on its next start; the
/// caller restarts the container when it is already running.
///
/// Returns true when anything was written (caller should restart HA).
pub(super) async fn seed_home_assistant_pine_defaults() -> bool {
let storage = std::path::Path::new(HA_STORAGE_DIR);
if let Err(e) = tokio::fs::create_dir_all(storage).await {
warn!("pine/HA seed: cannot create {}: {}", HA_STORAGE_DIR, e);
return false;
}
let entries_changed = seed_wyoming_config_entries(storage).await;
let claude = seed_claude_conversation(storage).await;
let pipeline_changed = seed_assist_pipeline(
storage,
claude.available.then_some(CLAUDE_CONVERSATION_ENTITY),
)
.await;
let intents_changed = seed_voice_intents().await;
let automation_changed = seed_mesh_announce_automation().await;
entries_changed || claude.changed || pipeline_changed || intents_changed || automation_changed
}
// ---------------------------------------------------------------------------
// Voice intents + REST sensors (configuration.yaml + custom_sentences)
// ---------------------------------------------------------------------------
/// Markers bounding the seeded block in configuration.yaml. The begin marker
/// doubles as the legacy marker (early seeds had no end marker and always sat
/// at EOF, so legacy upgrade = replace from begin marker to end of file).
const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---";
const VOICE_END_MARKER: &str = "# --- archipelago pine voice (end) ---";
/// Read the pine status token (minting it on first use, 0600). The same
/// token gates the sensitive tier of `/api/pine/status` (see
/// `api::rpc::pine_status`), so seeding it into HA's REST sensor config is
/// what lets HA — and only HA — read balances and mesh text.
async fn ensure_status_token() -> Option<String> {
let dir = std::path::Path::new(NODE_SECRETS_DIR);
let path = dir.join(crate::api::rpc::pine_status::PINE_STATUS_TOKEN_FILE);
if let Ok(existing) = tokio::fs::read_to_string(&path).await {
let existing = existing.trim().to_string();
if !existing.is_empty() {
return Some(existing);
}
}
if let Err(e) = tokio::fs::create_dir_all(dir).await {
warn!("pine/HA seed: cannot create {}: {}", NODE_SECRETS_DIR, e);
return None;
}
let raw: [u8; 32] = rand::random();
let token = hex::encode(raw);
if let Err(e) = tokio::fs::write(&path, &token).await {
warn!("pine/HA seed: writing status token failed: {e}");
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
info!("pine/HA seed: minted pine status token");
Some(token)
}
/// The seeded configuration.yaml block: REST sensors polling the node's
/// status endpoint (through nginx on :80) + intent_script answers.
fn voice_config_block(token: &str) -> String {
format!(
r#"{VOICE_MARKER}
rest:
- resource: http://host.containers.internal/api/pine/status
headers:
Authorization: "Bearer {token}"
scan_interval: 30
sensor:
- name: "Archy Block Height"
unique_id: archy_block_height
value_template: "{{{{ value_json.bitcoin.height }}}}"
- name: "Archy Bitcoin Sync"
unique_id: archy_bitcoin_sync
unit_of_measurement: "%"
value_template: "{{{{ value_json.bitcoin.sync_percent }}}}"
availability: "{{{{ value_json.bitcoin.sync_percent is not none }}}}"
- name: "Archy Bitcoin Peers"
unique_id: archy_bitcoin_peers
value_template: "{{{{ value_json.bitcoin.peers }}}}"
- name: "Archy Mesh Peers"
unique_id: archy_mesh_peers
value_template: "{{{{ value_json.mesh.peers }}}}"
- name: "Archy Lightning Balance"
unique_id: archy_lightning_balance
unit_of_measurement: "sats"
value_template: "{{{{ (value_json.lightning or {{}}).get('channel_balance_sats') }}}}"
availability: "{{{{ (value_json.lightning or {{}}).get('channel_balance_sats') is not none }}}}"
- name: "Archy Onchain Balance"
unique_id: archy_onchain_balance
unit_of_measurement: "sats"
value_template: "{{{{ (value_json.lightning or {{}}).get('balance_sats') }}}}"
availability: "{{{{ (value_json.lightning or {{}}).get('balance_sats') is not none }}}}"
- name: "Archy Mesh Message"
unique_id: archy_mesh_message
value_template: "{{{{ (value_json.mesh_message or {{}}).get('id') }}}}"
json_attributes_path: "$.mesh_message"
json_attributes:
- from
- text
- timestamp
intent_script:
ArchyBlockHeight:
description: >-
Get the current Bitcoin block height of this node. Use for any question
about the block height, chain tip, or number of blocks.
speech:
text: >-
{{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable', 'None'] %}}
The block height is {{{{ states('sensor.archy_block_height') }}}}.
{{% else %}}
I can't read the block height right now.
{{% endif %}}
ArchyPeers:
description: >-
Get how many peers this node is connected to (bitcoin peers and mesh
radio peers). Use for any question about peer or connection counts.
speech:
text: >-
{{% set btc = states('sensor.archy_bitcoin_peers') %}}
{{% set mesh = states('sensor.archy_mesh_peers') %}}
{{% if btc not in ['unknown', 'unavailable', 'None'] %}}
The node has {{{{ btc }}}} bitcoin peers{{% if mesh not in ['unknown', 'unavailable', 'None'] and mesh | int(0) > 0 %}} and {{{{ mesh }}}} mesh peers{{% endif %}}.
{{% else %}}
I can't read the peer count right now.
{{% endif %}}
ArchySyncStatus:
description: >-
Get this node's Bitcoin sync status / progress percentage. Use for any
question about whether the node or bitcoin is synced, syncing, or up to
date.
speech:
text: >-
{{% set pct = states('sensor.archy_bitcoin_sync') %}}
{{% if pct in ['unknown', 'unavailable', 'None'] %}}
I can't read the sync status right now.
{{% elif pct | float(0) >= 99.99 %}}
The node is fully synced at block {{{{ states('sensor.archy_block_height') }}}}.
{{% else %}}
Bitcoin is {{{{ pct }}}} percent synced.
{{% endif %}}
ArchyLightningBalance:
description: >-
Get the Lightning wallet balance of this node in sats. Use for any
question about the lightning balance, wallet balance, or how many sats
are available.
speech:
text: >-
{{% set ln = states('sensor.archy_lightning_balance') %}}
{{% if ln not in ['unknown', 'unavailable', 'None'] %}}
Your Lightning balance is {{{{ ln }}}} sats.
{{% else %}}
I can't read the Lightning balance right now. Is Lightning set up on this node?
{{% endif %}}
{VOICE_END_MARKER}
"#
)
}
const VOICE_SENTENCES: &str = r#"language: "en"
intents:
ArchyBlockHeight:
data:
- sentences:
- "what's the [current] block height"
- "what is the [current] block height"
- "[current] block height"
- "how many blocks [are there]"
ArchyPeers:
data:
- sentences:
- "how many peers [do I have]"
- "how many peers (is|are) [the node] connected to"
- "[node] peer count"
ArchySyncStatus:
data:
- sentences:
- "is (the node|bitcoin) [fully] synced"
- "[bitcoin] sync (status|progress|percent|percentage)"
- "how synced is (the node|bitcoin)"
ArchyLightningBalance:
data:
- sentences:
- "what's my lightning balance"
- "what is my lightning balance"
- "lightning balance"
- "how many sats (do I have|are in my wallet)"
"#;
/// Seed "ask Archy" node-info voice intents: custom sentences + a bounded
/// intent_script/rest block in configuration.yaml. Upgrades earlier seeded
/// blocks in place (including the pre-endpoint legacy block that ended at
/// EOF); skips cleanly when the user defines intent_script/rest themselves
/// outside our markers.
async fn seed_voice_intents() -> bool {
let config_dir = std::path::Path::new(HA_CONFIG_DIR);
let config_yaml = config_dir.join("configuration.yaml");
let Some(token) = ensure_status_token().await else {
return false;
};
let desired_block = voice_config_block(&token);
// Sentences file (its own dir, no key-collision risk) — keep in sync.
let sentences_dir = config_dir.join("custom_sentences/en");
if let Err(e) = tokio::fs::create_dir_all(&sentences_dir).await {
warn!(
"pine/HA seed: cannot create {}: {}",
sentences_dir.display(),
e
);
return false;
}
let sentences_path = sentences_dir.join("archy.yaml");
let sentences_changed = tokio::fs::read_to_string(&sentences_path)
.await
.map(|cur| cur != VOICE_SENTENCES)
.unwrap_or(true);
if sentences_changed {
if let Err(e) = tokio::fs::write(&sentences_path, VOICE_SENTENCES).await {
warn!("pine/HA seed: writing custom sentences failed: {e}");
return false;
}
}
let existing = tokio::fs::read_to_string(&config_yaml)
.await
.unwrap_or_default();
let merged = if let Some(begin) = existing.find(VOICE_MARKER) {
if let Some(end) = existing.find(VOICE_END_MARKER) {
// Bounded block — replace when stale.
let after = end + VOICE_END_MARKER.len();
// Consume the trailing newline of the old block, if present.
let after = after + existing[after..].starts_with('\n') as usize;
let current = &existing[begin..after];
if current == desired_block {
return sentences_changed;
}
format!(
"{}{}{}",
&existing[..begin],
desired_block,
&existing[after..]
)
} else {
// Legacy block (no end marker) — it was always appended at EOF,
// possibly hand-edited on the node (e.g. the interim bitcoind
// socat sensor). Replace marker..EOF wholesale.
format!("{}{}", &existing[..begin], desired_block)
}
} else {
if existing.contains("intent_script:") || existing.contains("\nrest:") {
warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents");
return sentences_changed;
}
format!("{existing}\n{desired_block}")
};
let tmp = config_yaml.with_extension("yaml.tmp-seed");
if let Err(e) = tokio::fs::write(&tmp, &merged).await {
warn!("pine/HA seed: writing configuration.yaml failed: {e}");
return false;
}
if let Err(e) = tokio::fs::rename(&tmp, &config_yaml).await {
warn!("pine/HA seed: replacing configuration.yaml failed: {e}");
return false;
}
info!("pine/HA seed: voice intents + node-status sensors seeded");
true
}
// ---------------------------------------------------------------------------
// Mesh-message announcements (automations.yaml)
// ---------------------------------------------------------------------------
const MESH_ANNOUNCE_AUTOMATION_ID: &str = "archy_mesh_announce";
/// Announce new mesh messages on every Assist satellite. Only seeded into an
/// empty/missing automations.yaml — merging into user-authored YAML risks
/// mangling it, and anyone with existing automations can paste this one from
/// the docs. Trigger conditions skip HA-restart transitions (unknown -> id).
const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce
alias: Announce mesh messages on Pine
description: Speak new mesh messages on the Pine speaker (seeded by Archipelago)
triggers:
- trigger: state
entity_id: sensor.archy_mesh_message
conditions:
- condition: template
value_template: >-
{{ trigger.from_state is not none
and trigger.from_state.state not in ['unknown', 'unavailable', 'None']
and trigger.to_state.state not in ['unknown', 'unavailable', 'None']
and trigger.from_state.state != trigger.to_state.state }}
actions:
- variables:
satellites: "{{ states.assist_satellite | map(attribute='entity_id') | list }}"
- condition: template
value_template: "{{ satellites | count > 0 }}"
- action: assist_satellite.announce
target:
entity_id: "{{ satellites }}"
data:
message: >-
New mesh message from {{ state_attr('sensor.archy_mesh_message', 'from') or 'unknown' }}:
{{ state_attr('sensor.archy_mesh_message', 'text') or '' }}
mode: queued
max: 5
"#;
async fn seed_mesh_announce_automation() -> bool {
let path = std::path::Path::new(HA_CONFIG_DIR).join("automations.yaml");
let existing = tokio::fs::read_to_string(&path).await.unwrap_or_default();
if existing.contains(MESH_ANNOUNCE_AUTOMATION_ID) {
return false;
}
let trimmed = existing.trim();
if !(trimmed.is_empty() || trimmed == "[]") {
warn!("pine/HA seed: automations.yaml has user content — skipping mesh announce seed");
return false;
}
if let Err(e) = tokio::fs::write(&path, MESH_ANNOUNCE_AUTOMATION).await {
warn!("pine/HA seed: writing automations.yaml failed: {e}");
return false;
}
info!("pine/HA seed: mesh-message announce automation seeded");
true
}
// ---------------------------------------------------------------------------
// Claude conversation agent (anthropic config entry)
// ---------------------------------------------------------------------------
struct ClaudeSeed {
/// An anthropic entry exists (seeded now or already there) — the
/// pipeline may point at the Claude conversation entity.
available: bool,
/// This call wrote the store.
changed: bool,
}
/// Seed HA's `anthropic` integration from the node's shared Claude API key
/// (`secrets/claude-api-key`, the same key the mesh `!ai` assistant uses).
/// Skips when the key is absent or an anthropic entry already exists.
async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
let none = ClaudeSeed {
available: false,
changed: false,
};
let key = match tokio::fs::read_to_string(
std::path::Path::new(NODE_SECRETS_DIR).join("claude-api-key"),
)
.await
{
Ok(k) if !k.trim().is_empty() => k.trim().to_string(),
_ => {
info!("pine/HA seed: no claude-api-key on node — skipping Claude conversation agent");
return none;
}
};
let path = storage.join("core.config_entries");
let mut store = if tokio::fs::metadata(&path).await.is_ok() {
match read_store(&path).await {
Some(v) => v,
None => return none,
}
} else {
json!({
"version": 1,
"minor_version": 1,
"key": "core.config_entries",
"data": { "entries": [] }
})
};
let Some(entries) = store
.get_mut("data")
.and_then(|d| d.get_mut("entries"))
.and_then(|e| e.as_array_mut())
else {
warn!("pine/HA seed: core.config_entries has unexpected shape, leaving untouched");
return none;
};
if entries
.iter()
.any(|e| e.get("domain").and_then(Value::as_str) == Some("anthropic"))
{
return ClaudeSeed {
available: true,
changed: false,
};
}
let id = |raw: [u8; 16]| hex::encode(raw);
// Shape mirrors what HA 2026.7's anthropic config flow creates (entry
// version 2.4 with conversation + ai_task subentries). Bookkeeping
// fields (created_at/modified_at/discovery_keys) must be written here:
// the store already carries HA's current schema minor_version, so HA
// never migrates appended entries — a missing created_at is a
// KeyError that crash-loops HA at boot.
entries.push(json!({
"entry_id": id(rand::random()),
"version": 2,
"minor_version": 4,
"domain": "anthropic",
"title": "Claude",
"data": { "api_key": key },
"options": {},
"pref_disable_new_entities": false,
"pref_disable_polling": false,
"source": "user",
"unique_id": null,
"disabled_by": null,
"created_at": ha_now(),
"modified_at": ha_now(),
"discovery_keys": {},
"subentries": [
{
"subentry_id": id(rand::random()),
"subentry_type": "conversation",
"title": "Claude conversation",
"unique_id": null,
"data": {
"recommended": true,
"llm_hass_api": ["assist"],
// Steers fuzzy phrasings onto the local Archy* intent
// tools (cheap + exact) instead of free-form answers,
// and keeps replies speaker-length.
"prompt": "You are Archy, the voice of this Archipelago Bitcoin node, speaking through a smart speaker. Answers are spoken aloud: keep them to one or two short sentences, no markdown, no lists. When the user asks about the node — block height, sync status, peers, balances — call the matching Archy tool rather than answering from memory, even if the phrasing is loose. Only answer directly when no tool fits."
}
},
{
"subentry_id": id(rand::random()),
"subentry_type": "ai_task_data",
"title": "Claude AI Task",
"unique_id": null,
"data": { "recommended": true }
}
]
}));
if !write_store(&path, &store).await {
return none;
}
info!("pine/HA seed: added Claude conversation agent (anthropic config entry)");
ClaudeSeed {
available: true,
changed: true,
}
}
// ---------------------------------------------------------------------------
// Wyoming config entries
// ---------------------------------------------------------------------------
/// Ensure `core.config_entries` has a `wyoming` entry per Pine engine.
async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
let path = storage.join("core.config_entries");
let mut store = if tokio::fs::metadata(&path).await.is_ok() {
match read_store(&path).await {
Some(v) => v,
// Present but unreadable/corrupt: never overwrite it with a
// fresh store — that would throw away every integration.
None => return false,
}
} else {
json!({
"version": 1,
"minor_version": 1,
"key": "core.config_entries",
"data": { "entries": [] }
})
};
let Some(entries) = store
.get_mut("data")
.and_then(|d| d.get_mut("entries"))
.and_then(|e| e.as_array_mut())
else {
warn!("pine/HA seed: core.config_entries has unexpected shape, leaving untouched");
return false;
};
let mut added = false;
for (title, host, port) in PINE_ENGINES {
let exists = entries.iter().any(|e| {
e.get("domain").and_then(Value::as_str) == Some("wyoming")
&& e.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
== Some(host)
&& e.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
== Some(port as u64)
});
if exists {
continue;
}
let entry_id: [u8; 16] = rand::random();
entries.push(json!({
"entry_id": hex::encode(entry_id),
"version": 1,
"minor_version": 1,
"domain": "wyoming",
"title": title,
"data": { "host": host, "port": port },
"options": {},
"pref_disable_new_entities": false,
"pref_disable_polling": false,
"source": "user",
"unique_id": null,
"disabled_by": null,
"created_at": ha_now(),
"modified_at": ha_now(),
"discovery_keys": {},
"subentries": []
}));
info!("pine/HA seed: added wyoming config entry {title} ({host}:{port})");
added = true;
}
if added && !write_store(&path, &store).await {
return false;
}
added
}
// ---------------------------------------------------------------------------
// Assist pipeline
// ---------------------------------------------------------------------------
/// Ensure an Assist pipeline uses the Pine engines. Creates the store when
/// missing; when it exists, repairs the pre-2024.6 conversation-agent id
/// (`homeassistant` -> `conversation.home_assistant`) and — when a Claude
/// agent is available — upgrades pipelines still on the default local agent
/// to Claude with `prefer_local_intents: true` (node intents stay local/free,
/// everything else goes to Claude).
async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&str>) -> bool {
let path = storage.join("assist_pipeline.pipelines");
if let Some(mut store) = read_store(&path).await {
let Some(items) = store
.get_mut("data")
.and_then(|d| d.get_mut("items"))
.and_then(|i| i.as_array_mut())
else {
return false;
};
let mut changed = false;
for item in items.iter_mut() {
if item.get("conversation_engine").and_then(Value::as_str) == Some("homeassistant") {
item["conversation_engine"] = json!("conversation.home_assistant");
info!("pine/HA seed: repaired legacy conversation agent id in Assist pipeline");
changed = true;
}
if let Some(engine) = claude_entity {
if item.get("conversation_engine").and_then(Value::as_str)
== Some("conversation.home_assistant")
{
item["conversation_engine"] = json!(engine);
item["prefer_local_intents"] = json!(true);
info!("pine/HA seed: pipeline upgraded to Claude (local intents preferred)");
changed = true;
}
}
}
if changed {
return write_store(&path, &store).await;
}
return false;
}
// ULID-shaped id (26 chars, Crockford base32) — HA only needs uniqueness.
let id: String = {
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
let raw: [u8; 26] = rand::random();
raw.iter()
.map(|b| ALPHABET[(*b % 32) as usize] as char)
.collect()
};
let store = json!({
"version": 1,
"minor_version": 1,
"key": "assist_pipeline.pipelines",
"data": {
"items": [{
"conversation_engine": claude_entity.unwrap_or("conversation.home_assistant"),
"conversation_language": "en",
"id": id,
"language": "en",
"name": "Pine (local)",
"prefer_local_intents": claude_entity.is_some(),
"stt_engine": "stt.faster_whisper",
"stt_language": "en",
"tts_engine": "tts.piper",
"tts_language": "en_GB",
"tts_voice": null,
"wake_word_entity": null,
"wake_word_id": null
}],
"preferred_item": id
}
});
if write_store(&path, &store).await {
info!("pine/HA seed: created default Assist pipeline (whisper + piper)");
return true;
}
false
}
// ---------------------------------------------------------------------------
// Presence probes + HA restart
// ---------------------------------------------------------------------------
/// True when the Pine Wyoming engines are installed on this node (their shared
/// data dir is created by the stack install).
pub(super) async fn pine_engines_installed() -> bool {
tokio::fs::metadata("/var/lib/archipelago/pine")
.await
.is_ok()
}
/// True when Home Assistant has a config dir on this node (installed at some
/// point; .storage may not exist until first boot, which seeding handles).
pub(super) async fn home_assistant_installed() -> bool {
tokio::fs::metadata(HA_CONFIG_DIR).await.is_ok()
}
/// Restart the HA container so it loads the seeded storage. Best-effort: when
/// the container doesn't exist (not yet created) the next start picks the
/// seeds up anyway.
pub(super) async fn restart_home_assistant_if_running() {
let running = tokio::process::Command::new("podman")
.args([
"ps",
"--format",
"{{.Names}}",
"--filter",
"name=homeassistant",
])
.output()
.await
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.any(|l| l.trim() == "homeassistant")
})
.unwrap_or(false);
if !running {
return;
}
match tokio::process::Command::new("podman")
.args(["restart", "homeassistant"])
.output()
.await
{
Ok(o) if o.status.success() => info!("pine/HA seed: restarted homeassistant"),
Ok(o) => warn!(
"pine/HA seed: podman restart homeassistant failed: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => warn!("pine/HA seed: podman restart homeassistant failed: {e}"),
}
}
async fn read_store(path: &std::path::Path) -> Option<Value> {
let raw = tokio::fs::read_to_string(path).await.ok()?;
match serde_json::from_str(&raw) {
Ok(v) => Some(v),
Err(e) => {
warn!(
"pine/HA seed: {} is not valid JSON ({}), leaving untouched",
path.display(),
e
);
None
}
}
}
async fn write_store(path: &std::path::Path, store: &Value) -> bool {
let pretty = match serde_json::to_string_pretty(store) {
Ok(s) => s,
Err(e) => {
warn!("pine/HA seed: serialize {} failed: {}", path.display(), e);
return false;
}
};
// Write via temp + rename so HA never reads a half-written store.
let tmp = path.with_extension("tmp-seed");
if let Err(e) = tokio::fs::write(&tmp, pretty).await {
warn!("pine/HA seed: write {} failed: {}", tmp.display(), e);
return false;
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
warn!("pine/HA seed: rename into {} failed: {}", path.display(), e);
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn voice_block_is_bounded_and_carries_token() {
let block = voice_config_block("deadbeef");
assert!(block.starts_with(VOICE_MARKER));
assert!(block.trim_end().ends_with(VOICE_END_MARKER));
assert!(block.contains("Bearer deadbeef"));
// Every intent the sentences file declares has a script answer.
for intent in [
"ArchyBlockHeight",
"ArchyPeers",
"ArchySyncStatus",
"ArchyLightningBalance",
] {
assert!(block.contains(intent), "missing intent {intent}");
assert!(
VOICE_SENTENCES.contains(intent),
"missing sentences {intent}"
);
}
}
#[test]
fn legacy_block_replacement_drops_old_content() {
// Simulates the pre-endpoint node state: legacy marker + hand-edited
// socat/bitcoind sensor block at EOF.
let existing = format!(
"default_config:\n\n{VOICE_MARKER}\nrest:\n - resource: http://host.containers.internal:18332/\n username: archipelago\n"
);
let begin = existing.find(VOICE_MARKER).unwrap();
let desired = voice_config_block("tok");
let merged = format!("{}{}", &existing[..begin], desired);
assert!(!merged.contains("18332"));
assert!(merged.contains("Bearer tok"));
assert!(merged.starts_with("default_config:"));
}
}
@@ -744,6 +744,15 @@ fn netbird_stack_app_ids() -> &'static [&'static str] {
&["netbird-server", "netbird-dashboard", "netbird"]
}
fn pine_stack_app_ids() -> &'static [&'static str] {
// Dependency/startup order: the two Wyoming engines (STT + TTS) first — they
// own their downloaded model/voice under /data and publish 10300/10200 —
// then the user-facing launcher ("pine", the nginx that serves the setup /
// status page + is the Open target). Mirrors the pine startup_order in
// dependencies.rs + the stack member table in app_ops.rs.
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"]
}
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
// Dependency order: backends + their generated secrets first, then the api
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
@@ -1907,6 +1916,54 @@ impl RpcHandler {
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
)
}
/// Install the Pine voice-assistant stack (Whisper STT + Piper TTS + the
/// setup/status launcher). Manifest-driven only, like netbird: render the
/// 3-member stack from apps/pine-*/manifest.yml via the orchestrator
/// (archy-net + network_aliases, published Wyoming ports 10300/10200 so
/// Home Assistant reaches the engines via host.containers.internal, the
/// launcher's setup page written via `files:`). The manifests use the exact
/// live container names, so on an existing node this ADOPTS the running
/// stack rather than recreating it (downloaded models/voices preserved).
///
/// There is no in-Rust hardcoded fallback: the signed catalog always ships
/// apps/pine-*/manifest.yml. If the orchestrator doesn't know these app_ids
/// and no running stack exists to adopt, install errors rather than
/// silently diverging from the manifest contract.
pub(super) async fn install_pine_stack(&self) -> Result<serde_json::Value> {
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
{
Self::seed_pine_ha_defaults().await;
return Ok(orchestrated);
}
if let Some(adopted) = adopt_stack_if_exists(
"pine",
"pine",
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
)
.await?
{
Self::seed_pine_ha_defaults().await;
return Ok(adopted);
}
anyhow::bail!(
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
)
}
/// After a Pine install, wire Home Assistant to the new engines when HA is
/// on this node (the HA post-install hook covers the reverse order).
async fn seed_pine_ha_defaults() {
if !super::pine_ha::home_assistant_installed().await {
return;
}
if super::pine_ha::seed_home_assistant_pine_defaults().await {
super::pine_ha::restart_home_assistant_if_running().await;
}
}
}
#[cfg(test)]
+159
View File
@@ -0,0 +1,159 @@
//! Node-status JSON for the Pine voice stack (`GET /api/pine/status`).
//!
//! Two tiers in one endpoint:
//! - **Public** (no credentials): version, uptime, bitcoin height / sync /
//! peer count, mesh peer count. Feeds the Pine launcher page's live status
//! card — nothing here a LAN visitor couldn't already infer from the
//! existing unauthenticated `/bitcoin-status`.
//! - **Token** (`Authorization: Bearer <pine-status-token>`): adds Lightning
//! balances and the most recent received mesh text message. Feeds the
//! Home Assistant REST sensors seeded by `package::pine_ha` — the seeder
//! mints the token into `data_dir/secrets/pine-status-token` (0600) and
//! embeds it in HA's configuration, so only HA (and the node owner) can
//! read balances or message text.
//!
//! Replaces the interim per-node socat forwarder + bitcoind-RPC-credentials-
//! in-configuration.yaml stopgap used before this endpoint existed.
use super::RpcHandler;
use crate::mesh::types::MessageDirection;
use serde_json::{json, Value};
/// File under `data_dir/secrets/` holding the bearer token that unlocks the
/// sensitive tier. Written by the pine/HA seeder, read per-request here.
pub(crate) const PINE_STATUS_TOKEN_FILE: &str = "pine-status-token";
/// Constant-time-ish equality — avoids early-exit timing on the token compare.
fn token_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
impl RpcHandler {
/// True when `presented` matches the on-disk pine status token. Missing or
/// empty token file means the sensitive tier is locked (seeder not run).
pub(crate) async fn pine_status_token_ok(&self, presented: &str) -> bool {
let path = self
.config
.data_dir
.join("secrets")
.join(PINE_STATUS_TOKEN_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(tok) => {
let tok = tok.trim();
!tok.is_empty() && token_eq(tok, presented.trim())
}
Err(_) => false,
}
}
/// Assemble the status document. `authorized` selects the token tier.
/// Every sub-source is best-effort: a dead bitcoind/LND/mesh never turns
/// the endpoint into an error — the field just reports what it can.
pub(crate) async fn pine_status_json(&self, authorized: bool) -> Value {
let bs = crate::bitcoin_status::get_bitcoin_status().await;
let bitcoin = {
let info = bs.blockchain_info.as_ref();
let height = info.and_then(|i| i.get("blocks")).and_then(Value::as_u64);
let progress = info
.and_then(|i| i.get("verificationprogress"))
.and_then(Value::as_f64);
let ibd = info
.and_then(|i| i.get("initialblockdownload"))
.and_then(Value::as_bool);
let peers = bs
.network_info
.as_ref()
.and_then(|n| n.get("connections"))
.and_then(Value::as_u64);
json!({
"ok": bs.ok,
"height": height,
"sync_percent": progress.map(|p| (p * 10000.0).round() / 100.0),
"ibd": ibd,
"peers": peers,
})
};
let (mesh, mesh_message) = {
let guard = self.mesh_service.read().await;
match guard.as_ref() {
Some(svc) => {
let status = svc.status().await;
let latest = if authorized {
svc.messages(None)
.await
.iter()
.rev()
.find(|m| {
m.direction == MessageDirection::Received
&& m.message_type == "text"
})
.map(|m| {
json!({
"id": m.id,
"from": m.peer_name.clone()
.unwrap_or_else(|| format!("contact {}", m.peer_contact_id)),
"text": m.plaintext,
"timestamp": m.timestamp,
})
})
} else {
None
};
(
json!({ "enabled": status.enabled, "peers": status.peer_count }),
latest,
)
}
None => (json!({ "enabled": false, "peers": 0 }), None),
}
};
let lightning = if authorized {
match self.handle_lnd_getinfo().await {
Ok(info) => json!({
"balance_sats": info.get("balance_sats"),
"channel_balance_sats": info.get("channel_balance_sats"),
"active_channels": info.get("num_active_channels"),
"synced_to_chain": info.get("synced_to_chain"),
}),
Err(_) => Value::Null,
}
} else {
Value::Null
};
json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": crate::crash_recovery::uptime_seconds(),
"bitcoin": bitcoin,
"mesh": mesh,
"lightning": lightning,
// Always an object: HA's REST sensor reads attributes via
// json_attributes_path "$.mesh_message", and a null there makes
// HA log a "JSON result was not a dictionary" warning every scan.
"mesh_message": mesh_message.unwrap_or_else(|| json!({})),
})
}
}
#[cfg(test)]
mod tests {
use super::token_eq;
#[test]
fn token_eq_matches_only_exact() {
assert!(token_eq("abc123", "abc123"));
assert!(!token_eq("abc123", "abc124"));
assert!(!token_eq("abc123", "abc12"));
assert!(!token_eq("", "x"));
assert!(token_eq("", ""));
}
}
@@ -719,4 +719,94 @@ impl RpcHandler {
_ => anyhow::bail!("Unknown setting: {}", key),
}
}
/// system.kiosk-display.get — Current kiosk display preset + whether this
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
&self,
) -> Result<serde_json::Value> {
let has_kiosk = tokio::fs::metadata("/etc/systemd/system/archipelago-kiosk.service")
.await
.is_ok();
let conf = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
.await
.unwrap_or_default();
let preset = if conf.contains("ARCHIPELAGO_KIOSK_SCALE=1") {
"native"
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920") {
"balanced"
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280") {
"large"
} else {
"auto"
};
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
}
/// system.kiosk-display.set — Write the kiosk display preset and restart
/// the kiosk (only if it is running) so it takes effect immediately. The
/// launcher sources /etc/archipelago/kiosk-display.conf at startup.
pub(in crate::api::rpc) async fn handle_system_kiosk_display_set(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let preset = params
.get("preset")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
let conf = match preset {
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
// 1080p TV -> 1.5, laptop panels -> 1.0.
"auto" => String::new(),
// Biggest UI: every panel targets a 1280-wide layout.
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
// Full-HD layout on any panel that can carry it.
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
// No scaling: native CSS viewport, most content, smallest UI.
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
other => anyhow::bail!("Unknown display preset: {other}"),
};
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
if conf.is_empty() {
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
} else {
// tee via sudo — the backend runs unprivileged and /etc is root's.
let mut child = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/tee", KIOSK_DISPLAY_CONF])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.context("spawn sudo tee for kiosk display conf")?;
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(conf.as_bytes()).await?;
}
let out = child.wait_with_output().await?;
if !out.status.success() {
anyhow::bail!(
"writing {} failed: {}",
KIOSK_DISPLAY_CONF,
String::from_utf8_lossy(&out.stderr).trim()
);
}
}
// try-restart: only bounces a kiosk that is actually running, so this
// never starts a kiosk an operator disabled.
let _ = host_sudo(&[
"/usr/bin/systemctl",
"try-restart",
"archipelago-kiosk.service",
])
.await;
info!(preset, "Kiosk display preset applied");
Ok(serde_json::json!({ "preset": preset, "applied": true }))
}
}
const KIOSK_DISPLAY_CONF: &str = "/etc/archipelago/kiosk-display.conf";
+13 -1
View File
@@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
/// Services for known-but-uninstalled apps are hidden (issue #79).
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let (data, _) = self.state_manager.get_snapshot().await;
let mut apps = AppInstallState {
known: Default::default(),
installed: Default::default(),
};
for (id, pkg) in &data.package_data {
apps.known.insert(id.clone());
if pkg.installed.is_some() {
apps.installed.insert(id.clone());
}
}
let services = list_services(&config_dir, Some(&apps)).await?;
let tor_running = check_tor_running().await;
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
}
+58 -3
View File
@@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
// ─── Service Listing ─────────────────────────────────────────────
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
/// Which packages the node knows about and which are installed — used to
/// hide hidden services for apps that aren't installed. ISO first-boot used
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
/// never installed (issue #79).
pub(super) struct AppInstallState {
pub known: std::collections::HashSet<String>,
pub installed: std::collections::HashSet<String>,
}
/// Package ids a Tor service name may correspond to. Service names predate
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
/// can map to several package ids.
fn service_alias_candidates(name: &str) -> Vec<&str> {
match name {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
}
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx", "electrs", "mempool-electrs"]
}
"btcpay" | "btcpay-server" | "btcpayserver" => {
vec!["btcpay", "btcpay-server", "btcpayserver"]
}
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
other => vec![other],
}
}
impl AppInstallState {
/// A service is listed unless it names a known-but-uninstalled app.
/// The node's own service, the content relay, and custom user-created
/// services (names matching no catalog package) always show.
fn service_visible(&self, name: &str) -> bool {
if name == "archipelago" || name == "relay" {
return true;
}
let candidates = service_alias_candidates(name);
if !candidates.iter().any(|c| self.known.contains(*c)) {
return true; // not an app — custom hidden service
}
candidates.iter().any(|c| self.installed.contains(*c))
}
}
pub(super) async fn list_services(
config_dir: &std::path::Path,
apps: Option<&AppInstallState>,
) -> Result<Vec<TorService>> {
let base = detect_hidden_service_base();
let config = load_services_config(config_dir).await;
let mut services = Vec::new();
let mut seen = std::collections::HashSet::new();
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
for entry in &config.services {
let onion = read_onion_address(&entry.name).await;
seen.insert(entry.name.clone());
if !visible(&entry.name) {
continue;
}
let onion = read_onion_address(&entry.name).await;
services.push(TorService {
name: entry.name.clone(),
local_port: entry.local_port,
@@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
if seen.contains(&service_name) {
continue;
}
seen.insert(service_name.clone());
if !visible(&service_name) {
continue;
}
let onion = read_onion_address(&service_name).await;
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
let is_proto = is_protocol_service(&service_name);
services.push(TorService {
name: service_name,
+46 -18
View File
@@ -437,6 +437,23 @@ impl RpcHandler {
Ok(serde_json::json!({ "added": true, "npub": npub }))
}
/// The host address a WireGuard peer should dial — prefer the configured
/// host IP, then public-IP lookup, then first local address.
async fn current_wg_endpoint_host(&self) -> String {
if self.config.host_ip != "127.0.0.1" {
return self.config.host_ip.clone();
}
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
}
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
pub(super) async fn handle_vpn_create_peer(
&self,
@@ -501,22 +518,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
};
// Detect host IP — prefer config, then nvpn, then system detection
let host_ip = if self.config.host_ip != "127.0.0.1" {
self.config.host_ip.clone()
} else {
// Fallback: get public IP via external service
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
};
let endpoint = format!("{}:51820", host_ip);
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
// Allocate a peer IP (simple: hash the peer name)
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
@@ -667,15 +669,41 @@ impl RpcHandler {
let content = tokio::fs::read_to_string(&peer_file)
.await
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
let peer: serde_json::Value = serde_json::from_str(&content)?;
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"No config stored for peer '{}' — recreate the device to get a new QR code",
name
)
})?;
// The stored Endpoint is the node's address at creation time; after
// the node moves networks it points at a dead IP and the QR produces
// a tunnel that can never connect. Refresh it to the current address.
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
let config: String = stored
.lines()
.map(|l| {
if l.trim_start().starts_with("Endpoint") {
format!("Endpoint = {}", endpoint)
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
if config != stored {
if let Some(obj) = peer.as_object_mut() {
obj.insert("config".to_string(), config.clone().into());
}
if let Ok(json) = serde_json::to_string_pretty(&peer) {
if tokio::fs::write(&peer_file, json).await.is_ok() {
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
}
}
}
let qr = qrcode::QrCode::new(config.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr
+9 -1
View File
@@ -50,6 +50,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
// The legacy umbrella id maps to the split stack (the orchestrator's
// umbrella alias handles this too; listing it here keeps the RPC
// layer's fan-out explicit).
@@ -75,7 +76,14 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
/// they drive archy-mempool-web), otherwise the app itself.
fn owning_package(app_id: &str) -> &str {
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
const STACKS: &[&str] = &[
"immich",
"indeedhub",
"btcpay-server",
"netbird",
"mempool",
"pine",
];
for stack in STACKS {
if stack_member_app_ids(stack).contains(&app_id) {
return stack;
+94
View File
@@ -80,6 +80,13 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block lacking the Pine node-status proxy.
/// `/api/pine/status` serves the Pine launcher page's live status card and
/// the seeded Home Assistant REST sensors (the sensitive tier is gated by a
/// bearer token at the backend, so nginx just forwards). Kept in sync with
/// the canonical block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PINE_STATUS_BLOCK: &str = "\n # Pine node status — live node facts for the Pine launcher page and the\n # seeded Home Assistant sensors. Sensitive fields are token-gated at the\n # backend; nginx only forwards.\n location /api/pine/status {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Authorization $http_authorization;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 10s;\n proxy_read_timeout 15s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
@@ -137,6 +144,13 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
match run_polkit_networkmanager_repair().await {
Ok(true) => info!(
"Installed NetworkManager polkit rule for the archipelago user — Wi-Fi setup enabled"
),
Ok(false) => debug!("NetworkManager polkit rule already present"),
Err(e) => warn!("polkit NetworkManager repair failed (non-fatal): {:#}", e),
}
match run_journald_dropin().await {
Ok(true) => info!("Installed journald log-volume policy drop-in"),
Ok(false) => debug!("journald log-volume policy already in place"),
@@ -442,6 +456,68 @@ exit 2
}
}
/// Self-heal Wi-Fi setup on nodes that predate the polkit fix (issue #99).
///
/// Archipelago drives NetworkManager from a system-level systemd service
/// (`User=archipelago`, no logind seat), so the stock NM polkit rule — which
/// only authorizes `subject.local && subject.active` sessions — denies it, and
/// "connect to Wi-Fi" fails with "Insufficient privileges". Fresh ISO installs
/// since 2026-05 ship the rule below, but nodes that reached this build over
/// OTA never got it (OTA replaces the binary + web UI, not host system config).
///
/// Install the scoped rule if it is missing, and best-effort ensure `polkitd`
/// itself is present (without the daemon the rule is inert). Both are wrapped
/// so an offline/locked apt or a missing package can never fail startup — the
/// rule is still written so it takes effect once polkitd arrives (e.g. after an
/// ISO reflash). Idempotent: keyed on the rule's unique `subject.user` marker.
async fn run_polkit_networkmanager_repair() -> Result<bool> {
let script = r#"
set -u
RULE=/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
MARKER='subject.user == "archipelago"'
# Rule already installed nothing to do.
if grep -qF "$MARKER" "$RULE" 2>/dev/null; then
exit 0
fi
# The rule is inert without the polkit daemon. Older nodes (the ones that hit
# issue #99) shipped without it. Try to install it, but never let apt failure
# (offline node, locked dpkg, package unavailable) abort the heal the rule is
# written regardless so it activates whenever polkitd lands.
if [ ! -d /usr/share/polkit-1 ] && ! command -v pkaction >/dev/null 2>&1; then
timeout 240 apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1 \
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1' \
|| true
fi
mkdir -p /etc/polkit-1/rules.d
cat > "$RULE" <<'RULEEOF'
polkit.addRule(function(action, subject) {
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
return polkit.Result.YES;
}
});
RULEEOF
chmod 644 "$RULE"
# Pick up the new rule. polkitd re-reads rules.d on reload; restart as a
# fallback. Non-fatal if the unit name differs or the daemon is absent.
systemctl reload polkit 2>/dev/null \
|| systemctl restart polkit 2>/dev/null \
|| systemctl restart polkit.service 2>/dev/null \
|| true
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("install NetworkManager polkit rule")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => Ok(true),
_ => {
warn!("polkit NetworkManager repair helper exited with {}", status);
Ok(false)
}
}
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
@@ -785,6 +861,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !content.contains("location /bitcoin-status");
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
let missing_pine_status = has_lnd_anchor && !content.contains("location /api/pine/status");
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
let needs_fedimint_css = content.contains("location /app/fedimint/")
@@ -793,6 +870,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !missing_bitcoin_status
&& !missing_lnd_proxy
&& !missing_peer_content
&& !missing_pine_status
&& !has_lnd_dup_cors
&& !needs_fedimint_css
{
@@ -838,6 +916,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
}
}
if missing_pine_status {
// Same anchoring as the LND proxy: prepend to every server block that
// proxies to the backend.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if patched.contains(anchor) {
let replacement = format!("{}{}", NGINX_PINE_STATUS_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
} else {
warn!("nginx conf missing anchor — skipping /api/pine/status patch");
}
}
if missing_peer_content {
// Same anchoring as the LND proxy: prepend the block to every server
// block so /api/peer-content/* reaches the backend instead of the SPA.
@@ -57,6 +57,9 @@ impl DockerPackageScanner {
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"pine-whisper",
"pine-piper",
"pine-openwakeword",
"buildx_buildkit_default",
];
@@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
/// write-probes are `podman exec`s into EVERY running container; running them
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
/// tick forever — a permanent conmon "Failed to create container" storm on
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
/// steady-state one: sweep each container on the first pass after it appears,
/// then at most once per hour.
fn ownership_sweep_due(name: &str) -> bool {
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
static LAST: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
> = std::sync::OnceLock::new();
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let Ok(mut map) = map.lock() else {
return true;
};
let now = std::time::Instant::now();
match map.get(name) {
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
_ => {
map.insert(name.to_string(), now);
true
}
}
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container.
///
@@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator {
if crate::app_ops::lifecycle_op_in_flight(&c.name) {
continue;
}
// Throttled: first pass after the container appears, then
// hourly — not on every 30s tick (see ownership_sweep_due).
if !ownership_sweep_due(&c.name) {
continue;
}
if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman")
+24 -4
View File
@@ -284,7 +284,7 @@ impl QuadletUnit {
let _ = writeln!(s, "PodmanArgs=--cpus={cpus}");
}
if let Some(h) = &self.health {
let _ = writeln!(s, "HealthCmd={}", h.cmd);
let _ = writeln!(s, "HealthCmd={}", h.cmd.replace('%', "%%"));
let _ = writeln!(s, "HealthInterval={}", h.interval);
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
let _ = writeln!(s, "HealthRetries={}", h.retries);
@@ -298,7 +298,7 @@ impl QuadletUnit {
// images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper
// into `bitcoind sh -lc ...` and crash-looped). Emitting
// Entrypoint= makes the unit independent of the image's entrypoint.
let _ = writeln!(s, "Entrypoint={first}");
let _ = writeln!(s, "Entrypoint={}", first.replace('%', "%%"));
let mut parts: Vec<String> = rest.to_vec();
parts.extend(self.command.iter().cloned());
if !parts.is_empty() {
@@ -335,11 +335,16 @@ impl QuadletUnit {
/// the minimum quoting needed so quadlet's parser sees one element per
/// item: anything containing whitespace, quotes, or shell metacharacters
/// gets wrapped in double quotes with embedded `"` and `\` escaped.
///
/// `%` is escaped to `%%`: quadlet copies Exec= content into the generated
/// service's ExecStart, where systemd expands `%` specifiers at load time —
/// a bare `%s` in a manifest script silently became `/bin/bash` (the user's
/// shell) and corrupted bitcoind's generated rpc.conf.
fn shell_join(parts: &[String]) -> String {
parts
.iter()
.map(|p| {
let p = p.replace(['\r', '\n'], " ");
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
let escaped = p
.replace('\\', "\\\\")
@@ -355,7 +360,8 @@ fn shell_join(parts: &[String]) -> String {
}
fn quote_environment(env: &str) -> String {
let env = env.replace(['\r', '\n'], " ");
// `%` → `%%` for the same systemd-specifier reason as shell_join.
let env = env.replace(['\r', '\n'], " ").replace('%', "%%");
if env.is_empty()
|| env
.chars()
@@ -1122,6 +1128,20 @@ mod tests {
);
}
#[test]
fn percent_is_escaped_for_systemd_specifiers() {
// Regression: a bare `%s` in an Exec= line is a systemd specifier
// (user's shell = /bin/bash) once quadlet copies it into the
// generated ExecStart — bitcoind's rpc.conf came out as
// `rpcuser=/bin/bash` and every RPC consumer got 401s.
assert_eq!(
shell_join(&["printf 'rpcuser=%s' \"$U\"".to_string()]),
"\"printf 'rpcuser=%%s' \\\"$$U\\\"\""
);
assert_eq!(shell_join(&["--fmt=%h:%p".to_string()]), "--fmt=%%h:%%p");
assert_eq!(quote_environment("FMT=%m"), "FMT=%%m");
}
#[test]
fn restart_policy_emits_correct_systemd_string() {
assert_eq!(RestartPolicy::Always.as_systemd(), "always");
+75
View File
@@ -372,6 +372,28 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
/// Recover containers that were running before a crash.
/// Attempts to start each container, logging success/failure.
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
// Snapshot entries can outlive their containers (removed while we were
// down, or podman storage partially reset by an unclean poweroff).
// `podman start` on those fails permanently, and recovery runs BEFORE the
// server binds its port and notifies systemd ready — burning retries on
// them pushed recovery past TimeoutStartSec and brick-looped the node
// (killed mid-recovery → next boot sees a crash again, forever).
let containers: Vec<&RunningContainerRecord> = match existing_container_names().await {
Some(existing) => {
let (present, missing): (Vec<_>, Vec<_>) =
containers.iter().partition(|r| existing.contains(&r.name));
if !missing.is_empty() {
warn!(
"Skipping {} snapshot container(s) that no longer exist: {:?}",
missing.len(),
missing.iter().map(|r| r.name.as_str()).collect::<Vec<_>>()
);
}
present
}
None => containers.iter().collect(),
};
let mut report = RecoveryReport {
total: containers.len(),
recovered: 0,
@@ -386,6 +408,15 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
record.name, record.image
);
// Recovery counts against systemd's start timeout; a heavy node
// legitimately needs several minutes for dozens of containers. Push
// the deadline out ahead of each container so systemd only kills us
// if we stop making progress (360s covers one full attempt chain).
let _ = sd_notify::notify(
false,
&[sd_notify::NotifyState::ExtendTimeoutUsec(360_000_000)],
);
// Rate-limit container starts to avoid overwhelming podman on low-resource systems
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
@@ -427,6 +458,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
attempt + 1,
stderr.trim()
);
// The container is gone (raced past the pre-filter, or the
// filter query failed) — retrying can never succeed.
if stderr.contains("no such container") {
break;
}
}
Err(e) => {
warn!(
@@ -448,6 +484,26 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
report
}
/// All container names podman knows about (running or not). `None` if the
/// query fails — callers fail open and attempt every snapshot entry.
async fn existing_container_names() -> Option<std::collections::HashSet<String>> {
let output = podman_output(
&["ps", "-a", "--format", "{{.Names}}"],
Duration::from_secs(30),
)
.await
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.map(|s| s.to_string())
.collect(),
)
}
#[derive(Debug)]
pub struct RecoveryReport {
pub total: usize,
@@ -710,6 +766,10 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
| "netbird-server"
| "netbird-dashboard"
| "netbird"
| "pine-whisper"
| "pine-piper"
| "pine-openwakeword"
| "pine"
)
}
@@ -771,6 +831,21 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
containers: &["netbird-server", "netbird-dashboard", "netbird"],
anchor: "netbird-server",
},
StackRecoverySpec {
name: "pine",
network: "archy-net",
aliases: &[
("pine-whisper", "pine-whisper"),
("pine-piper", "pine-piper"),
("pine-openwakeword", "pine-openwakeword"),
("pine", "pine"),
],
containers: &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
// The launcher only depends on the two engines; whisper is the
// heaviest/first member, so treat it as the stack anchor (its
// presence means the stack was really installed, not orphan debris).
anchor: "pine-whisper",
},
]
}
+63 -15
View File
@@ -49,9 +49,7 @@ pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
/// The upstream public anchor as a ready-to-apply `SeedAnchor`.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
@@ -61,6 +59,38 @@ pub fn default_public_anchor() -> SeedAnchor {
}
}
// Archipelago-operated anchor on vps2 (the OTA/registry host, 146.59.87.168).
// Every node already reaches this host for updates, so it is reachable from
// networks that the upstream anchor is not — which is most of them (the
// upstream anchor answers on one IPv4 that many home/office networks can't
// reach, and its DNS resolves IPv6-first while the daemon is IPv4-only).
// TCP because that traverses NAT/firewalls best; 8444 because 8443 on that
// host is already taken by a container.
pub const ARCHY_ANCHOR_NPUB: &str =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak";
pub const ARCHY_ANCHOR_ADDR: &str = "146.59.87.168:8444";
pub const ARCHY_ANCHOR_TRANSPORT: &str = "tcp";
/// The Archipelago-operated anchor as a ready-to-apply `SeedAnchor`.
pub fn archy_anchor() -> SeedAnchor {
SeedAnchor {
npub: ARCHY_ANCHOR_NPUB.to_string(),
address: ARCHY_ANCHOR_ADDR.to_string(),
transport: ARCHY_ANCHOR_TRANSPORT.to_string(),
label: "Archipelago anchor (vps2)".to_string(),
}
}
/// The default anchor set carried implicitly by `load()` on nodes that have
/// never edited their anchor list, so every node dials them without operator
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
/// node: `fipsctl connect` is attempted for each, and whichever the node's
/// network can reach wins. The Archipelago-operated anchor is listed first
/// because it is reachable from the widest set of networks.
pub fn default_public_anchors() -> Vec<SeedAnchor> {
vec![archy_anchor(), default_public_anchor()]
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
@@ -94,7 +124,7 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(vec![default_public_anchor()]);
return Ok(default_public_anchors());
}
let bytes = tokio::fs::read(&path)
.await
@@ -268,28 +298,46 @@ mod tests {
}
#[tokio::test]
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
async fn load_missing_seeds_default_public_anchors() {
// A node that has never edited its anchor list should still get the
// full default anchor set so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
assert_eq!(got, default_public_anchors());
// The Archipelago-operated anchor must come first (widest reachability)
// and the upstream anchor must remain present as a fallback.
assert_eq!(got[0], archy_anchor());
assert!(got.contains(&default_public_anchor()));
// Every default must be a TCP form (traverses NAT/firewalls), never the
// dead udp:8668 the upstream anchor never answers on.
assert!(got.iter().all(|a| a.transport == "tcp"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
async fn removing_one_default_persists_and_keeps_the_other() {
// Editing the anchor list (here removing one default) makes the file
// authoritative: the removed anchor must not be silently re-seeded on
// next load, and the remaining default must stay.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
assert!(!list.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
assert!(list.contains(&default_public_anchor()));
let got = load(dir.path()).await.unwrap();
assert_eq!(got, list, "edited list is authoritative; no re-seed");
}
#[tokio::test]
async fn removing_all_defaults_persists_as_empty() {
// Removing every default leaves an empty authoritative list that must
// not be re-seeded on next load.
let dir = tempfile::tempdir().unwrap();
remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
assert!(got.is_empty(), "defaults must stay removed once edited");
}
#[tokio::test]
+191 -38
View File
@@ -10,6 +10,7 @@
//! whitelists `install` into `/etc/fips/`.
use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use tokio::process::Command;
@@ -17,47 +18,145 @@ use super::{
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
};
/// Write the FIPS daemon config based on the local npub and default
/// transports. Overwrites any existing file — callers are expected to
/// Header prepended to the generated YAML. serde doesn't emit comments, so
/// this is concatenated onto the serialised body.
const CONFIG_HEADER: &str = "# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n";
/// Typed mirror of the subset of upstream `fips.yaml` that archipelago owns.
///
/// This was previously built by `format!`-ing a string literal. Upstream's
/// config structs are `#[serde(deny_unknown_fields)]`, so a key we get wrong
/// doesn't degrade gracefully — the daemon refuses to start and the node drops
/// off the mesh. Serialising from typed structs lets the compiler and the
/// tests below catch drift, instead of a node discovering it at boot after an
/// upgrade.
///
/// Schema verified field-by-field against jmcorgan/fips **v0.4.1** (2026-07-20).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FipsConfig {
pub node: NodeSection,
pub tun: TunSection,
pub dns: DnsSection,
pub transports: TransportsSection,
/// Static peers. Always empty: archipelago feeds peers dynamically via the
/// seed-anchors apply loop and federation-invite hooks.
pub peers: Vec<PeerEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NodeSection {
pub identity: IdentitySection,
pub discovery: DiscoverySection,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct IdentitySection {
/// With `persistent: true` the daemon reuses the key file at
/// config-dir/fips.key (= `DAEMON_KEY_PATH`) instead of generating an
/// ephemeral identity on every start.
pub persistent: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DiscoverySection {
pub lan: LanDiscoverySection,
}
/// mDNS / DNS-SD discovery on the local link (`node.discovery.lan.*`), added
/// upstream in v0.4.0 and opt-in there (upstream default is `false`).
///
/// We enable it so co-located nodes peer directly instead of depending on the
/// public anchor being reachable — an anchor blackhole on one network segment
/// otherwise islands a node completely.
///
/// Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
/// has no `lan` field *and* no `deny_unknown_fields`, so a v0.3.0 daemon ignores
/// this key harmlessly (verified against the v0.3.0 source). It therefore starts
/// working on its own when a node upgrades, with no second config migration.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LanDiscoverySection {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TunSection {
pub enabled: bool,
pub name: String,
pub mtu: u16,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DnsSection {
pub enabled: bool,
pub bind_addr: String,
}
/// Both UDP and TCP are enabled: the public anchor answers on TCP/8443 only,
/// and networks that block outbound UDP can still bootstrap over TCP.
/// Upstream dropped the `tor:` transport variant — archipelago's own Tor
/// fallback handles that layer.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TransportsSection {
pub udp: TransportBind,
pub tcp: TransportBind,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TransportBind {
/// Upstream takes `bind_addr` ("host:port"), not `enabled` + `port`.
pub bind_addr: String,
}
/// A static peer entry. Never constructed today (see `FipsConfig::peers`), but
/// typed so the shape is checked if static peering is ever needed.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PeerEntry {
pub npub: String,
pub address: String,
pub transport: String,
}
impl Default for FipsConfig {
fn default() -> Self {
Self {
node: NodeSection {
identity: IdentitySection { persistent: true },
discovery: DiscoverySection {
lan: LanDiscoverySection { enabled: true },
},
},
tun: TunSection {
enabled: true,
name: "fips0".to_string(),
mtu: 1280,
},
dns: DnsSection {
enabled: true,
bind_addr: "127.0.0.1".to_string(),
},
transports: TransportsSection {
udp: TransportBind {
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
},
tcp: TransportBind {
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
},
},
peers: Vec::new(),
}
}
}
/// Render the FIPS daemon config. Overwrites any existing file — callers
/// re-run this whenever the key or daemon version changes.
///
/// Schema is intentionally minimal: node identity comes from the key
/// file on disk (the daemon handles it), transports enable UDP + TCP
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
/// Static peer list is empty — archipelago feeds peers dynamically via
/// the seed-anchors apply loop and federation-invite hooks.
/// Node identity comes from the key file on disk; the static peer list stays
/// empty because peers are fed dynamically at runtime.
pub fn render_config_yaml() -> String {
// Schema matches upstream jmcorgan/fips as of 2026-04. With
// `node.identity.persistent: true` the daemon reuses the key file at
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
// rather than `enabled: true / port: N`. Both UDP and TCP are
// enabled by default because the public anchor (fips.v0l.io)
// currently answers on TCP/8443 only, and networks that block UDP
// outbound can still bootstrap via TCP. Upstream fips no longer
// has a `tor:` transport variant — archipelago's own Tor fallback
// handles that layer.
format!(
"# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n\
node:\n \
identity:\n \
persistent: true\n\
tun:\n \
enabled: true\n \
name: fips0\n \
mtu: 1280\n\
dns:\n \
enabled: true\n \
bind_addr: \"127.0.0.1\"\n\
transports:\n \
udp:\n \
bind_addr: \"0.0.0.0:{udp}\"\n \
tcp:\n \
bind_addr: \"0.0.0.0:{tcp}\"\n\
peers: []\n",
udp = DEFAULT_UDP_PORT,
tcp = DEFAULT_TCP_PORT,
)
let body = serde_yaml::to_string(&FipsConfig::default())
.expect("FipsConfig is a plain struct tree and cannot fail to serialise");
format!("{CONFIG_HEADER}{body}")
}
/// Install the local FIPS key + rendered config into `/etc/fips/`.
@@ -205,6 +304,60 @@ mod tests {
assert!(!yaml.contains("tor:"));
}
/// Exact-output snapshot. Upstream's config structs are
/// `deny_unknown_fields`, so an accidental key rename/addition means the
/// daemon won't start. Pinning the full rendering makes any such change
/// fail here — where it's cheap — instead of on a node after an upgrade.
/// If this fails, re-verify against the upstream schema before updating it.
#[test]
fn test_rendered_yaml_exact_snapshot() {
let expected = "\
# Generated by archipelago do not edit by hand.
# Regenerated on every key change and daemon upgrade.
node:
identity:
persistent: true
discovery:
lan:
enabled: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: 127.0.0.1
transports:
udp:
bind_addr: 0.0.0.0:8668
tcp:
bind_addr: 0.0.0.0:8443
peers: []
";
assert_eq!(render_config_yaml(), expected);
}
/// The rendered config must parse as YAML and carry the mDNS opt-in at the
/// exact path upstream reads (`node.discovery.lan.enabled`) — a typo there
/// would silently leave LAN discovery off rather than erroring.
#[test]
fn test_lan_discovery_enabled_at_upstream_path() {
let yaml = render_config_yaml();
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("renders valid YAML");
assert_eq!(
parsed["node"]["discovery"]["lan"]["enabled"],
serde_yaml::Value::Bool(true),
);
}
/// Rendering is deterministic: the startup drift check in server.rs compares
/// the freshly rendered config against what's on disk, so any instability
/// here would cause an endless reinstall+restart loop of the daemon.
#[test]
fn test_render_is_deterministic() {
assert_eq!(render_config_yaml(), render_config_yaml());
}
#[tokio::test]
async fn test_install_refuses_when_key_missing() {
let dir = tempfile::tempdir().unwrap();
+34
View File
@@ -98,6 +98,40 @@ async fn main() -> Result<()> {
return ceremony::run();
}
// Plain CLI flags must never boot the daemon (a stray `--version` used to
// start a second instance next to the systemd one). Handled before any
// tracing/state init so stdout stays clean.
match std::env::args().nth(1).as_deref() {
Some("--version") | Some("-V") => {
println!(
"archipelago {}-{}",
env!("CARGO_PKG_VERSION"),
option_env!("GIT_HASH").unwrap_or("dev")
);
return Ok(());
}
Some("--help") | Some("-h") => {
println!("Archipelago Bitcoin Node OS");
println!();
println!("Usage: archipelago [COMMAND]");
println!();
println!("Running with no arguments starts the node daemon.");
println!();
println!("Commands:");
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
println!();
println!("Options:");
println!(" -V, --version Print version and exit");
println!(" -h, --help Print this help and exit");
return Ok(());
}
Some(other) if other.starts_with('-') => {
eprintln!("archipelago: unknown option '{other}' (see --help)");
std::process::exit(2);
}
_ => {}
}
let startup_start = std::time::Instant::now();
crash_recovery::init_start_time();
@@ -421,6 +421,7 @@ pub fn spawn_mesh_listener(
our_x25519_pubkey_hex: String,
server_name: Option<String>,
lora_region: Option<String>,
lora_radio_params: Option<super::LoraRadioParams>,
channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>,
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
@@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
&our_x25519_pubkey_hex,
server_name.as_deref(),
lora_region.as_deref(),
lora_radio_params,
channel_name.as_deref(),
device_kind,
reticulum_tcp.clone(),
@@ -836,6 +836,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// Same retry-cap idea as the region, for the Meshcore radio-params write.
static RADIO_PARAMS_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// Same retry-cap idea as the region, for the shared-channel write.
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
@@ -851,6 +855,7 @@ pub(super) async fn run_mesh_session(
our_x25519_pubkey_hex: &str,
server_name: Option<&str>,
lora_region: Option<&str>,
lora_radio_params: Option<crate::mesh::LoraRadioParams>,
channel_name: Option<&str>,
device_kind: Option<DeviceType>,
reticulum_tcp: Option<ReticulumTcpConfig>,
@@ -978,6 +983,62 @@ pub(super) async fn run_mesh_session(
);
}
// Provision Meshcore LoRa PHY params (freq/bw/sf/cr) when the operator has
// configured them. Meshcore-only: Meshtastic radios get region+preset via
// ensure_lora_region above, and Reticulum carries its own RNode profile.
// Gated on a persisted marker of the last-applied params rather than the
// device's SELF_INFO readback (its field offsets shift across firmware
// versions), so we send the set-command once per configured value and never
// reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so
// a successful write restarts the session like the region path.
if let (Some(params), MeshRadioDevice::Meshcore(dev)) = (lora_radio_params, &mut device) {
let marker_path = data_dir.join("meshcore-radio-params.json");
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
.await
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
if applied != Some(params) {
let attempts = RADIO_PARAMS_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
if attempts < MAX_REGION_PROVISION_ATTEMPTS {
match dev
.set_radio_params(params.freq_khz, params.bw_hz, params.sf, params.cr)
.await
{
Ok(()) => {
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
if let Ok(json) = serde_json::to_vec(&params) {
if let Err(e) = tokio::fs::write(&marker_path, json).await {
warn!("Failed to persist radio-params marker: {}", e);
}
}
info!(
freq_khz = params.freq_khz,
bw_hz = params.bw_hz,
sf = params.sf,
cr = params.cr,
"Provisioned Meshcore radio params — radio rebooting, \
restarting mesh session"
);
tokio::time::sleep(Duration::from_secs(10)).await;
return Ok(());
}
Err(e) => {
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
warn!("Failed to provision Meshcore radio params: {}", e);
}
}
} else {
warn!(
attempts = MAX_REGION_PROVISION_ATTEMPTS,
"Meshcore radio rejected the configured radio params after \
repeated attempts continuing with the device's own settings."
);
}
} else {
RADIO_PARAMS_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed);
}
}
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
// falling back to the DID fragment if no name is configured.
let advert_name = if let Some(name) = server_name {
+23
View File
@@ -322,6 +322,22 @@ pub(crate) async fn seed_federation_peers_into_mesh(
}
}
/// Operator-configured LoRa PHY parameters for a Meshcore radio, in the
/// firmware's own field units: `freq_khz` = MHz×1000 (869618 → 869.618 MHz),
/// `bw_hz` = kHz×1000 (62500 → 62.5 kHz), `sf` 5..=12, `cr` 5..=8. These are
/// region/deployment-specific (e.g. the Portugal preset 869618/62500/8/8) and
/// MUST match every radio on the local mesh — a mismatched radio hears RF
/// energy but demodulates nothing. None (the default) leaves the device's own
/// settings untouched, so nodes outside the configured deployment are never
/// affected.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoraRadioParams {
pub freq_khz: u32,
pub bw_hz: u32,
pub sf: u8,
pub cr: u8,
}
/// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
@@ -340,6 +356,11 @@ pub struct MeshConfig {
/// unset/None.
#[serde(default)]
pub lora_region: Option<String>,
/// Meshcore LoRa PHY parameters (freq/bw/sf/cr). Provisioned onto the
/// radio on connect when set; None leaves the device untouched. Ignored
/// for Meshtastic (region/preset covers it) and Reticulum.
#[serde(default)]
pub lora_radio_params: Option<LoraRadioParams>,
/// Whether to periodically broadcast our identity.
#[serde(default)]
pub broadcast_identity: bool,
@@ -422,6 +443,7 @@ impl Default for MeshConfig {
device_path: None,
channel_name: Some("archipelago".to_string()),
lora_region: None,
lora_radio_params: None,
broadcast_identity: true,
advert_name: None,
mesh_only_mode: None,
@@ -722,6 +744,7 @@ impl MeshService {
self.our_x25519_pubkey_hex.clone(),
self.server_name.clone(),
self.config.lora_region.clone(),
self.config.lora_radio_params,
self.config.channel_name.clone(),
self.config.device_kind,
self.config.reticulum_tcp.clone(),
+41
View File
@@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
encode_frame(&data)
}
/// CMD_SET_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to
/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq =
/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz);
/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore
/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`,
/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The
/// same fields (same units) come back in the SELF_INFO reply, so a caller can
/// read them to detect drift. Values outside the firmware's accepted ranges are
/// rejected by the device (it replies with an error frame), not clamped here.
pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec<u8> {
let mut data = vec![CMD_SET_RADIO_PARAMS];
data.extend_from_slice(&freq_field.to_le_bytes());
data.extend_from_slice(&bw_field.to_le_bytes());
data.push(sf);
data.push(cr);
encode_frame(&data)
}
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME];
@@ -730,6 +748,29 @@ mod tests {
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_build_set_radio_params_wire_layout() {
// Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8.
// freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500.
let frame = build_set_radio_params(869_618, 62_500, 8, 8);
assert_eq!(frame[0], OUTBOUND_MARKER);
// payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11);
let data = &frame[3..];
assert_eq!(data[0], CMD_SET_RADIO_PARAMS);
assert_eq!(
u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
869_618
);
assert_eq!(
u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
62_500
);
assert_eq!(data[9], 8); // sf
assert_eq!(data[10], 8); // cr
assert_eq!(data.len(), 11);
}
#[test]
fn test_decode_frame_complete() -> Result<()> {
// Simulate an inbound frame: < + len(2) + [RESP_OK]
+23
View File
@@ -164,6 +164,29 @@ impl MeshcoreDevice {
Ok(())
}
/// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field
/// units — see `protocol::build_set_radio_params`). On RESP_OK the
/// firmware persists the params and reboots to apply them, so the caller
/// must treat the session as gone and reconnect.
pub async fn set_radio_params(
&mut self,
freq_khz: u32,
bw_hz: u32,
sf: u8,
cr: u8,
) -> Result<()> {
self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr))
.await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Set radio params failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Broadcast our advertisement to the mesh.
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_raw(&protocol::build_send_self_advert()).await?;
+27 -3
View File
@@ -688,12 +688,27 @@ impl Server {
let fips_peer_registry = fips_peer_registry.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut interval = tokio::time::interval(Duration::from_secs(300));
// Steady cadence, but retry fast right after a daemon restart:
// regenerating fips.yaml (this build does, once, on first boot
// after the OTA) restarts the fips daemon, and for a few seconds
// `/run/fips/control.sock` is gone so every `fipsctl connect`
// fails and the node islands until the next tick. Detect that
// exact failure and retry in 15s instead of 5 min — bounded, so a
// node with no fips daemon falls back to the steady cadence
// rather than busy-looping.
const STEADY: Duration = Duration::from_secs(300);
const FAST: Duration = Duration::from_secs(15);
const MAX_FAST_RETRIES: u32 = 8; // ≤2 min of fast retries/episode
let mut fast_retries: u32 = 0;
loop {
interval.tick().await;
let mut daemon_restarting = false;
match crate::fips::anchors::load(&data_dir).await {
Ok(list) if !list.is_empty() => {
let _ = crate::fips::anchors::apply(&list).await;
let results = crate::fips::anchors::apply(&list).await;
daemon_restarting = !results.is_empty()
&& results
.iter()
.all(|r| !r.ok && r.message.contains("control.sock"));
}
Ok(_) => { /* no seed anchors configured yet */ }
Err(e) => {
@@ -717,6 +732,15 @@ impl Server {
let _ = crate::fips::anchors::apply(&direct).await;
}
}
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
fast_retries += 1;
FAST
} else {
fast_retries = 0;
STEADY
};
tokio::time::sleep(next).await;
}
});
}
+226 -38
View File
@@ -24,6 +24,16 @@ pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
/// confidence than "looks stuck at 0%".
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
/// Serializes the mutating update operations (download, apply, and the
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
/// exactly this race: two concurrent `update.download` RPCs shared one
/// staging file, a cancel wiped staging mid-flight, a third download began
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
/// via `try_lock` so a concurrent caller gets an explicit "already running"
/// error instead of silently interleaving.
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn now_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
@@ -976,6 +986,9 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// verified over the complete file at the end of each component, so a
/// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("another update operation (download or apply) is already running")
})?;
let mut state = load_state(data_dir).await?;
if state.available_update.is_none() {
state = check_for_updates(data_dir).await?;
@@ -1133,7 +1146,6 @@ async fn download_component_resumable(
dest: &Path,
prior_total: u64,
) -> Result<()> {
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
const MAX_ATTEMPTS: u32 = 6;
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
@@ -1145,8 +1157,19 @@ async fn download_component_resumable(
Err(_) => 0,
};
if existing_len >= component.size_bytes {
// File is already complete — break out and go verify.
break;
// File is already complete (a resumed run finished it, or a
// leftover from an earlier attempt) — verify it instead of
// trusting it. The old code `break`d here, which skipped
// verification entirely AND landed on the error return below
// ("download failed without a captured error").
match verify_component_on_disk(component, dest).await {
Ok(()) => return Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(e);
continue;
}
}
}
if attempt > 1 {
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
@@ -1294,44 +1317,86 @@ async fn download_component_resumable(
continue;
}
// Full file — verify hash.
let bytes = tokio::fs::read(dest)
.await
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash == component.sha256 {
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
// match too. SHA-256 stays the mandatory gate during migration;
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
// — treat it like a SHA mismatch and re-download.
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
));
continue;
}
// Full file — verify hashes. On mismatch the file on disk is
// garbage: nuke it and start over from scratch on the next attempt.
match verify_component_on_disk(component, dest).await {
Ok(()) => return Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(e);
}
return Ok(());
}
// SHA mismatch — the file on disk is garbage. Nuke it and
// start over from scratch on the next attempt.
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
}
/// Verify a fully-downloaded component file on disk: SHA-256 is the
/// mandatory gate; when the manifest also pins a BLAKE3 digest it must
/// match too (BLAKE3 is the hash the iroh swarm fetches/verifies by, so
/// a present-but-wrong BLAKE3 means the bytes aren't swarm-consistent —
/// treated exactly like a SHA mismatch). Err = mismatch; the caller
/// decides whether to remove the file and retry.
async fn verify_component_on_disk(component: &ComponentUpdate, dest: &Path) -> Result<()> {
use sha2::{Digest, Sha256};
let bytes = tokio::fs::read(dest)
.await
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash != component.sha256 {
anyhow::bail!(
"SHA256 mismatch for {}: expected {}, got {}",
component.name,
component.sha256,
hash
));
);
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
);
}
}
Ok(())
}
/// Re-verify every manifest component against the bytes actually sitting
/// in staging, immediately before install. The download path verifies as
/// it goes, but staging can change between download and apply — on .198
/// (v1.7.103, 2026-07-18) a concurrent download was re-filling a wiped
/// staging dir when apply ran, and a 17MB partial of the 49MB binary got
/// installed. This apply-time gate is the one that must never be skipped.
async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest) -> Result<()> {
for component in &manifest.components {
let dest = staging_dir.join(&component.name);
let len = tokio::fs::metadata(&dest)
.await
.map(|m| m.len())
.unwrap_or(0);
if len != component.size_bytes {
anyhow::bail!(
"staged component {} is {} bytes but the manifest says {} — \
refusing to apply (incomplete or concurrently-rewritten download)",
component.name,
len,
component.size_bytes
);
}
verify_component_on_disk(component, &dest)
.await
.with_context(|| {
format!(
"staged component {} failed verification — refusing to apply",
component.name
)
})?;
}
Ok(())
}
/// Cancel an in-flight download. Sets the cancellation flag so the
@@ -1343,11 +1408,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
// Only wipe staging when no download/apply holds the op lock. Wiping
// under a live operation is how .198 ended up applying a re-filling
// staging dir; with the lock held elsewhere we just set the cancel
// flag and let the in-flight loop bail at its next chunk boundary
// (partials are size+hash revalidated on the next resume anyway).
let staging = data_dir.join("update-staging");
let wiped = if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
let wiped = match UPDATE_OP_LOCK.try_lock() {
Ok(_op) => {
if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
}
}
Err(_) => false,
};
// Clear the "downloaded, ready to apply" marker too — a canceled
// download is not a staged update.
@@ -1398,11 +1473,34 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
pub async fn apply_update(data_dir: &Path) -> Result<()> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("another update operation (download or apply) is already running")
})?;
let staging_dir = data_dir.join("update-staging");
if !staging_dir.exists() {
anyhow::bail!("No staged update found. Download first.");
}
// Gate 1: the completion marker is written only after EVERY component
// downloaded and hash-verified. A staging dir without it is a partial
// or in-flight download — exactly what got installed on .198.
if !has_staged_update(data_dir).await {
anyhow::bail!(
"Staged update is incomplete (no completion marker) — download the update again before applying"
);
}
// Gate 2: re-verify the actual staged bytes against the manifest.
let manifest = load_state(data_dir)
.await?
.available_update
.ok_or_else(|| {
anyhow::anyhow!(
"no update manifest in state to verify staged files against — re-download the update"
)
})?;
verify_staged_components(&staging_dir, &manifest).await?;
let backup_dir = data_dir.join("update-backup");
fs::create_dir_all(&backup_dir)
.await
@@ -1690,6 +1788,30 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
.await;
}
// Install the OTA crash-loop guard as a drop-in on existing
// nodes (fresh ISOs carry it in the unit file itself). The
// guard restores the update-backup binary when a freshly
// applied binary SEGVs before it can run its own post-OTA
// verification — the .198 v1.7.103 truncated-binary loop.
// Best-effort: `+-` in the drop-in means a missing script can
// never block the service, and a failed install here must not
// abort the apply.
if Path::new("/opt/archipelago/scripts/ota-crash-guard.sh").exists() {
let dropin_dir = "/etc/systemd/system/archipelago.service.d";
let _ = host_sudo(&["mkdir", "-p", dropin_dir]).await;
let _ = host_sudo(&[
"bash",
"-c",
&format!(
"printf '%s\\n' '[Service]' \
'ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh' \
> {}/ota-crash-guard.conf",
dropin_dir
),
])
.await;
}
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
let _ =
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
@@ -2443,6 +2565,72 @@ mod tests {
assert!(!persisted.update_in_progress);
}
/// apply_update takes the global single-flight UPDATE_OP_LOCK, so tests
/// that call it must not run concurrently — one would see the other's
/// lock and fail with "another update operation is already running".
static APPLY_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn test_apply_refuses_unmarked_staging() {
let _serial = APPLY_TEST_SERIAL.lock().await;
// Regression: .198 v1.7.103 bricking — apply ran against a staging
// dir that a concurrent download was still filling. Without the
// .download-complete marker, apply must refuse before touching
// anything.
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"partial")
.await
.unwrap();
let err = apply_update(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("completion marker"),
"got: {err:#}"
);
}
#[tokio::test]
async fn test_apply_refuses_staged_bytes_that_mismatch_manifest() {
let _serial = APPLY_TEST_SERIAL.lock().await;
// Marker present (a complete download once existed) but the staged
// bytes no longer match the manifest — apply must re-verify and
// refuse rather than install whatever is on disk.
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
.await
.unwrap();
tokio::fs::write(staging.join("archipelago"), b"truncated-garbage")
.await
.unwrap();
let state = UpdateState {
available_update: Some(UpdateManifest {
version: "999.0.0".to_string(),
release_date: "2026-07-18".to_string(),
changelog: vec![],
components: vec![ComponentUpdate {
name: "archipelago".to_string(),
current_version: "1.0.0".to_string(),
new_version: "999.0.0".to_string(),
download_url: "http://example.invalid/archipelago".to_string(),
sha256: "0".repeat(64),
size_bytes: 49_949_048,
blake3: None,
}],
}),
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let err = apply_update(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("refusing to apply"),
"got: {err:#}"
);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();
+238
View File
@@ -0,0 +1,238 @@
# Handoff — 2026-07-20 — peer-files diagnosis, FIPS 0.4.1, mobile transport pill
Written for a fresh session that will **cut the OTA release and build the ISO**.
Everything below is already committed and pushed to `gitea-ai/main`. Last release
was `v1.7.105-alpha` (`e2f83c01`); the next one should be **`v1.7.106-alpha`**.
---
## 1. What this release carries (3 commits on top of v1.7.105-alpha)
| Commit | What | User-visible? |
|---|---|---|
| `9e3ac9ba` | Show the FIPS/Tor transport pill on **mobile** peer files | Yes |
| `3ab7fb52` | Log the full anyhow error chain on RPC failures | No (diagnostics) |
| `5fd0d6c3` | Generate `fips.yaml` from typed structs + enable **mDNS LAN discovery** | Indirectly |
### `9e3ac9ba` — mobile transport pill
`PeerFiles.vue:15` wraps the peer title in `hidden md:block` (the global header
carries the name on mobile), and the transport pill was nested inside it — so it
vanished below 768px. Added a separate `md:hidden` pill next to the peer icon.
Frontend was rebuilt and the class verified present in the emitted bundle.
Caveats worth knowing (pre-existing, not introduced here):
- On this code path the backend only ever emits `fips` or `tor`, so the `mesh`
and `lan` branches in `transportPill` (`PeerFiles.vue:609-627`) are dead.
- For **received** mesh messages, `mesh/mod.rs:1519-1533` falls back to a
hardcoded `"tor"` when the transport is unknown — that pill can genuinely lie.
The peer-files pill does not.
### `3ab7fb52` — full error chain in logs
`api/rpc/mod.rs:441` logged only the outermost anyhow context, so every
peer-files failure read exactly `RPC error on content.browse-peer: Failed to
connect to peer` with the real cause discarded. Now `{:#}`. The client-facing
message still goes through `sanitize_error_message(&e.to_string())` (`{}`), so
no internal detail leaks. **This fix applies to every RPC method, not just
browse-peer.**
### `5fd0d6c3` — typed FIPS config + mDNS
`fips/config.rs` built `/etc/fips/fips.yaml` by `format!`-ing a string literal.
Upstream's config structs are `#[serde(deny_unknown_fields)]`, so a wrong key
does not degrade — **the daemon refuses to start and the node leaves the mesh**.
Now a typed serde struct tree, verified field-by-field against jmcorgan/fips
**v0.4.1**, with 4 tests: exact-output snapshot, determinism, mDNS key path, and
the pre-existing schema test. All pass.
Also enables `node.discovery.lan.enabled` (mDNS/DNS-SD, new upstream in v0.4.0)
so co-located nodes peer directly instead of depending on the public anchor.
> ⚠️ **Expected one-time behaviour on first boot after this lands:** the startup
> drift check at `server.rs:864` compares the freshly rendered config against
> what's on disk. The render differs now, so it reinstalls the config and
> restarts the FIPS daemon **once**. This is the intended self-healing path and
> settles immediately. Do not mistake it for a regression.
Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
has no `lan` field **and** no `deny_unknown_fields`, so v0.3.0 daemons ignore it
harmlessly (verified against the v0.3.0 source). It self-activates on upgrade.
---
## 2. FIPS 0.4.1 — validated, but the fleet is NOT rolled
Fleet was on FIPS **0.3.0 / 0.3.0-dev** (2026-05-11). Upstream is **v0.4.1**
(2026-07-19). Verified before touching anything:
- **Wire-compatible** 0.3.0 → 0.4.0 → 0.4.1. Rolling upgrade, any order, no flag day.
- **Config forward-compatible** — every key we emit exists in 0.4.1.
- **Asset names match** what `fips/update.rs` expects (`fips_<ver>_<arch>.deb` +
`checksums-linux.txt`), so the in-product updater should work.
### Upgraded so far (2 of N)
| Node | Before | After | Result |
|---|---|---|---|
| OptiPlex `.198` / `100.114.134.21` | `0.3.0-dev-1` | **0.4.1** | ✅ anchor connected, `is_parent: true`, tree `depth: 4` |
| thinkpad (this machine) | `0.3.0` | **0.4.1** | ✅ service active, but still islanded (see §4) |
The OptiPlex was still running the **old string-rendered config** and 0.4.1
accepted it — empirical confirmation of the compat analysis, not just desk work.
### Upgrade recipe (nodes cannot reach GitHub — sideload)
```bash
# 1. On a host with GitHub access:
curl -sL -o fips_0.4.1_amd64.deb \
https://github.com/jmcorgan/fips/releases/download/v0.4.1/fips_0.4.1_amd64.deb
curl -sL -o checksums-linux.txt \
https://github.com/jmcorgan/fips/releases/download/v0.4.1/checksums-linux.txt
sha256sum fips_0.4.1_amd64.deb # must match checksums-linux.txt
# expected: 9befcc0990c7e08742b5a88f75d753a1088134b20525156688d559a317334ded
# 2. Sideload:
scp fips_0.4.1_amd64.deb archipelago@<node>:/tmp/
# 3. On the node — the same command update.rs uses:
sudo -n systemd-run --collect --wait --quiet --pipe -- \
env DEBIAN_FRONTEND=noninteractive dpkg --force-confold --force-downgrade -i \
/tmp/fips_0.4.1_amd64.deb
# 4. Restart the ACTIVE unit — it is archipelago-fips.service,
# NOT fips.service (which is inactive on these nodes):
sudo -n systemctl restart archipelago-fips.service
# 5. Verify:
fipsctl --version
sudo -n fipsctl show links # expect anchor 185.18.221.160:8443 connected
sudo -n fipsctl show tree # expect is_root: false, depth > 0
```
### ISO implication (important)
`image-recipe/build/auto-installer/Dockerfile.rootfs:23` builds FIPS from
**unpinned upstream main** (`git clone --depth 1`, no rev/tag/checksum, amd64
only). So a freshly built ISO will pick up whatever main is that day — probably
≥0.4.1, but it is not deterministic. Pinning is an open item in
`docs/1.8.0-RELEASE-HARDENING-PLAN.md:319-322`. **Consider pinning to v0.4.1
before building the release ISO** so the shipped version is knowable.
---
## 3. The original bug — peer cloud files not loading
**Status: root-caused for the thinkpad; NOT fully explained.** Being explicit
because it would be easy to read this as closed.
What is established:
- FIPS was fully down on the thinkpad: `fipsctl show peers``[]`, `show links`
`[]`, `show tree``is_root: true, depth 0`. An island.
- Cause is **network egress**, not FIPS config: the thinkpad cannot reach the
public anchor `185.18.221.160` (`fips.v0l.io`) **at all** — 100% packet loss on
ICMP, 443/8443/8668 all time out. `show transports` showed
`packets_sent: 760, packets_recv: 0` on both UDP and TCP.
- Local firewall is **not** the cause (nft/iptables policy `accept`; only stock
Tailscale anti-spoof DROPs).
- The OptiPlex, on the same `/24`, reaches the anchor fine → it's the thinkpad's
WiFi segment (`wlp3s0`), which also blocks L2 to `.198` (`ip neigh``FAILED`).
- With no FIPS tree, everything falls back to Tor. Every peer in
`federation/nodes.json` reads `last_transport: "tor"`, never `"fips"`.
- **Tor itself is healthy**: fetched the OptiPlex's `/content` over Tor 3×,
HTTP 200 in 4.18.5s — well inside the 30s budget at `content.rs:349`.
What is **not** established: why three specific `content.browse-peer` calls
failed today (05:25, 16:37, 16:43 UTC). Tor tested healthy and was never
reproduced. Two hypotheses were tested and **disproved**: the Tor fallback logic
is correct (FIPS-unreachable returns `None` and falls through in Auto mode), and
the legs get independent timeouts (Tor gets a fresh 30s). Best remaining guess is
cold-circuit timeouts on first fetch after idle — **a guess, not a finding.**
`3ab7fb52` means the next occurrence will log the actual cause.
### Corrections to earlier claims in this session
- "Point FIPS at the Tailscale IP" was **wrong**. FIPS routes by npub; the
`ip:port` in `fipsctl connect` is only an underlay endpoint hint.
- "The public anchor may be dead fleet-wide" was **wrong**. Its peer is healthy
(`delivery_ratio` 1.0 both directions, bloom filter syncing). The
`bytes_recv: 0` link counters are simply uninstrumented in 0.3.0.
---
## 4. Open items — decisions NOT taken
1. **Second FIPS anchor (user asked for this; not built).** Needs a host running
FIPS that is reachable from the restricted WiFi. Candidate found: OVH
**`146.59.87.168`** — pings fine from the thinkpad and general egress works
(github 200), while the upstream anchor fails even ICMP there. But it does not
run FIPS yet, so this means **installing FIPS on the box that hosts Gitea**
a production change, deliberately not made unprompted. Code side is easy after:
`fips/anchors.rs:47-50` is a single hardcoded anchor that should become a list
(`default_public_anchor()``default_public_anchors() -> Vec<SeedAnchor>`).
2. **Fleet rollout of FIPS 0.4.1** — only 2 nodes done. `.228`
(`100.64.204.114`) has been **offline ~20h** and could not be included.
3. **Deploying the archipelago binary** carrying `5fd0d6c3` — no node has it yet,
so mDNS is not actually live anywhere. That is what this OTA is for.
4. **mDNS caveat:** on the thinkpad's WiFi, multicast may also be blocked, so
mDNS may not rescue that particular node even after the OTA. It will help
co-located nodes on sane networks.
5. **Pin FIPS in the ISO build** (see §2) — recommended before the release ISO.
---
## 5. Release ritual (from prior sessions — follow exactly)
Working tree at handoff had pre-existing unrelated dirt: `core/Cargo.lock`,
`release-manifest.json`, `releases/manifest.json` modified, and an untracked
`neode-ui/vite.preview.config.mts`. **Stage explicitly by path** — another
agent may share this tree; never `git add -A`.
```bash
V=1.7.106-alpha
# Frontend build — MUST verify dist actually changed (build can silently no-op)
cd neode-ui && npm run build # → web/dist/neode-ui/
grep -r "md:hidden" ../web/dist/neode-ui/assets/PeerFiles-*.js # sanity
# Backend
cd core && cargo build --release -p archipelago
# If you hit `rust-lld: undefined hidden symbol`, it's incremental-cache
# corruption — rebuild with CARGO_INCREMENTAL=0
# Tarball MUST be flat (files at root, no neode-ui/ wrapper) or every fleet UI 403s
tar -czf releases/v$V/archipelago-frontend-$V.tar.gz -C web/dist/neode-ui .
tar -tzf releases/v$V/archipelago-frontend-$V.tar.gz | head -3 # ./ then ./index.html
# Exclude the ~17MB companion APK from tarballs.
# Ship
scripts/create-release.sh $V
scripts/publish-release-assets.sh $V gitea-vps2
git push origin main && git push origin --tags # tag or the Releases page stays empty
git push gitea-ai main # main is protected; use the `ai` account
# Verify the live manifest
curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
```
Notes: vps2 (`146.59.87.168`) is the **primary** OTA manifest host. Signing is
done at the **user's TTY** — do not attempt it unattended. Clean `/tmp` first
(past releases hit ENOSPC). Changelogs must be **layman-readable**, leading with
user benefit.
### ISO
```bash
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh
```
ISO builds are **always unbundled** — the default env silently builds the wrong
full-bundle variant. Only filebrowser + fmcd are baked in. Verify the output
filename contains `unbundled` and is ≈2.4G. The ISO's frontend source is
`/opt/archipelago/web-ui` — rsync dist there first and verify **inside** the ISO.
---
## 6. Node access quick reference
- **thinkpad (`.116`) is the local machine** — do not SSH to it; read
`journalctl -u archipelago` and `/var/lib/archipelago/**` directly.
- **OptiPlex `.198`** = Tailscale `archipelago-5` / `100.114.134.21`, user
`archipelago`. Its LAN IP is unreachable from the thinkpad — use Tailscale.
- `.228` = `archipelago-2` / `100.64.204.114`**offline as of 2026-07-20**, and
it is in real use; don't touch uninvited.
- `archipelago-1` (`100.82.34.38`) is a Ryzen AI Max desktop, **not** the OptiPlex.
- Nodes have no `sqlite3` — use `sudo -n python3` to read the JSON stores.
- `fipsctl` needs `sudo -n` (socket is `root:fips` 0660).
- **Never run `archipelago --version` on fleet nodes** (deployed binaries predate #74).
+60
View File
@@ -0,0 +1,60 @@
# Framework PT test plan — Pine voice epic (pre-release gate)
Target node: **framework-pt** (`100.65.115.109`, LAN 192.168.1.249). Run after
BOTH agents' work is merged, with the dev binary sideloaded and the signed
catalog (pine 1.3.0 + pine-openwakeword) published. Every ❑ must pass before
the release ritual starts. Items marked **(user)** need a human in the room.
## A. Deploy / prerequisites
- ❑ A1 Dev binary sideloaded, `archipelago` service active, no crash-loop in journal.
- ❑ A2 nginx self-heal added `location /api/pine/status` to every server block; `nginx -t` passes; nginx reloaded.
- ❑ A3 Signed catalog with pine 1.3.0 + pine-openwakeword live at the raw URL; node refreshed it (hourly sweep or "Check for updates").
## B. `/api/pine/status` endpoint
- ❑ B1 Public tier through nginx (`curl http://127.0.0.1/api/pine/status`): version, uptime, bitcoin height/sync_percent/peers, mesh peers. `lightning` null, `mesh_message` absent.
- ❑ B2 Wrong bearer token → still public-only (no balances). Correct token (from `/var/lib/archipelago/secrets/pine-status-token`) → lightning balances + latest mesh message present.
- ❑ B3 Reachable from inside the HA container via `host.containers.internal:80`.
- ❑ B4 Token file is 0600, owned by the service user.
## C. Stack / openwakeword container
- ❑ C1 Reconcile installs `pine-openwakeword` (wyoming-openwakeword 2.1.0), healthy on :10400.
- ❑ C2 Existing pine-whisper / pine-piper / pine were ADOPTED, not recreated — model data dirs untouched.
- ❑ C3 `archipelago` service restart → all four pine containers come back (crash-recovery stack spec).
- ❑ C4 UI: openwakeword listed under Services (no extra store card); Pine card shows 1.3.0.
## D. Home Assistant seeding
- ❑ D1 configuration.yaml: legacy hand-staged block (bitcoind :18332 + plaintext RPC creds) fully replaced by the bounded token-based block.
- ❑ D2 `custom_sentences/en/archy.yaml` carries all four intents.
- ❑ D3 `.storage/core.config_entries`: wyoming entry for openwakeword (:10400) + `anthropic` entry (Claude, conversation + ai_task subentries).
- ❑ D4 Pipeline: `conversation_engine = conversation.claude_conversation`, `prefer_local_intents: true`.
- ❑ D5 automations.yaml: `archy_mesh_announce` seeded.
- ❑ D6 HA restarts clean — no setup errors for anthropic / wyoming / rest / intent_script in `podman logs homeassistant`.
- ❑ D7 Sensors report real values: archy_block_height, archy_bitcoin_sync, archy_bitcoin_peers, archy_mesh_peers, archy_lightning_balance (or clean unavailable if LND absent), archy_mesh_message.
## E. Voice / intents (API level first, then live speaker)
- ❑ E1 Exact phrase "what's the block height" → answered by the LOCAL intent (correct height, no Anthropic API call in HA logs).
- ❑ E2 Fuzzy phrase (e.g. "how tall is the chain right now") → Claude routes to the ArchyBlockHeight tool; answer contains the real height.
- ❑ E3 "how many peers", "is the node synced", "what's my lightning balance" → correct spoken-length answers.
- ❑ E4 Off-topic question → Claude answers, 12 sentences, no markdown.
- ❑ E5 **(user)** Live speaker: "Hey Jarvis, what's the block height" → audible correct answer.
- ❑ E6 Mesh announce: new received mesh text (or manual `assist_satellite.announce` if no radio) → speaker announces sender + text; no announce storm on HA restart.
## F. Pine launcher page (1.3.0)
- ❑ F1 Page on :10380→:10381 shows the live node card (version, uptime, block, sync, peers) within ~5s.
- ❑ F2 `/node-status` proxy works (pine nginx resolves host.containers.internal at startup — container must not crash-loop).
- ❑ F3 "Connect Pine to WiFi" provisioner still intact (no JS errors on load).
## G. Cleanup / regression sweep
- ❑ G1 Both stray socat 18332 forwarders killed; sensors still work via the endpoint.
- ❑ G2 No bitcoind RPC credentials anywhere in HA config.
- ❑ G3 Pre-existing HA function intact: whisper/piper entities, PineVoice satellite pairing, other integrations.
- ❑ G4 nginx regressions: `/health`, `/bitcoin-status`, `/api/app-catalog`, `/proxy/lnd/` all still proxied post-patch.
- ❑ G5 **(user)** Mobile Home: wallet card sits directly under My Apps; desktop layout unchanged.
- ❑ G6 Other agent's changes re-verified after merge (their own checklist).
## H. Production-readiness (release ritual gate)
- ❑ H1 `cargo test` workspace green; frontend builds; drift check `--release --strict` green.
- ❑ H2 `tests/lifecycle/run-gate.sh` re-run ON .228 (stack membership changed → lifecycle gate rule applies).
- ❑ H3 Catalog regenerated → signed (ceremony) → published via gitea-ai; verified at the raw URL.
- ❑ H4 Changelog (layman-readable) + `scripts/sync-whats-new.py` + version bump; release ritual per v1.7.110 notes (push main via gitea-ai BEFORE publish; sign manifest AFTER create-release).
- ❑ H5 No secrets in any commit; frontend tarball flat + APK policy per release notes.
@@ -254,17 +254,29 @@ container_pull() {
echo "📦 Step 1: Building root filesystem..."
ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar"
ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
# The cached rootfs must be invalidated when its recipe changes: a stale
# archipelago-rootfs.tar on the build machine shipped ISOs with NO
# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages
# were added to the Dockerfile below — the cache condition never looked at
# the recipe. Hash the rootfs-defining region of this script; any edit to it
# forces a rebuild. `--rebuild` still forces one unconditionally.
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
echo " Using Docker to create Debian root filesystem..."
# Create a Dockerfile for building the rootfs
cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE
# ─── Stage 1: Build the FIPS mesh daemon .deb from upstream main ─────────
# ─── Stage 1: Build the FIPS mesh daemon .deb at a pinned tag ────────────
#
# FIPS (github.com/jmcorgan/fips) is a fast Nostr-keyed mesh routing
# protocol archipelago uses as its preferred non-Tor transport. We track
# upstream main per project decision (2026-04) — v0.2.0 isn't stable yet.
# protocol archipelago uses as its preferred non-Tor transport.
# Pinned so the shipped version is knowable: an unpinned --depth 1 clone of
# main made every ISO carry whatever upstream happened to be that day.
# v0.4.1 is the version fips/config.rs renders its typed config against and
# the one validated in the field. Bump the two together.
# The .deb is rebuilt every ISO build; Docker layer caching keeps the
# incremental cost low. Failure here fails the ISO build on purpose:
# we don't want to ship an ISO that silently skips FIPS.
@@ -282,7 +294,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
clang libclang-dev libnftnl-dev libmnl-dev \\
&& rm -rf /var/lib/apt/lists/*
RUN cargo install --locked cargo-deb
RUN git clone --depth 1 https://github.com/jmcorgan/fips.git /src/fips
ARG FIPS_VERSION=v0.4.1
RUN git clone --depth 1 --branch "\$FIPS_VERSION" \\
https://github.com/jmcorgan/fips.git /src/fips
WORKDIR /src/fips
# fips-gateway is gated behind the `gateway` Cargo feature (depends on
# `rustables`). Without the feature, cargo doesn't build it, and
@@ -370,6 +384,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
chromium \
unclutter \
fonts-liberation \
fonts-noto-color-emoji \
xfonts-base \
plymouth \
plymouth-themes \
@@ -694,6 +709,7 @@ SYSTEMDSERVICE
$CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR"
$CONTAINER_CMD rm archipelago-rootfs-tmp
echo "$RECIPE_HASH" > "$ROOTFS_STAMP"
echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)"
else
echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)"
@@ -1168,39 +1184,13 @@ BACKENDFILE
fi
fi
# Extract NostrVPN binary from container image (native system service, not a container app)
# NOTE: The container image must be built against Debian 13's GLIBC (2.40).
# If built against a newer GLIBC, the binary will fail at runtime.
# Rebuild with: FROM debian:13 AS builder
echo " Extracting NostrVPN binary..."
_NVPN_IMG="${NOSTR_VPN_IMAGE:-146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7}"
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
if [ -z "$NVPN_IMAGE_ID" ]; then
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
fi
NVPN_CONTAINER=$($CONTAINER_CMD create "$_NVPN_IMG" 2>/dev/null) || true
if [ -n "$NVPN_CONTAINER" ]; then
$CONTAINER_CMD cp "$NVPN_CONTAINER:/usr/local/bin/nvpn" "$ARCH_DIR/bin/nvpn" 2>/dev/null && \
chmod +x "$ARCH_DIR/bin/nvpn" && \
echo " ✅ NostrVPN binary extracted ($(du -h "$ARCH_DIR/bin/nvpn" | cut -f1))"
$CONTAINER_CMD rm "$NVPN_CONTAINER" 2>/dev/null || true
# Check GLIBC compatibility — Debian 13 (Trixie) has GLIBC 2.40
if [ -f "$ARCH_DIR/bin/nvpn" ]; then
NVPN_GLIBC=$(objdump -T "$ARCH_DIR/bin/nvpn" 2>/dev/null | grep -oP 'GLIBC_\K[0-9.]+' | sort -V | tail -1)
if [ -n "$NVPN_GLIBC" ]; then
# Compare: if required GLIBC > 2.40, warn
if printf '%s\n' "2.40" "$NVPN_GLIBC" | sort -V | tail -1 | grep -qv "^2\.40$"; then
echo " ⚠ WARNING: nvpn binary requires GLIBC $NVPN_GLIBC but Debian 13 has 2.40"
echo " ⚠ The nvpn daemon will fail at runtime. Rebuild the container against Debian 13."
echo " ⚠ VPN invite/status will still work via Rust backend config.toml fallback."
else
echo " ✅ nvpn GLIBC compatibility OK (requires $NVPN_GLIBC, target has 2.40)"
fi
fi
fi
else
echo " ⚠ NostrVPN image not available — nvpn binary will be missing"
fi
# NostrVPN (the native `nvpn` mesh-VPN daemon) has been removed from the
# product — the active VPN path is WireGuard/Tailscale (see core vpn.rs). Its
# service is already masked below (ln -sf /dev/null nostr-vpn.service) and the
# binary is never spawned at runtime, so we no longer extract it. The
# nostr-vpn image was deleted from the registry, which is why hard-requiring
# it here bricked the build. Intentionally left out; do not re-add without
# restoring the daemon.
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
echo " Extracting nostr-rs-relay binary..."
@@ -1210,7 +1200,11 @@ if [ -z "$RELAY_IMAGE" ]; then
fi
RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
if [ -n "$RELAY_CONTAINER" ]; then
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
# The relay image builds to its WORKDIR /usr/src/app and execs
# ./nostr-rs-relay from there (not /usr/local/bin — that path was from an
# older image build and silently broke extraction once the image was
# rebuilt to the standard layout).
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/src/app/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
echo " ✅ nostr-rs-relay binary extracted ($(du -h "$ARCH_DIR/bin/nostr-rs-relay" | cut -f1))"
$CONTAINER_CMD rm "$RELAY_CONTAINER" 2>/dev/null || true
@@ -1218,6 +1212,23 @@ else
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
fi
# A missing nostr-rs-relay used to be a warning, and the resulting ISO shipped
# an enabled nostr-relay unit that crash-looped on every install. Refuse to
# produce that ISO unless explicitly overridden. (nvpn is intentionally no
# longer required — NostrVPN was removed; see the note above.)
MISSING_VPN_BINARIES=""
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
if [ -n "$MISSING_VPN_BINARIES" ]; then
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)"
else
echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES"
echo " The registry (146.59.87.168:3000) must be reachable and hold the images,"
echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling."
exit 1
fi
fi
# Copy WireGuard helper script
if [ -f "$WORK_DIR/archipelago-wg" ]; then
cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg"
@@ -1658,17 +1669,17 @@ LOG="/var/log/archipelago-tor.log"
mkdir -p "$ARCHY_TOR_DIR" "$TOR_CONFIG_DIR"
# Write services.json for the backend to read
# First boot only: seed services.json + torrc. The unit runs on EVERY boot
# (oneshot, multi-user.target), and rewriting these unconditionally clobbered
# hidden services the backend added after app installs. Only the node's own
# service is pre-baked — apps get their hidden service created on install
# (auto_add_tor_service / tor.create-service), never pre-created for apps
# that may never be installed (issue #79).
if [ ! -f "$TOR_CONFIG_DIR/services.json" ]; then
cat > "$ARCHY_TOR_DIR/services.json" <<TORJSON
{
"services": [
{"name": "archipelago", "local_port": 80, "enabled": true},
{"name": "bitcoin", "local_port": 8333, "enabled": true},
{"name": "electrumx", "local_port": 50001, "enabled": true},
{"name": "lnd", "local_port": 9735, "enabled": true},
{"name": "btcpay", "local_port": 23000, "enabled": true},
{"name": "mempool", "local_port": 4080, "enabled": true},
{"name": "fedimint", "local_port": 8175, "enabled": true}
{"name": "archipelago", "local_port": 80, "enabled": true}
]
}
TORJSON
@@ -1688,33 +1699,16 @@ SocksPolicy reject *
HiddenServiceDir $TOR_DIR/hidden_service_archipelago
HiddenServicePort 80 127.0.0.1:80
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
HiddenServicePort 8333 127.0.0.1:8333
HiddenServicePort 8332 127.0.0.1:8332
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
HiddenServicePort 50001 127.0.0.1:50001
HiddenServiceDir $TOR_DIR/hidden_service_lnd
HiddenServicePort 9735 127.0.0.1:9735
HiddenServicePort 8080 127.0.0.1:8080
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
HiddenServicePort 23000 127.0.0.1:23000
HiddenServiceDir $TOR_DIR/hidden_service_mempool
HiddenServicePort 4080 127.0.0.1:4080
HiddenServiceDir $TOR_DIR/hidden_service_fedimint
HiddenServicePort 8175 127.0.0.1:8175
HiddenServiceDir $TOR_DIR/hidden_service_relay
HiddenServicePort 7777 127.0.0.1:7777
TORRC
else
echo "$(date): tor already initialized — leaving services.json/torrc alone" >> "$LOG"
fi
# Create hidden service dirs with correct ownership and permissions (700, not 750)
# Tor refuses to start if permissions are too permissive
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
for svc in archipelago relay; do
mkdir -p "$TOR_DIR/hidden_service_$svc"
chown debian-tor:debian-tor "$TOR_DIR/hidden_service_$svc"
chmod 700 "$TOR_DIR/hidden_service_$svc"
@@ -1759,7 +1753,7 @@ done
# Sync hostnames to backend-readable directory
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
mkdir -p "$HOSTNAMES_DIR"
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
for svc in archipelago relay; do
if [ -f "$TOR_DIR/hidden_service_${svc}/hostname" ]; then
cp "$TOR_DIR/hidden_service_${svc}/hostname" "$HOSTNAMES_DIR/$svc"
echo "$(date): Synced hostname: $svc" >> "$LOG"
@@ -2497,6 +2491,17 @@ HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
LIDCONF
# Kiosk appliance: a short press or accidental brush of the power button used
# to shut the node down instantly (confirmed on a Framework node — "Power key
# pressed short -> Powering off"). Ignore the short press; keep a deliberate
# long press (hold ~2s) as the intentional power-off, so the node isn't taken
# down by accident but can still be shut down on purpose without a keyboard.
cat > /mnt/target/etc/systemd/logind.conf.d/power-key.conf <<'PWRCONF'
[Login]
HandlePowerKey=ignore
HandlePowerKeyLongPress=poweroff
PWRCONF
# Copy Archipelago binaries and files
if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then
cp -r "$BOOT_MEDIA/archipelago/bin/"* /mnt/target/usr/local/bin/ 2>/dev/null || true
@@ -2656,6 +2661,11 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
W='\033[1;37m'
N='\033[0m'
# The logo uses UTF-8 block-drawing glyphs. Switching back from the kiosk
# (Xorg on vt1) can leave the console VT out of UTF-8 mode, which renders
# them as garbage bytes ("sometimes corrupt"). ESC % G forces the VT into
# UTF-8 mode so the logo always draws correctly.
printf '\033%%G' 2>/dev/null || true
clear
echo -e " ${O}▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█${N}"
echo -e " ${O}█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █${N}"
@@ -2671,10 +2681,14 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
if [ -b /dev/mapper/archipelago-data ] || [ -b /dev/mapper/archipelago_crypt ]; then
echo -e " ${OD}storage LUKS2 encrypted${N}"
fi
# The kiosk's Xorg runs on vt1 (see archipelago-kiosk-launcher: "Xorg :0
# vt1"), so Ctrl+Alt+F1 IS the kiosk and a terminal is on another VT (F2,
# where systemd auto-spawns a getty). The old hints had these backwards —
# they sent you to an empty black VT7 and there was no way back to the kiosk.
if systemctl is-active archipelago-kiosk.service >/dev/null 2>&1; then
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F1 for terminal)${N}"
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F2 for terminal)${N}"
else
echo -e " ${OD}display Console (Ctrl+Alt+F7 for kiosk)${N}"
echo -e " ${OD}display Console (Ctrl+Alt+F1 for kiosk)${N}"
fi
echo ""
fi
@@ -3345,6 +3359,11 @@ echo ""
echo "=== Done ==="
DIAGSCRIPT
chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh
# v1.7.104 shipped installs where this script was missing while its unit was
# enabled (203/EXEC forever). Verify the write actually landed, loudly.
if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then
echo "ERROR: first-boot-diag.sh was not written to the target" >&2
fi
# Systemd oneshot service for first-boot diagnostics
cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
@@ -3352,6 +3371,8 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
Description=Archipelago First Boot Diagnostics
After=multi-user.target archipelago.service nginx.service
ConditionPathExists=!/var/log/archipelago-first-boot-diag.log
# Skip cleanly (instead of failing 203/EXEC) if the script is missing.
ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh
[Service]
Type=oneshot
@@ -22,6 +22,11 @@ if [ "$X_READY" != "true" ]; then
exit 1
fi
# Settings-managed display overrides (system.kiosk-display.set writes this
# file, then restarts the kiosk): may set ARCHIPELAGO_KIOSK_SCALE,
# ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH or ARCHIPELAGO_KIOSK_MAX_WIDTH.
[ -f /etc/archipelago/kiosk-display.conf ] && . /etc/archipelago/kiosk-display.conf
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
@@ -54,6 +59,25 @@ configure_display() {
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$mode" ] || mode=1920x1080
# Optional resolution cap for weak hardware: ARCHIPELAGO_KIOSK_MAX_WIDTH=1920
# drops a 4K panel to its best <=1920-wide mode (the TV upscales) so the
# software rasterizer paints 1/4 the pixels. Off by default — native mode
# is sharp and fits the tile budget now that --window-size is in DIPs.
local max_w=${ARCHIPELAGO_KIOSK_MAX_WIDTH:-0}
if [ "$max_w" -gt 0 ] 2>/dev/null && [ "${mode%x*}" -gt "$max_w" ] 2>/dev/null; then
local capped
capped=$(awk -v out="$output" -v maxw="$max_w" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
split($1, wh, "x")
if (wh[1] + 0 <= maxw && wh[1] + 0 > best_w) { best_w = wh[1] + 0; best = $1 }
}
active && /^[^[:space:]]/ { active = 0 }
END { if (best) print best }
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$capped" ] && mode=$capped
fi
# Kiosk should use one native output. A spanning desktop makes Chromium land
# on the laptop panel or stretch across both outputs.
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
@@ -82,6 +106,40 @@ configure_display() {
configure_display
# --- Kiosk UI scaling for large / high-res displays -----------------------
# REVERT: set env ARCHIPELAGO_KIOSK_SCALE=1 (per-node, no rebuild), or restore
# the hardcoded --force-device-scale-factor=1 below to disable entirely.
#
# A big TV reports its full native resolution as the CSS viewport, so a 4K
# panel becomes a 3840px-wide viewport and the UI renders tiny — and a
# keyboard-less kiosk can't zoom. Derive Chromium's device-scale-factor from
# the detected panel width so the *effective* CSS viewport lands near a
# comfortable target. Panels >=2560 wide get a 1920-wide layout (4K -> scale
# 2.0 — user-validated on a 72" TV: spacious desktop layout, 2x sharp);
# smaller panels get a 1280-wide layout (1920 -> 1.50, laptops -> 1.0).
if [ -z "${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-}" ]; then
if [ "$KIOSK_MODE_W" -ge 2560 ] 2>/dev/null; then
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920
else
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280
fi
fi
KIOSK_TARGET_CSS_WIDTH=$ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH
if [ -n "${ARCHIPELAGO_KIOSK_SCALE:-}" ]; then
KIOSK_SCALE=$ARCHIPELAGO_KIOSK_SCALE
else
KIOSK_SCALE=$(awk -v w="$KIOSK_MODE_W" -v t="$KIOSK_TARGET_CSS_WIDTH" \
'BEGIN{if(t<=0)t=1280; s=w/t; s=int(s*4+0.5)/4; if(s<1)s=1; if(s>3)s=3; printf "%.2f", s}')
fi
# Chromium's --window-size is in DIPs (CSS px), not physical pixels: at scale S
# the window paints S x larger. This X session has no window manager, so
# --kiosk/--start-fullscreen cannot snap an oversized window back to the panel
# — it just hangs off the right/bottom edges (content cropped, background art
# offscreen). Size the window in DIPs so DIPs x scale = the panel exactly.
KIOSK_WIN_W=$(awk -v w="$KIOSK_MODE_W" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", w/s}')
KIOSK_WIN_H=$(awk -v h="$KIOSK_MODE_H" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", h/s}')
xhost +SI:localuser:archipelago 2>/dev/null || true
xsetroot -solid black 2>/dev/null || true
xset s off 2>/dev/null || true
@@ -116,8 +174,17 @@ while true; do
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
# falls back to raw ALSA "default", fails to connect, and produces no audio
# at all with no visible error (--noerrdialogs suppresses it).
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
# Force a DARK color-scheme preference. The main UI hardcodes its dark
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
# and defaults to its LIGHT variant (white panels) when the browser reports
# no preference — which a minimal kiosk X session does. Two independent dark
# signals so this survives a Chromium enum change: GTK_THEME is version-
# independent and can never force light (worst case: no effect), and the
# blink-settings flag (0 = kDark in modern Chromium) reinforces it. Neither
# can regress the always-dark main UI.
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago GTK_THEME=Adwaita:dark XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
--blink-settings=preferredColorScheme=0 \
--noerrdialogs \
--disable-infobars \
--disable-translate \
@@ -130,10 +197,10 @@ while true; do
--disable-component-update \
$GPU_FLAGS \
--renderer-process-limit=2 \
--window-size=${KIOSK_MODE_W},${KIOSK_MODE_H} \
--window-size=${KIOSK_WIN_W},${KIOSK_WIN_H} \
--window-position=0,0 \
--start-fullscreen \
--force-device-scale-factor=1 \
--force-device-scale-factor=${KIOSK_SCALE} \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
+5
View File
@@ -25,6 +25,11 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
# in-binary post-OTA probe never runs — this restores the update-backup binary
# after 5 failed start attempts while the pending-verify marker exists.
# "-" so a missing/failed guard can never block the service itself.
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
@@ -236,6 +236,22 @@ server {
error_page 504 = @backend_timeout;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
location /api/pine/status {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 10s;
proxy_read_timeout 15s;
proxy_send_timeout 5s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /lnd-connect-info {
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
@@ -1022,6 +1038,22 @@ server {
error_page 504 = @backend_timeout;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
location /api/pine/status {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 10s;
proxy_read_timeout 15s;
proxy_send_timeout 5s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /lnd-connect-info {
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
+3
View File
@@ -3,6 +3,9 @@ Description=Archipelago Private Nostr Relay
After=network-online.target
Wants=network-online.target
Before=nostr-vpn.service
# An ISO built without the relay binary (registry unreachable at build time)
# must not crash-loop every 3s forever — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nostr-rs-relay
[Service]
Type=simple
+3
View File
@@ -4,6 +4,9 @@ After=network-online.target tor.service archipelago.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
# An ISO built without the nvpn binary (registry unreachable at build time)
# must not restart-loop — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nvpn
[Service]
Type=simple
+11 -2
View File
@@ -228,7 +228,9 @@ function seedMockState() {
return {
analyticsEnabled: false,
nodeVisibility: 'discoverable',
nostrDiscovery: true,
// Discovery starts OFF, matching the production default — enabling it in
// the UI walks through the presence-signing overlay.
nostrDiscovery: false,
pendingPeerRequests: [
{
id: 'preq-demo-1',
@@ -1634,7 +1636,14 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { event_id: 'mock-event-id', success: 2, failed: 0 } })
}
case 'node.nostr-pubkey': {
return res.json({ result: { nostr_pubkey: 'mock-nostr-pubkey-hex' } })
// Exact shape of the real handler (node.rs handle_node_nostr_pubkey):
// the node's dedicated discovery key — hex + genuine bech32 npub
// (deliberately NOT one of the personal identity keys; discovery
// presence events are always signed with this separate node key).
return res.json({ result: {
nostr_pubkey: 'c9f0a2e84b71d3568e0f4a6b2c8d1e97a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2',
nostr_npub: 'npub1e8c296ztw8f4drs0ff4jerg7j73mt37eu8e2fdkg6r30ff4ccrfq8j5vuy',
} })
}
case 'node.signChallenge': {
+94 -9
View File
@@ -1,22 +1,25 @@
{
"name": "neode-ui",
"version": "1.7.102-alpha",
"version": "1.7.111-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.102-alpha",
"version": "1.7.111-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -3896,6 +3899,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@@ -4727,7 +4736,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -4801,6 +4809,31 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -4901,10 +4934,9 @@
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -4922,7 +4954,7 @@
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
@@ -5099,6 +5131,15 @@
"node": ">=8"
}
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -5499,6 +5540,15 @@
"node": ">= 8"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -7408,7 +7458,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -7564,6 +7613,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"license": "MIT"
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -8358,6 +8413,17 @@
"node": ">= 0.4"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"license": "BSD-3-Clause",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -9193,6 +9259,15 @@
"node": ">=6"
}
},
"node_modules/qr-scanner": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -9317,6 +9392,16 @@
"node": ">=6"
}
},
"node_modules/qrloop": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/qrloop/-/qrloop-1.4.1.tgz",
"integrity": "sha512-LXkwCl1Qd8imTHb+KqjMn+cHmuncyFT81AXoooWJvbG3+g9q61l9udSRPgY4cgl+5goQHuAK4teEdUF6UErYXw==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"md5": "^2.3.0"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.102-alpha",
"version": "1.7.111-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -26,13 +26,16 @@
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Pine">
<rect width="512" height="512" fill="#ffffff"/>
<g fill="#000000">
<!-- trunk -->
<rect x="236" y="396" width="40" height="72" rx="6"/>
<!-- three tiers of the evergreen, top to bottom -->
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 508 B

+13 -2
View File
@@ -351,12 +351,12 @@
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1.0",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -370,6 +370,17 @@
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
Binary file not shown.
+28 -11
View File
@@ -131,6 +131,13 @@ function syncKioskSafeArea() {
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
// (3D bg layers, animated mix-blend overlays) fail and paint black.
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
if (isKiosk) {
// AIUI (same-origin iframe) themes from localStorage['aiui-theme'], and a
// stored value beats its prefers-color-scheme fallback. Kiosk profiles
// that ran before the launcher forced a dark color-scheme have "light"
// baked in white panels on an otherwise dark TV UI so pin it dark.
try { localStorage.setItem('aiui-theme', 'dark') } catch { /* storage full/denied */ }
}
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
@@ -152,13 +159,12 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
messageToast.startPolling()
screensaverStore.resetInactivityTimer()
// Don't start relay on kiosk kiosk gets input via xdotool (system-level),
// relay would duplicate every keystroke/click as DOM events
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
if (!isKiosk) {
startRemoteRelay()
}
// Kiosk included: the backend's remote-input path is relay-only (it
// validates and broadcasts; it never runs xdotool), so the browser-side
// relay is the ONLY consumer of companion input. Skipping it on kiosk
// an assumption from a long-gone system-level xdotool injector left
// TVs deaf to the companion remote.
startRemoteRelay()
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' }
@@ -424,10 +430,14 @@ onMounted(async () => {
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && bootPath === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
// Root boots always ask the backend even when this browser thinks it has
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
// are per-origin browser state: after a reinstall (or another node coming
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
// a fresh install's intro / misroute it to login.
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
if (splashCandidate && onboardingComplete !== true) {
if (splashCandidate) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
// Bound the pre-splash status check: its retry ladder can spend ~30s
@@ -437,10 +447,17 @@ onMounted(async () => {
// the splash play (a fresh install IS the slow-backend case; onboarded
// nodes answer in milliseconds, so their suppression path is intact).
// handleSplashComplete re-checks with full retries after the intro.
onboardingComplete = await Promise.race([
const live = await Promise.race([
checkOnboardingStatus(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
])
if (live !== null) onboardingComplete = live
if (live === false && seenIntro) {
// Backend-confirmed fresh node behind a browser with a stale flag
// drop it so this boot (and every later one) plays the intro.
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
seenIntro = false
}
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
@@ -161,6 +161,14 @@
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<button
v-if="wgError && !wgLoading"
type="button"
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
@click="retryWgPeer"
>
Try again
</button>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
@@ -318,7 +326,15 @@ const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
// Running inside the companion app's own WebView (it injects this JS bridge).
// The "get the companion app" pitch is nonsense there the user is already in
// it and the WG steps mid-pairing race the phone's changing network (the
// "Failed to fetch" dead-end QR). Server/tunnel management for connected
// companions lives in the NESMenu instead.
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
onMounted(() => {
if (IN_COMPANION_APP) return
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
setTimeout(maybeShow, BASE_DELAY_MS)
@@ -471,6 +487,19 @@ function backFromPair() {
}
}
// Network-class failures: the node itself was unreachable (as opposed to the
// backend answering with an RPC error). Includes the client's own timeout.
const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i
// Auto-retry ladder for network-class failures. On a first install this step
// is often reached while the backend is still settling (services starting,
// backend restarting during container orchestration) a single failed fetch
// left a permanently blank QR unless the user spotted the retry button.
const WG_RETRY_DELAYS_MS = [2000, 4000, 8000]
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const stillOnWgStep = () => visible.value && step.value === 'wgqr'
// Create (or fetch) the phone's VPN peer and render its config as a QR.
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
// looked up first so reopening the modal never duplicates it.
@@ -478,30 +507,71 @@ async function loadWgPeer() {
if (wgQrDataUrl.value || wgLoading.value) return
wgLoading.value = true
wgError.value = ''
try {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => ({ peers: [] as { name: string }[] }))
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
params: { name: WG_PEER_NAME },
})
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
} catch (e) {
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
} finally {
wgLoading.value = false
for (let attempt = 0; ; attempt++) {
try {
await provisionWgPeer()
break
} catch (e) {
const raw = e instanceof Error ? e.message : ''
const isNetworkErr = WG_NETWORK_ERR.test(raw)
const retryDelay = WG_RETRY_DELAYS_MS[attempt]
if (isNetworkErr && retryDelay !== undefined && stillOnWgStep()) {
await sleep(retryDelay)
if (stillOnWgStep()) continue
}
// fetch()'s raw "Failed to fetch" means the node itself was unreachable
// after the retry ladder that's usually the phone's network mid-change
// (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16).
// Say so, and leave a Retry path instead of a dead end.
wgError.value = isNetworkErr
? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again."
: raw || 'Failed to generate the tunnel config'
break
}
}
wgLoading.value = false
}
async function provisionWgPeer() {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => null)
// list-peers unreachable don't guess "doesn't exist": create-peer on an
// existing name would fail. Try create first, fall back to peer-config.
const exists = listed === null
? null
: (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
let res: { config: string; peer_ip: string }
if (exists === true) {
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
} else {
try {
res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } })
} catch (e) {
// Peer already provisioned on a previous visit (list failed or raced).
const msg = e instanceof Error ? e.message : ''
if (/exist|duplicate/i.test(msg)) {
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
} else {
throw e
}
}
}
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
function retryWgPeer() {
wgError.value = ''
void loadWgPeer()
}
// Same-device path: hand the config to the WireGuard app as an importable
@@ -27,9 +27,8 @@
<div class="flex-1 min-w-0">
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
<p class="text-white/55 text-xs leading-relaxed">
Pair your node with the Zeus mobile wallet open a channel to their Olympus node and
start sending and receiving Lightning payments from your phone.
Minimum 150,000 · maximum 1,500,000 sats.
Open a channel with Zeus Olympus node and start sending and receiving Lightning
payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.
</p>
</div>
<div class="flex sm:flex-col items-center gap-2 shrink-0">
@@ -26,6 +26,7 @@
import { ref, onMounted } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { IS_DEMO } from '@/composables/useDemoIntro'
const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null
@@ -53,6 +54,12 @@ function reloadAfterCinematic() {
}
onMounted(() => {
// The public demo has no version to update to the prompt is noise, and
// both accept-paths end in a reload that replays the demo intro ("the site
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
// waiting worker is safe: this page keeps its complete old cache, and the
// new build activates on the next visit.
if (IS_DEMO) return
// Listen for service worker updates
if ('serviceWorker' in navigator) {
// On the very first visit the page loads with no controlling SW; the
@@ -72,6 +72,12 @@
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
Scan
</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
</button>
@@ -95,7 +101,7 @@ const props = defineProps<{
/** Generate an on-chain address immediately when the modal opens */
autoGenerate?: boolean
}>()
const emit = defineEmits<{ close: []; received: [] }>()
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
+7 -1
View File
@@ -69,6 +69,12 @@
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
Scan
</button>
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : t('common.send') }}
</button>
@@ -85,7 +91,7 @@ import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
const amount = ref<number>(0)
+752
View File
@@ -0,0 +1,752 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="modalRef"
class="glass-card p-6 w-full max-w-md relative z-10 overflow-hidden"
role="dialog"
aria-modal="true"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between gap-4 mb-4">
<div class="flex items-center gap-2 min-w-0">
<button
v-if="pane !== 'scan'"
@click="goBack"
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Back"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 class="text-xl font-semibold text-white truncate">{{ paneTitle }}</h3>
</div>
<button
@click="close"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
<!-- ============ SCAN PANE ============ -->
<div v-if="pane === 'scan'" key="scan">
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
source-less <video> flashes a native play glyph in Android WebViews -->
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
<!-- Viewfinder -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="scan-viewfinder"></div>
</div>
<!-- While auto-start is settling, show only a quiet spinner the
Start/Take-photo buttons are the FALLBACK, not a splash screen -->
<div v-if="!isScanning && autoStarting" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
<svg class="w-8 h-8 text-white/40 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v3a5 5 0 00-5 5H4z" />
</svg>
</div>
<div v-else-if="!isScanning" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<button v-if="!liveCameraUnavailable" @click="startScanning" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ cameraError ? 'Retry camera' : 'Start camera' }}
</button>
<!-- Insecure-context fallback: live getUserMedia preview needs
HTTPS, but the native camera via a file input does not
snap a photo of the QR and decode it locally. -->
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
Take photo of QR
</button>
<input
ref="photoInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhotoPicked"
/>
</div>
</div>
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
</p>
</div>
<!-- Paste fallback (also the path on camera-less nodes) -->
<div class="flex gap-2">
<input
v-model="pasteInput"
type="text"
placeholder="…or paste an invoice / address / token"
class="flex-1 input-glass font-mono text-xs"
@keydown.enter="submitPaste"
/>
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
Use
</button>
</div>
</div>
<!-- ============ AMOUNT / CONFIRM PANE ============ -->
<div v-else-if="pane === 'amount'" key="amount">
<!-- Rail + destination summary -->
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg mb-4">
<span class="w-9 h-9 rounded-lg bg-white/10 flex items-center justify-center shrink-0" :class="railColor">
<span v-if="rail === 'onchain'" class="text-lg font-bold">&#x20bf;</span>
<svg v-else-if="rail === 'lightning'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<!-- SVG, not 🥜: kiosk images ship no color-emoji font -->
<svg v-else-if="rail === 'cashu'" class="w-5 h-5" role="img" aria-label="Cashu" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ railLabel }}</p>
<p class="text-[11px] text-white/40 font-mono truncate">{{ destDisplay }}</p>
</div>
</div>
<!-- Federation join is a confirm, not an amount entry -->
<template v-if="action === 'fedimint-join'">
<p class="text-sm text-white/70 mb-4">
This is a Fedimint federation invite. Join it to hold and send ecash backed by this federation.
</p>
</template>
<template v-else>
<!-- Amount -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">Amount (sats)</label>
<span v-if="amountLocked" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</div>
<input
v-model.number="amount"
type="number"
min="1"
inputmode="numeric"
placeholder="0"
:disabled="amountLocked || sendMax"
class="w-full input-glass text-2xl font-semibold text-center disabled:opacity-70"
/>
</div>
<!-- Presets + Max -->
<div v-if="!amountLocked" class="grid grid-cols-5 gap-2 mb-4">
<button
v-for="preset in PRESETS"
:key="preset"
@click="applyPreset(preset)"
:disabled="balance !== null && preset > balance"
class="px-1 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30"
:class="!sendMax && amount === preset ? 'bg-white/20 text-white' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
>{{ formatPreset(preset) }}</button>
<button
@click="toggleMax"
class="px-1 py-1.5 rounded-lg text-xs font-semibold transition-colors"
:class="sendMax ? 'bg-orange-500/25 text-orange-300 border border-orange-500/40' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
>Max</button>
</div>
<!-- Live balance impact -->
<div class="rounded-lg border border-white/10 divide-y divide-white/5 mb-4">
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">{{ railLabel }} balance</span>
<span class="text-sm font-medium" :class="railColor">
{{ balance === null ? '…' : balance.toLocaleString() + ' sats' }}
</span>
</div>
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">This send</span>
<span class="text-sm font-medium text-white/80">{{ effectiveAmount.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-semibold" :class="insufficient ? 'text-red-400' : 'text-white'">
{{ balance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ railLabel }} balance for this amount.</p>
<p v-else-if="sendMax && rail === 'onchain'" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
</template>
<div v-if="error" class="mb-3 alert-error text-sm">{{ error }}</div>
<div class="flex gap-3">
<button @click="goBack" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button
@click="confirmSend"
:disabled="processing || (action !== 'fedimint-join' && (insufficient || (effectiveAmount <= 0 && !sendMax)))"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ processing ? 'Sending…' : confirmLabel }}
</button>
</div>
</div>
<!-- ============ SUCCESS PANE ============ -->
<div v-else key="success" class="text-center py-2">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full mx-auto mb-5 success-ring">
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successAmount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-2">{{ successVerb }}</div>
<p class="text-sm text-white/50 mb-1">{{ successDetail }}</p>
<p v-if="successRef" class="text-[11px] text-white/30 font-mono break-all px-4 mb-4">{{ successRef }}</p>
<div v-if="balance !== null" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 text-xs text-white/60 mb-5">
<span :class="railColor">{{ railLabel }}</span>
<span>balance now {{ balance.toLocaleString() }} sats</span>
</div>
<button
@click="close"
class="w-full py-3 rounded-xl font-semibold text-base bg-white/10 hover:bg-white/20 text-white transition-colors"
>
Done
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import QrScanner from 'qr-scanner'
import { rpcClient } from '@/api/rpc-client'
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
type Pane = 'scan' | 'amount' | 'success'
const PRESETS = [21, 2100, 21000, 100000]
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
// --- Pane state ---
const pane = ref<Pane>('scan')
const direction = ref<'forward' | 'back'>('forward')
const paneTitle = computed(() => {
if (pane.value === 'scan') return 'Scan to send'
if (pane.value === 'success') return 'Success'
if (action.value === 'fedimint-join') return 'Join federation'
if (action.value === 'redeem-token') return 'Redeem'
return 'Send'
})
function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') {
direction.value = dir
pane.value = p
}
function goBack() {
if (pane.value === 'amount') {
error.value = ''
goTo('scan', 'back')
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
} else if (pane.value === 'success') {
close()
}
}
// --- Scanner ---
const videoElement = ref<HTMLVideoElement | null>(null)
const isScanning = ref(false)
// True while a camera-start attempt is in flight (incl. the automatic one on
// open) the fallback buttons stay hidden until it resolves, so they don't
// flash for a second on every open.
const autoStarting = ref(false)
const scanStatus = ref('')
const scanStatusIsError = ref(false)
const cameraError = ref(false)
const pasteInput = ref('')
const qrScanner = ref<QrScanner | null>(null)
const animatedDecoder = useAnimatedQRDecoder()
async function startScanning() {
cameraError.value = false
scanStatusIsError.value = false
autoStarting.value = true
try {
if (!videoElement.value) return
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
location.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(location.hostname)
? 'Camera needs HTTPS — paste the code below instead'
: 'No camera available — paste the code below instead'
)
}
qrScanner.value = new QrScanner(
videoElement.value,
(result) => handleScanned(result.data),
{
returnDetailedScanResult: true,
highlightScanRegion: false,
preferredCamera: 'environment',
// 4/s decodes plenty fast for a hand-held QR while keeping the
// preview smooth on phone WebViews (10/s visibly lagged the video).
maxScansPerSecond: 4,
}
)
await qrScanner.value.start()
isScanning.value = true
scanStatus.value = ''
} catch (err) {
cameraError.value = true
scanStatusIsError.value = true
scanStatus.value = err instanceof Error ? err.message : 'Unable to access camera'
} finally {
autoStarting.value = false
}
}
function stopScanning() {
qrScanner.value?.stop()
qrScanner.value?.destroy()
qrScanner.value = null
isScanning.value = false
}
function submitPaste() {
const text = pasteInput.value.trim()
if (!text) return
handleScanned(text)
}
// --- Photo-capture fallback (works without a secure context) ---
// getUserMedia is unreachable over plain http (navigator.mediaDevices is
// undefined), but <input capture> opens the native camera in any browser,
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
const photoInput = ref<HTMLInputElement | null>(null)
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
async function onPhotoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
handleScanned(result.data)
} catch {
scanStatusIsError.value = true
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
} finally {
input.value = ''
}
}
// --- Detection (ported from k484's scanner) ---
const rail = ref<Rail>('lightning')
const action = ref<Action>('pay-invoice')
const dest = ref('')
const amount = ref<number>(0)
const amountLocked = ref(false)
const sendMax = ref(false)
const railLabel = computed(() => ({
onchain: 'On-chain',
lightning: 'Lightning',
cashu: 'Cashu',
fedimint: 'Fedimint',
}[rail.value]))
const railColor = computed(() => ({
onchain: 'text-orange-500',
lightning: 'text-yellow-400',
cashu: 'text-purple-400',
fedimint: 'text-blue-400',
}[rail.value]))
const destDisplay = computed(() =>
dest.value.length > 46 ? `${dest.value.slice(0, 26)}${dest.value.slice(-14)}` : dest.value
)
const confirmLabel = computed(() => {
if (action.value === 'fedimint-join') return 'Join'
if (action.value === 'redeem-token') return 'Redeem'
return 'Send'
})
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
function parseBolt11AmountSats(invoice: string): number | null {
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
if (!m || !m[1]) return null
const value = Number(m[1])
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
return Math.round(value * mult * 1e8)
}
function isLightningInvoice(text: string): boolean {
const t = text.toLowerCase()
return t.startsWith('lnbc') || t.startsWith('lntb') || t.startsWith('lnbcrt')
}
function isOnchainAddress(text: string): boolean {
return /^(bc1|tb1|bcrt1)[a-z0-9]{20,90}$/i.test(text) || /^[13][a-km-zA-HJ-NP-Z1-9]{25,40}$/.test(text)
}
function isCashuToken(text: string): boolean {
return text.startsWith('cashuA') || text.startsWith('cashuB')
}
function isFedimintInvite(text: string): boolean {
return text.startsWith('fed11')
}
function isFedimintToken(text: string): boolean {
return /^AwE/.test(text)
}
function isAnimatedFrame(text: string): boolean {
return text.startsWith('B$') || text.startsWith('AAAH') || text.startsWith('ZAAE') || text.startsWith('AAA')
}
function handleScanned(raw: string) {
let text = raw.trim()
scanStatusIsError.value = false
// Animated multi-frame QR (large Fedimint tokens): keep collecting frames
if (isAnimatedFrame(text)) {
animatedDecoder.addFrame(text)
if (animatedDecoder.isComplete.value && animatedDecoder.decodedData.value) {
const token = animatedDecoder.decodedData.value
animatedDecoder.reset()
acceptDetected(token)
} else {
scanStatus.value = `Animated code… ${animatedDecoder.progressText()}`
}
return
}
acceptDetected(text)
}
function acceptDetected(raw: string) {
let text = raw.trim()
let bip21AmountSats: number | null = null
// URI schemes
if (text.toLowerCase().startsWith('lightning:')) text = text.slice(10)
if (text.toLowerCase().startsWith('bitcoin:')) {
try {
const uri = new URL(text)
const lnParam = uri.searchParams.get('lightning')
const amt = uri.searchParams.get('amount')
if (amt && !Number.isNaN(Number(amt))) bip21AmountSats = Math.round(Number(amt) * 1e8)
// Prefer the unified lightning param when present, else the address
text = lnParam || text.slice(8).split('?')[0] || ''
} catch {
text = text.slice(8).split('?')[0] || ''
}
}
if (isLightningInvoice(text)) {
rail.value = 'lightning'
action.value = 'pay-invoice'
dest.value = text
const invoiceAmount = parseBolt11AmountSats(text)
// Zero-amount invoice inside a unified BIP21 URI: prefill from amount=
amount.value = invoiceAmount ?? bip21AmountSats ?? 0
amountLocked.value = invoiceAmount !== null
} else if (isCashuToken(text)) {
rail.value = 'cashu'
action.value = 'redeem-token'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isFedimintInvite(text)) {
rail.value = 'fedimint'
action.value = 'fedimint-join'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isFedimintToken(text)) {
rail.value = 'fedimint'
action.value = 'redeem-token'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isOnchainAddress(text)) {
rail.value = 'onchain'
action.value = 'send-onchain'
dest.value = text
amount.value = bip21AmountSats ?? 0
amountLocked.value = false
} else if (text.toLowerCase().startsWith('lno1')) {
// BOLT12 offer LND (this node's Lightning backend) can't pay offers yet
scanStatusIsError.value = true
scanStatus.value = 'BOLT12 offers aren\'t supported yet — ask the recipient for a BOLT11 invoice'
return
} else if (text.toLowerCase().startsWith('lnurl1') || /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/.test(text)) {
// LNURL-pay / lightning address needs an HTTP callback flow we don't do yet
scanStatusIsError.value = true
scanStatus.value = 'LNURL and lightning addresses aren\'t supported yet — ask for a BOLT11 invoice'
return
} else {
scanStatusIsError.value = true
scanStatus.value = 'Not a recognised invoice, address or token'
return
}
sendMax.value = false
error.value = ''
stopScanning()
loadBalance()
// Tokens carry their own value redeem straight away, success screen follows
if (action.value === 'redeem-token') {
goTo('amount')
confirmSend()
} else {
goTo('amount')
}
}
// --- Balance for the active rail ---
const balance = ref<number | null>(null)
async function loadBalance() {
balance.value = null
try {
if (rail.value === 'cashu') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
balance.value = res.balance_sats ?? 0
} else if (rail.value === 'fedimint') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
balance.value = res.balance_sats ?? 0
} else {
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
balance.value = rail.value === 'onchain' ? (res.balance_sats ?? 0) : (res.channel_balance_sats ?? 0)
}
} catch {
balance.value = null
}
}
const effectiveAmount = computed(() => {
if (sendMax.value) return balance.value ?? 0
return amount.value > 0 ? Math.floor(amount.value) : 0
})
const balanceAfter = computed(() => (balance.value ?? 0) - effectiveAmount.value)
const insufficient = computed(() =>
action.value !== 'fedimint-join' && action.value !== 'redeem-token' &&
balance.value !== null && effectiveAmount.value > balance.value
)
function applyPreset(preset: number) {
sendMax.value = false
amount.value = preset
}
function toggleMax() {
sendMax.value = !sendMax.value
if (sendMax.value && balance.value !== null && rail.value !== 'onchain') {
amount.value = balance.value
}
}
function formatPreset(preset: number): string {
return preset >= 1000 ? `${(preset / 1000).toLocaleString()}k` : String(preset)
}
// --- Send / redeem / join ---
const processing = ref(false)
const error = ref('')
const successAmount = ref(0)
const successVerb = ref('SENT')
const successDetail = ref('')
const successRef = ref('')
async function confirmSend() {
if (processing.value) return
processing.value = true
error.value = ''
try {
if (action.value === 'pay-invoice') {
const params: Record<string, unknown> = { payment_request: dest.value }
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params,
timeout: 60000,
})
successAmount.value = res.amount_sats || effectiveAmount.value
successVerb.value = 'PAID'
successDetail.value = 'Lightning invoice paid'
successRef.value = res.payment_hash
} else if (action.value === 'send-onchain') {
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: sendMax.value
? { addr: dest.value, send_all: true }
: { addr: dest.value, amount: effectiveAmount.value },
timeout: 60000,
})
successAmount.value = sendMax.value ? (balance.value ?? 0) : effectiveAmount.value
successVerb.value = 'SENT'
successDetail.value = 'On-chain transaction broadcast'
successRef.value = res.txid
} else if (action.value === 'redeem-token') {
const res = await rpcClient.call<{ received_sats: number; kind: string }>({
method: 'wallet.ecash-receive',
params: { token: dest.value },
timeout: 60000,
})
rail.value = res.kind === 'fedimint' ? 'fedimint' : 'cashu'
successAmount.value = res.received_sats || 0
successVerb.value = 'RECEIVED'
successDetail.value = `${res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'} token redeemed`
successRef.value = ''
} else {
await rpcClient.call({
method: 'wallet.fedimint-join',
params: { invite_code: dest.value },
timeout: 60000,
})
successAmount.value = 0
successVerb.value = 'JOINED'
successDetail.value = 'Federation joined — you can now hold its ecash'
successRef.value = ''
}
await loadBalance()
goTo('success')
emit('sent')
} catch (err) {
error.value = err instanceof Error ? err.message : 'Send failed'
// Token redeems jump straight from scan; surface the failure on the amount pane
if (pane.value !== 'amount') goTo('amount')
} finally {
processing.value = false
}
}
// --- Lifecycle ---
function resetAll() {
stopScanning()
animatedDecoder.reset()
pane.value = 'scan'
direction.value = 'forward'
scanStatus.value = ''
scanStatusIsError.value = false
cameraError.value = false
pasteInput.value = ''
dest.value = ''
amount.value = 0
amountLocked.value = false
sendMax.value = false
balance.value = null
processing.value = false
error.value = ''
}
function close() {
resetAll()
emit('close')
}
watch(() => props.show, (open) => {
if (open) {
resetAll()
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
} else {
stopScanning()
}
})
onUnmounted(stopScanning)
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
}
/* Horizontal pane slide: forward = new pane arrives from the right */
.pane-forward-enter-active,
.pane-forward-leave-active,
.pane-back-enter-active,
.pane-back-leave-active {
transition: transform 0.22s ease, opacity 0.22s ease;
}
.pane-forward-enter-from {
transform: translateX(40px);
opacity: 0;
}
.pane-forward-leave-to {
transform: translateX(-40px);
opacity: 0;
}
.pane-back-enter-from {
transform: translateX(-40px);
opacity: 0;
}
.pane-back-leave-to {
transform: translateX(40px);
opacity: 0;
}
.scan-viewfinder {
width: 62%;
height: 62%;
border-radius: 1rem;
border: 2px solid rgba(249, 115, 22, 0.85);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
.success-ring {
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
}
</style>
@@ -0,0 +1,60 @@
import { ref } from 'vue'
import {
parseFramesReducer,
areFramesComplete,
framesToData,
totalNumberOfFrames,
currentNumberOfFrames,
} from 'qrloop'
/**
* Collects animated-QR frames (qrloop format, as used by k484 for large
* Fedimint tokens) and reassembles them into the original token string.
*/
export function useAnimatedQRDecoder() {
const framesState = ref<ReturnType<typeof parseFramesReducer> | null>(null)
const isComplete = ref(false)
const decodedData = ref<string | null>(null)
const uniqueFrames = ref<Set<string>>(new Set())
/** Feed one scanned frame; returns true once the full payload is decoded. */
function addFrame(frame: string): boolean {
if (isComplete.value) return true
if (uniqueFrames.value.has(frame)) return false
uniqueFrames.value.add(frame)
try {
framesState.value = parseFramesReducer(framesState.value, frame)
if (areFramesComplete(framesState.value)) {
const dataBuffer = framesToData(framesState.value)
// Tokens travel as URL-safe base64
decodedData.value = dataBuffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
isComplete.value = true
return true
}
return false
} catch {
// A frame that qrloop rejects may just be a different QR format
return false
}
}
function progressText(): string {
if (!framesState.value) return ''
const total = totalNumberOfFrames(framesState.value)
const current = currentNumberOfFrames(framesState.value)
return `${current}/${total} frames`
}
function reset() {
framesState.value = null
uniqueFrames.value.clear()
isComplete.value = false
decodedData.value = null
}
return { isComplete, decodedData, addFrame, reset, progressText }
}
+4 -4
View File
@@ -15,7 +15,7 @@ const FUND_WALLET_STEP: GoalStep = {
id: 'fund-wallet',
title: 'Fund Your Bitcoin Wallet',
description:
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels need between 150,000 and 1,500,000 sats. Funding unlocks once your node finishes syncing the blockchain.",
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels: minimum 150,000 · maximum 1,500,000 on-chain sats required. Funding unlocks once your node finishes syncing the blockchain.",
action: 'fund',
isAutomatic: false,
icon: '/assets/img/app-icons/bitcoin-knots.webp',
@@ -51,7 +51,7 @@ export const GOALS: GoalDefinition[] = [
id: 'open-zeus-channel',
title: 'Open a Channel with Zeus',
description:
'Open a Lightning channel to Zeus, the mobile wallet that pairs perfectly with your node. Fund it with 150,0001,500,000 sats and your shop can accept instant Lightning payments.',
'Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
@@ -106,7 +106,7 @@ export const GOALS: GoalDefinition[] = [
id: 'open-channel',
title: 'Open a Channel with Zeus',
description:
'Open your first payment channel to Zeus, the mobile wallet built for nodes like yours (150,0001,500,000 sats). You can then send and receive Lightning payments from your phone.',
'Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
@@ -208,7 +208,7 @@ export const GOALS: GoalDefinition[] = [
id: 'open-channels',
title: 'Open Payment Channels',
description:
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus (150,0001,500,000 sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus Olympus (minimum 150,000 · maximum 1,500,000 on-chain sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
action: 'configure',
isAutomatic: false,
icon: ZEUS_ICON,
+3 -3
View File
@@ -88,9 +88,9 @@ const router = createRouter({
// normal app (onboarding → login → dashboard) like any other client.
// This route only persists kiosk mode + safe-area insets, then redirects
// to the root app. The launcher still points Chromium here (not directly
// at `/`) so the 'kiosk' flag gets set — App.vue uses it to skip the
// remote relay, which would otherwise double xdotool input on the kiosk
// display. Public so the auth guard doesn't bounce us before beforeEnter.
// at `/`) so the 'kiosk' flag gets set — App.vue keys kiosk-only styling
// (kiosk-mode/kiosk-safe-area classes, dark AIUI pin) off it. Public so
// the auth guard doesn't bounce us before beforeEnter.
path: '/kiosk',
name: 'kiosk',
meta: { public: true },
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// vi.hoisted runs before vi.mock hoisting
@@ -43,6 +43,40 @@ describe('useAppLauncherStore', () => {
expect(store.title).toBe('')
})
describe('companion app (native bridge)', () => {
const openInApp = vi.fn()
beforeEach(() => {
;(window as any).ArchipelagoNative = { openInApp, openExternal: vi.fn() }
openInApp.mockClear()
})
afterEach(() => {
delete (window as any).ArchipelagoNative
})
it('openSession hands iframeable apps to the native WebView, never the iframe session', () => {
const store = useAppLauncherStore()
store.openSession('filebrowser')
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining(':8083'))
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('openSession carries deep-link paths into the WebView URL', () => {
const store = useAppLauncherStore()
store.openSession('mempool', { path: '/tx/abc123' })
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining('/tx/abc123'))
expect(store.panelAppId).toBeNull()
})
it('open() never falls through to the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown app' })
expect(openInApp).toHaveBeenCalledWith('http://192.168.1.228:9999')
expect(store.isOpen).toBe(false)
})
})
it('routes known port apps to full-page session', () => {
const store = useAppLauncherStore()
+23 -1
View File
@@ -3,7 +3,9 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab } from '@/utils/openExternal'
import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
/**
@@ -215,6 +217,19 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Companion app: EVERY app opens in the native in-app WebView — never an
// iframe. The WebView is more performant on the phone and carries the
// native back/forward/reload/close controls. Plain mobile browsers (PWA)
// keep the iframe session below.
if (!IS_DEMO && isCompanionApp()) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
openInAppOrNewTab(launchUrl)
return
}
}
// Demo: apps backed by a real external site that blocks iframing open
// externally; everything else demoable renders in the in-app session.
if (IS_DEMO && isDemoExternal(appId)) {
@@ -309,6 +324,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return
}
// Companion app: never fall through to the iframe overlay — hand the URL
// to the native in-app WebView instead (see openSession).
if (!IS_DEMO && isCompanionApp()) {
openInAppOrNewTab(launchUrl)
return
}
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = launchUrl
title.value = payload.title
+3
View File
@@ -48,6 +48,9 @@ export interface MeshConfigureParams {
receive_block_headers?: boolean
lora_region?: string
device_kind?: string
/** MeshCore LoRa PHY params in firmware field units (freq_khz = MHz×1000,
* bw_hz = kHz×1000). null clears; omitted leaves unchanged. */
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
}
export interface MeshPeer {
+9
View File
@@ -1417,6 +1417,15 @@ html.kiosk-safe-area body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
/* Black fill on BODY only, never on the root element. With a background on
<html>, body's background no longer propagates to the canvas and instead
paints as a normal block background which by CSS paint order lands ON TOP
of negative-z-index descendants, i.e. over .bg-perspective-container
(z-index -10): every dashboard/login background image rendered as solid
black on kiosk displays. Body-only matches the non-kiosk pages, where the
art has always shown. */
html.kiosk-safe-area body {
background: #000;
}
+9
View File
@@ -0,0 +1,9 @@
declare module 'qrloop' {
export type FramesState = unknown
export function parseFramesReducer(state: FramesState | null, frame: string): FramesState
export function areFramesComplete(state: FramesState): boolean
export function framesToData(state: FramesState): { toString(encoding: string): string }
export function totalNumberOfFrames(state: FramesState): number
export function currentNumberOfFrames(state: FramesState): number
export function progressOfFrames(state: FramesState): number
}
@@ -38,4 +38,50 @@ describe('shouldShowIntroSplash', () => {
replayRequested: true,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: false,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/login',
fromBoot: true,
onboardingComplete: false,
})).toBe(true)
})
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: null,
})).toBe(false)
})
it('fresh node on a deep route without boot handoff stays suppressed', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/onboarding/seed',
fromBoot: false,
onboardingComplete: false,
})).toBe(false)
})
it('boot dev mode never root-boots into the intro', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
devMode: 'boot',
onboardingComplete: false,
})).toBe(false)
})
})
+10 -1
View File
@@ -10,10 +10,19 @@ export interface IntroSplashDecisionInput {
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true
const isDirectRoute = input.routePath !== '/'
// A node the backend CONFIRMS has never completed onboarding always gets
// the full intro on a root boot. `seenIntro` is per-origin browser state —
// after a reinstall (or a DHCP-recycled IP), the browser still carries the
// previous node's flag at the same origin, which silently muted the intro
// on genuinely fresh installs.
if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) {
return true
}
if (input.seenIntro) return false
if (input.onboardingComplete === true) return false
const isDirectRoute = input.routePath !== '/'
if (input.fromBoot) return true
if (input.devMode === 'boot') return false
return !isDirectRoute
+26
View File
@@ -100,3 +100,29 @@ export function meshcorePlanFor(code: string | null | undefined): MeshcorePlan |
if (!code) return undefined
return MESHCORE_PLANS[code.toUpperCase()]
}
/** A named MeshCore RF preset for the Device-panel dropdown. */
export interface MeshcoreRfPreset {
id: string
label: string
freqMhz: number
bwKhz: number
sf: number
cr: number
}
/**
* Prepopulated MeshCore RF plans by country/region for the Device panel's
* preset dropdown ("Custom" in the UI unlocks free entry). Values are only
* added here when verifiable: the MeshCore community plans above, the
* MeshCore firmware's own build defaults (platformio.ini `arduino_base`:
* 869.618/62.5/SF8, CR default 5; companion example: 915.0/250/SF10/CR5),
* and operator-attested deployment plans.
*/
export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
{ id: 'eu_868', label: 'Europe / UK — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
{ id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
{ id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 },
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
{ id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
]
+10
View File
@@ -18,6 +18,16 @@ function nativeBridge(): ArchipelagoNativeBridge | undefined {
return (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge }).ArchipelagoNative
}
/**
* True when running inside the Android companion app (native WebView shell).
* The shell injects `window.ArchipelagoNative`; a plain mobile browser / PWA
* never has it.
*/
export function isCompanionApp(): boolean {
const native = nativeBridge()
return !!native && typeof native.openInApp === 'function'
}
export function openExternalUrl(url: string): void {
if (!url) return
const native = nativeBridge()
+6 -27
View File
@@ -546,35 +546,14 @@ onBeforeUnmount(() => {
opacity: 0.5;
}
/* Mode dropdown */
.mode-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
text-align: left;
}
.mode-option:hover {
background: rgba(255, 255, 255, 0.08);
color: white;
}
.mode-option-active {
/* Active display-mode button in the header bar */
.app-session-btn-active {
color: #fb923c;
background: rgba(251, 146, 60, 0.08);
background: rgba(251, 146, 60, 0.12);
}
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
.app-session-btn-active:hover {
color: #fb923c;
background: rgba(251, 146, 60, 0.18);
}
.content-fade-enter-active,
+160 -31
View File
@@ -52,36 +52,108 @@
manages everything related to peering. The toggle directly mutates
the `nostr_discovery_enabled` config flag backend defaults to OFF
and nothing is published until the user explicitly turns it on. -->
<div class="glass-card p-4 mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Nostr discoverability</span>
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
<div class="glass-card p-4 mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Nostr discoverability</span>
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
</div>
<p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only never an onion)
so other nodes can find you and request to peer. Inbound requests land in the
panel below for your approval. Off by default.
</p>
<button
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
@click="showSigningInfo = !showSigningInfo"
>
{{ showSigningInfo ? 'Hide signing details' : 'Signing details' }}
</button>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discoveryToggling"
@click="toggleDiscovery"
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
</div>
<p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only never an onion)
so other nodes can find you and request to peer. Inbound requests land in the
panel below for your approval. Off by default.
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discoveryToggling"
@click="toggleDiscovery"
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
<!-- Signing details: shows WHO signs the presence event (always the
node's own discovery key — deliberately not pickable, so a user's
personal Web5/Nostr identities can never be burned for node
discovery) and a human-readable view of WHAT gets signed. -->
<div v-if="showSigningInfo" class="mt-4 pt-4 border-t border-white/10 space-y-3">
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">Signer</label>
<!-- Same row style as the NostrIdentityPicker overlay, but locked:
node discovery always signs with the node's own key, never a
personal identity, so there is nothing to pick. -->
<div class="w-full sm:max-w-md p-3 rounded-lg bg-white/[0.03] ring-1 ring-white/10">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
<span class="text-sm font-bold">{{ (appStore.serverName || 'N').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ appStore.serverName || 'This node' }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
</div>
<div class="mt-0.5">
<span class="text-white/35 text-xs font-mono truncate">{{ nodeNpub ? truncateNpub(nodeNpub) : 'loading…' }}</span>
</div>
</div>
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Presence events are always signed with this node's dedicated discovery key.
Your personal identities are never used for node discovery, so unlike the app
sign-in overlay there is nothing to choose here.
</p>
</div>
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">What gets signed &amp; published</label>
<dl class="text-xs space-y-1.5">
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
<dd class="text-white/80 font-mono break-all">{{ selfDid || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
<dd class="text-white/80 font-mono break-all">{{ nodeNpub || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
<dd class="text-white/80">{{ appVersion || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
</div>
</dl>
<p class="text-[11px] text-white/40 mt-2">
Every presence event carries a Schnorr signature (NIP-01) made with the key
above relays reject unsigned events, so anything you see via discovery was
cryptographically signed by its node. Your onion address is never part of this
event; it is only shared over encrypted DMs (NIP-44) after you approve a peer.
</p>
</div>
</div>
</div>
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
@@ -140,6 +212,17 @@
@close="showDiscoverModal = false"
@sent="loadPendingRequests"
/>
<PresenceSignModal
:show="showPresenceSignModal"
:server-name="appStore.serverName"
:npub="nodeNpub"
:did="selfDid"
:version="appVersion"
:busy="discoveryToggling"
@confirm="confirmPresenceSign"
@cancel="showPresenceSignModal = false"
/>
</div>
</template>
@@ -148,6 +231,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
import NetworkMap from '@/components/federation/NetworkMap.vue'
import FederationHeader from './federation/FederationHeader.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
@@ -157,12 +241,14 @@ import NodeDetailModal from './federation/NodeDetailModal.vue'
import JoinModal from './federation/JoinModal.vue'
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
import DiscoverModal from './federation/DiscoverModal.vue'
import PresenceSignModal from './federation/PresenceSignModal.vue'
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
import type { PendingPeerRequest } from '@/api/rpc-client'
import { nodeName, timeAgo } from './federation/utils'
const transportStore = useTransportStore()
const appStore = useAppStore()
const syncStore = useSyncStore()
const nodes = ref<FederatedNode[]>([])
const loading = ref(true)
@@ -253,7 +339,7 @@ const dwnSyncLabel = computed(() => {
case 'synced': return 'Synced'
case 'syncing': return 'Syncing...'
case 'error': return 'Error'
default: return dwnStatus.value.sync_status
default: return dwnStatus.value.sync_status || 'Unknown'
}
})
@@ -271,6 +357,26 @@ const discoveryEnabled = ref(false)
const discoveryToggling = ref(false)
const discoveryError = ref('')
const showDiscoverModal = ref(false)
// Signing-details disclosure: who signs the presence event (always the node's
// own discovery key) and a human-readable view of the signed content.
const showSigningInfo = ref(false)
const showPresenceSignModal = ref(false)
const nodeNpub = ref('')
const appVersion = computed(() => syncStore.serverInfo?.version || '')
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
async function loadNodeNpub() {
try {
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
if (res?.nostr_npub) nodeNpub.value = res.nostr_npub
} catch {
// Nostr key not provisioned yet panel shows a placeholder
}
}
const pendingRequests = ref<PendingPeerRequest[]>([])
const pollingHandshake = ref(false)
const pendingBusyId = ref<string | null>(null)
@@ -285,11 +391,19 @@ async function loadDiscoveryState() {
}
async function toggleDiscovery() {
if (!discoveryEnabled.value) {
// Enabling publishes a signed presence event surface the signer overlay
// first so the user sees exactly what gets signed and with which key.
// Nothing is published until they confirm.
if (!nodeNpub.value) loadNodeNpub()
showPresenceSignModal.value = true
return
}
// Disabling publishes nothing new apply immediately.
discoveryToggling.value = true
discoveryError.value = ''
const next = !discoveryEnabled.value
try {
const result = await rpcClient.nostrSetDiscovery(next)
const result = await rpcClient.nostrSetDiscovery(false)
discoveryEnabled.value = !!result.enabled
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
@@ -298,6 +412,20 @@ async function toggleDiscovery() {
}
}
async function confirmPresenceSign() {
discoveryToggling.value = true
discoveryError.value = ''
try {
const result = await rpcClient.nostrSetDiscovery(true)
discoveryEnabled.value = !!result.enabled
showPresenceSignModal.value = false
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to enable discoverability'
} finally {
discoveryToggling.value = false
}
}
async function loadPendingRequests() {
try {
const result = await rpcClient.federationListPendingRequests()
@@ -557,6 +685,7 @@ onMounted(async () => {
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
loadNodeNpub()
transportStore.fetchPeers()
try {
const result = await rpcClient.getNodeDid()
+2 -2
View File
@@ -125,7 +125,7 @@
{{ walletOnchainSats.toLocaleString() }} sats
</span>
</div>
<p class="text-xs text-white/45 mt-1">Zeus channels need 150,0001,500,000 sats.</p>
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
@@ -205,7 +205,7 @@
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
<ReceiveBitcoinModal
:show="showFundModal"
note="Fund your Lightning channel: Zeus channels need a minimum of 150,000 and a maximum of 1,500,000 sats."
note="Fund your Lightning channel: minimum 150,000 · maximum 1,500,000 on-chain sats required."
auto-generate
@close="showFundModal = false"
/>
+13 -8
View File
@@ -67,7 +67,7 @@
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<!-- My Apps Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 0">
<div data-controller-container tabindex="0" class="home-card controller-focusable order-1 lg:order-none" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 0">
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
@@ -101,7 +101,7 @@
</div>
<!-- Cloud Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 1">
<div data-controller-container tabindex="0" class="home-card controller-focusable order-3 lg:order-none" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 1">
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
@@ -127,6 +127,7 @@
<!-- Wallet Overview -->
<HomeWalletCard
class="order-2 lg:order-none"
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
@@ -136,6 +137,7 @@
:wallet-ark="walletArk"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-scan="showScanModal = true"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@@ -145,7 +147,7 @@
/>
<!-- Network Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
<div data-controller-container tabindex="0" class="home-card controller-focusable order-4 lg:order-none" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
@@ -191,7 +193,7 @@
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable lg:col-span-2"
class="home-card controller-focusable lg:col-span-2 order-5 lg:order-none"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 4"
>
@@ -238,7 +240,7 @@
<!-- Quick Start Goals -->
<div
v-if="showQuickStart"
class="home-card lg:col-span-2 transition-opacity duration-300"
class="home-card lg:col-span-2 transition-opacity duration-300 order-6 lg:order-none"
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
style="--card-stagger: 5"
>
@@ -264,6 +266,7 @@
<!-- System Stats -->
<HomeSystemCard
class="order-7 lg:order-none"
:animate="animateCards"
:loaded="systemStatsLoaded"
:stats="systemStats"
@@ -278,8 +281,9 @@
</div>
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
</div>
@@ -289,6 +293,7 @@
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import WalletScanModal from '@/components/WalletScanModal.vue'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import TransactionsModal from '@/components/TransactionsModal.vue'
@@ -517,7 +522,7 @@ onMounted(async () => {
})
// Wallet modals
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
const showScanModal = ref(false); const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
+10
View File
@@ -64,6 +64,7 @@
type="password"
autocomplete="new-password"
data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordSetup')"
@keydown.enter="confirmPasswordInputRef?.focus()"
@@ -83,6 +84,7 @@
type="password"
autocomplete="new-password"
data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.confirmPasswordPlaceholder')"
@keydown.enter="handleSetupWithSound"
@@ -127,6 +129,7 @@
pattern="[0-9]*"
maxlength="8"
autocomplete="one-time-code"
data-controller-no-submit
:aria-label="t('login.totpLabel')"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
@@ -165,6 +168,12 @@
🎮 Demo mode Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
</div>
<!-- All auth inputs opt out of controller-nav's Enterclick-next-button
pattern (data-controller-no-submit): they submit via their own Enter
handlers, and while the submit button is still disabled the "next
focusable" is Replay Intro the companion's auto-login injects
Enter before Vue re-enables the button, which replayed the intro
in a loop on every app connect. -->
<div class="mb-6">
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
{{ t('login.password') }}
@@ -175,6 +184,7 @@
type="password"
autocomplete="current-password"
data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordPlaceholder')"
@keydown.enter="handleLoginWithSound"
+14 -6
View File
@@ -691,20 +691,28 @@ video.bg-layer {
why the kiosk login/onboarding background still went black. Keep 2D
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
glitch overlays. */
:global(html.kiosk-mode) .bg-perspective-container,
:global(html.kiosk-mode) .perspective-container {
/* The full selector must live inside :global() with `:global(html.kiosk-mode)
.bg-layer` the SFC compiler drops the descendant part, emitting bare
`html.kiosk-mode { display: none !important }` rules that blank the whole
document on kiosk (the v1.7.104 white-screen). */
:global(html.kiosk-mode .bg-perspective-container),
:global(html.kiosk-mode .perspective-container) {
perspective: none !important;
}
:global(html.kiosk-mode) .bg-layer,
:global(html.kiosk-mode) .view-wrapper {
/* Scoped to the onboarding viewport: a bare `.view-wrapper` match also killed
the dashboard's route transitions on kiosk (transform:none !important beats
the transition classes), freezing every tab change. The dashboard has its
own kiosk-safe 2D transition overrides in dashboard-styles.css. */
:global(html.kiosk-mode .bg-layer),
:global(html.kiosk-mode .onb-viewport .view-wrapper) {
transform: none !important;
transform-style: flat !important;
backface-visibility: visible !important;
will-change: auto !important;
filter: none !important;
}
:global(html.kiosk-mode) .login-glitch-layer,
:global(html.kiosk-mode) .login-glitch-scan {
:global(html.kiosk-mode .login-glitch-layer),
:global(html.kiosk-mode .login-glitch-scan) {
display: none !important;
}
</style>
+9
View File
@@ -25,6 +25,15 @@
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
<p v-else class="text-sm text-white/50">Peer files</p>
</div>
<!-- Mobile: the title block above is hidden (the global header carries the
peer name), so the transport pill would vanish with it. Render it on
its own here so mobile also sees whether this peer is FIPS or Tor. -->
<span
v-if="transportPill"
:class="transportPill.cls"
:title="transportPill.title"
class="md:hidden text-xs px-2 py-0.5 rounded-full font-medium"
>{{ transportPill.label }}</span>
</div>
</div>
+1 -1
View File
@@ -146,7 +146,7 @@
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
<span class="text-white/80 text-sm">F*ck IPs Mesh</span>
</div>
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
</div>
@@ -18,10 +18,8 @@
let the app's own UI load instead of a loader stuck on top (B7). -->
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
</svg>
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
<p class="text-white/50 text-sm mb-5">
@@ -119,6 +117,7 @@
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = defineProps<{
appUrl: string
@@ -22,63 +22,41 @@
</svg>
</button>
<!-- Display mode selector -->
<div class="relative" ref="modeMenuRef">
<!-- Display mode: one-click switch -->
<div class="flex items-center gap-0.5">
<button
class="app-session-btn"
aria-label="Display mode"
title="Display mode"
@click="showModeMenu = !showModeMenu"
:class="{ 'app-session-btn-active': displayMode === 'panel' }"
aria-label="Right panel"
title="Right panel"
@click="$emit('setMode', 'panel')"
>
<!-- Panel icon -->
<svg v-if="displayMode === 'panel'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<!-- Overlay icon -->
<svg v-else-if="displayMode === 'overlay'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</button>
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'overlay' }"
aria-label="Over whole app"
title="Over whole app"
@click="$emit('setMode', 'overlay')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<!-- Fullscreen icon -->
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</button>
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'fullscreen' }"
aria-label="Open fullscreen"
title="Open fullscreen"
@click="$emit('setMode', 'fullscreen')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="menu-fade">
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 w-48 bg-black/90 border border-white/10 rounded-lg backdrop-blur-xl shadow-2xl overflow-hidden z-50">
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'panel' }"
@click="selectMode('panel')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<span>Right panel</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'overlay' }"
@click="selectMode('overlay')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<span>Over whole app</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'fullscreen' }"
@click="selectMode('fullscreen')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
<span>Open fullscreen</span>
</button>
</div>
</Transition>
</div>
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
@@ -98,7 +76,6 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import type { DisplayMode } from './appSessionConfig'
defineProps<{
@@ -107,7 +84,7 @@ defineProps<{
displayMode: DisplayMode
}>()
const emit = defineEmits<{
defineEmits<{
goBack: []
goForward: []
refresh: []
@@ -115,26 +92,4 @@ const emit = defineEmits<{
close: []
setMode: [mode: DisplayMode]
}>()
const showModeMenu = ref(false)
const modeMenuRef = ref<HTMLElement | null>(null)
function selectMode(mode: DisplayMode) {
showModeMenu.value = false
emit('setMode', mode)
}
function onClickOutside(e: MouseEvent) {
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
showModeMenu.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
@@ -33,7 +33,10 @@ describe('appSessionConfig', () => {
configurable: true,
})
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8083')
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
// manifest.yml) — assert against the manifest-generated value, which is
// exactly what this test exists to protect.
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8088')
})
it('does not treat service-only tcp ports as web launch surfaces', () => {
@@ -6,26 +6,29 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"archy-nbxplorer": 32838,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8083,
"did-wallet": 8088,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
"immich": 2283,
"indeedhub": 7778,
"jellyfin": 8096,
"lnd-ui": 18083,
"mempool": 4080,
"mempool-api": 8999,
"morphos-server": 8086,
"morphos-server": 8089,
"netbird": 8087,
"nextcloud": 8085,
"nostr-rs-relay": 18081,
"photoprism": 2342,
"pine": 10380,
"portainer": 9000,
"router": 8084,
"searxng": 8888,
"strfry": 8082,
"strfry": 8090,
"uptime-kuma": 3002,
"vaultwarden": 8082,
}
@@ -36,6 +39,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
"archy-nbxplorer": "NBXplorer",
"barkd": "Ark Wallet",
"bitcoin-core": "Bitcoin Core",
"bitcoin-knots": "Bitcoin Knots",
"bitcoin-ui": "Bitcoin UI",
@@ -45,24 +49,41 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"did-wallet": "Web5 DID Wallet",
"electrs-ui": "Electrs UI",
"electrumx": "ElectrumX",
"fedimint": "Fedimint",
"fedimint": "Fedimint Guardian",
"fedimint-clientd": "Fedimint Client",
"fedimint-gateway": "Fedimint Gateway",
"filebrowser": "File Browser",
"fips-ui": "FIPS Mesh",
"gitea": "Gitea",
"grafana": "Grafana",
"homeassistant": "Home Assistant",
"immich": "Immich",
"immich-postgres": "Immich Postgres",
"immich-redis": "Immich Redis",
"indeedhub": "IndeeHub",
"indeedhub-api": "IndeedHub API",
"indeedhub-ffmpeg": "IndeedHub FFmpeg Worker",
"indeedhub-minio": "IndeedHub MinIO",
"indeedhub-postgres": "IndeedHub Postgres",
"indeedhub-redis": "IndeedHub Redis",
"indeedhub-relay": "IndeedHub Nostr Relay",
"jellyfin": "Jellyfin",
"lightning-stack": "Lightning Stack",
"lnd": "LND",
"lnd-ui": "LND UI",
"mempool": "Mempool Explorer",
"mempool-api": "Mempool API",
"meshtastic": "Meshtastic",
"morphos-server": "MorphOS Server",
"netbird": "NetBird",
"netbird-dashboard": "NetBird Dashboard",
"netbird-server": "NetBird Server",
"nextcloud": "Nextcloud",
"nostr-rs-relay": "Nostr Relay (Rust)",
"photoprism": "PhotoPrism",
"pine": "Pine",
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
"pine-piper": "Pine Piper (TTS)",
"pine-whisper": "Pine Whisper (STT)",
"portainer": "Portainer",
"router": "Mesh Router",
"searxng": "SearXNG",
@@ -76,8 +97,10 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
"gitea",
"grafana",
"homeassistant",
"immich",
"nextcloud",
"photoprism",
"pine",
"portainer",
"uptime-kuma",
"vaultwarden",
+6 -1
View File
@@ -26,6 +26,10 @@ export const SERVICE_NAMES = new Set([
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
// reached by Home Assistant over host.containers.internal — the user-facing
// card is "pine" (the setup/status launcher), so the engines go to Services.
'pine-whisper', 'pine-piper', 'pine-openwakeword',
])
const INTERNAL_TOOLING_NAMES = new Set([
@@ -61,7 +65,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home', 'pine': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
@@ -212,6 +216,7 @@ const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
['immich-', '/assets/img/app-icons/immich.png'],
['immich_', '/assets/img/app-icons/immich.png'],
['pine-', '/assets/img/app-icons/pine.svg'],
]
function serviceParentIcon(id: string): string | undefined {
@@ -756,6 +756,38 @@ html.kiosk-mode .dashboard-view .bg-layer {
transition: opacity 0.45s ease;
}
/* Kiosk: keep tab changes animated with cheap 2D moves. The 3D depth
transitions (translateZ + blur + preserve-3d) drop frames / paint black
under the software compositor, and the onboarding-era blanket
`transform: none` on .view-wrapper killed tab motion entirely. Plain 2D
scale + opacity composites fine there. */
html.kiosk-mode .depth-forward-enter-active.view-wrapper,
html.kiosk-mode .depth-forward-leave-active.view-wrapper,
html.kiosk-mode .depth-back-enter-active.view-wrapper,
html.kiosk-mode .depth-back-leave-active.view-wrapper {
transition: transform 0.45s ease, opacity 0.45s ease;
transform-style: flat;
backface-visibility: visible;
filter: none !important;
}
html.kiosk-mode .depth-forward-enter-from.view-wrapper,
html.kiosk-mode .depth-back-leave-to.view-wrapper {
transform: scale(0.94);
filter: none !important;
}
html.kiosk-mode .depth-forward-leave-to.view-wrapper,
html.kiosk-mode .depth-back-enter-from.view-wrapper {
transform: scale(1.05);
filter: none !important;
}
html.kiosk-mode .depth-forward-enter-to.view-wrapper,
html.kiosk-mode .depth-forward-leave-from.view-wrapper,
html.kiosk-mode .depth-back-enter-to.view-wrapper,
html.kiosk-mode .depth-back-leave-from.view-wrapper {
transform: scale(1);
filter: none !important;
}
/* Background glitch effect layers - World Fair style */
.bg-glitch-layer-1,
.bg-glitch-layer-2,
@@ -0,0 +1,264 @@
<template>
<Teleport to="body">
<Transition name="presence-sign">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop frosted blur, same treatment as the app signer overlay -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-label="Sign and publish node presence"
class="relative z-10 w-full max-w-lg flex flex-col min-h-0 max-h-full"
>
<!-- Header: glass disc + radial viz ring, mirroring NostrIdentityPicker -->
<div class="relative mb-4 sm:mb-6 flex flex-col items-center shrink-0">
<div class="presence-hero">
<div class="presence-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="presence-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<div class="presence-glass-border">
<div class="presence-glass-inner">
<svg class="w-9 h-9 text-white/90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
</div>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Sign &amp; Publish Presence</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr node discovery</p>
</div>
<!-- Scrollable content: on short viewports (small phones) this card
shrinks and scrolls internally so the action buttons stay in view -->
<div class="glass-card p-4 space-y-4 overflow-y-auto min-h-0 overscroll-contain">
<!-- Signer: single locked row, same look as the identity picker rows.
Node discovery always signs with the node's own key never a
personal identity so there is deliberately nothing to pick. -->
<div>
<div class="text-[10px] uppercase tracking-wide text-white/40 mb-1.5">Signer</div>
<div class="w-full p-3 rounded-lg bg-white/10 ring-1 ring-white/20">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
<span class="text-sm font-bold">{{ (serverName || 'N').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ serverName || 'This node' }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
</div>
<div class="mt-0.5">
<span class="text-white/35 text-xs font-mono truncate">{{ npub ? truncateNpub(npub) : 'loading…' }}</span>
</div>
</div>
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1.5">
Presence events are always signed with this node's dedicated discovery key
your personal identities are never used, so there is nothing to choose.
</p>
</div>
<!-- Human-readable view of the exact content that gets signed -->
<div>
<div class="text-[10px] uppercase tracking-wide text-white/40 mb-1.5">What gets signed &amp; published</div>
<dl class="text-xs space-y-1.5">
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
<dd class="text-white/80 font-mono break-all">{{ did || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
<dd class="text-white/80 font-mono break-all">{{ npub || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
<dd class="text-white/80">{{ version || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
</div>
</dl>
<p class="text-[11px] text-white/40 mt-2">
Every presence event carries a Schnorr signature (NIP-01) made with the key
above relays reject unsigned events. Your onion address is never part of
this event; it is only shared over encrypted DMs (NIP-44) after you approve
a peer.
</p>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4 shrink-0">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
Cancel
</button>
<button
@click="$emit('confirm')"
:disabled="busy"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 bg-white/10 text-white hover:bg-white/15 disabled:opacity-30 disabled:cursor-not-allowed"
>
{{ busy ? 'Publishing…' : 'Sign & Publish' }}
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest shrink-0">
NIP-01 &middot; SECP256K1 &middot; SCHNORR &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = defineProps<{
show: boolean
serverName: string
npub: string
did: string
version: string
busy?: boolean
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
</script>
<style scoped>
/* Hero container — same dimensions/pattern as the NostrIdentityPicker overlay */
.presence-hero {
position: relative;
width: 148px;
height: 148px;
}
/* Small phones: shrink the hero so the signed-info card keeps most of the
viewport; the card itself scrolls internally when it still doesn't fit. */
@media (max-width: 480px), (max-height: 740px) {
.presence-hero {
width: 96px;
height: 96px;
transform: scale(0.9);
}
.presence-viz-segment {
--seg-radius: -40px;
height: 10px;
}
.presence-glass-border {
width: 72px;
height: 72px;
}
}
.presence-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.presence-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px));
animation: presence-seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
@keyframes presence-seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
}
.presence-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.presence-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* Modal transitions — same curve as the identity picker */
.presence-sign-enter-active,
.presence-sign-leave-active {
transition: opacity 0.4s ease;
}
.presence-sign-enter-active > .relative {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.presence-sign-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.presence-sign-enter-from { opacity: 0; }
.presence-sign-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.presence-sign-leave-to { opacity: 0; }
.presence-sign-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>
+23 -6
View File
@@ -122,7 +122,11 @@
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
<!-- Inline SVG, not an emoji: kiosk images ship no color-emoji
font, so 🥜 rendered as a tofu box on TVs -->
<svg class="w-5 h-5 text-purple-400" role="img" aria-label="Cashu" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span class="text-sm text-white/80">Cashu</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
@@ -139,25 +143,37 @@
<!-- Only rendered once barkd reports a balance most nodes don't run the Ark sidecar -->
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Ark"></span>
<svg class="w-5 h-5 text-teal-400" role="img" aria-label="Ark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8a2.5 2.5 0 10-2.5-2.5A2.5 2.5 0 0012 8zm0 0v13m-7-7c0 3.87 3.13 7 7 7s7-3.13 7-7m-14 0H3m18 0h-2" />
</svg>
<span class="text-sm text-white/80">Ark</span>
</div>
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
</div>
</div>
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<button @click="$emit('showSend')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('common.send') }}
</button>
<button @click="$emit('showReceive')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
<button @click="$emit('showReceive')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Receive
</button>
<button @click="$emit('showTransactions')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
<button @click="$emit('showTransactions')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Transactions
</button>
<button v-if="isDev" @click="$emit('faucet')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
Faucet
</button>
<button
@click="$emit('showScan')"
aria-label="Scan QR to send"
title="Scan QR to send"
class="home-card-btn px-3 py-2 glass-button rounded-lg transition-colors shrink-0 flex items-center justify-center"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
</button>
</div>
</div>
</div>
@@ -197,6 +213,7 @@ const props = defineProps<{
}>()
defineEmits<{
showScan: []
showSend: []
showReceive: []
showTransactions: []
+125 -15
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { LORA_REGIONS, regionByCode, meshcorePlanFor } from '@/utils/loraRegions'
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS } from '@/utils/loraRegions'
const mesh = useMeshStore()
@@ -27,6 +27,12 @@ const form = ref({
channel: 'archipelago',
name: '',
broadcastIdentity: true,
// MeshCore LoRa PHY params (human units; converted to firmware units on
// save). All four empty = leave the radio's flashed settings untouched.
rfFreqMhz: '',
rfBwKhz: '',
rfSf: '',
rfCr: '',
})
const saving = ref(false)
const saveError = ref<string | null>(null)
@@ -43,10 +49,54 @@ watch(
form.value.deviceKind = s.device_kind ?? 'auto'
form.value.channel = s.channel_name || 'archipelago'
form.value.name = s.self_advert_name ?? ''
const rp = (s as Record<string, unknown>).lora_radio_params as
| { freq_khz: number; bw_hz: number; sf: number; cr: number }
| null
| undefined
if (rp) {
applyingPreset = true
form.value.rfFreqMhz = String(rp.freq_khz / 1000)
form.value.rfBwKhz = String(rp.bw_hz / 1000)
form.value.rfSf = String(rp.sf)
form.value.rfCr = String(rp.cr)
// Show the matching named preset if the stored values are one; else Custom.
const match = MESHCORE_RF_PRESETS.find(
(p) =>
Math.round(p.freqMhz * 1000) === rp.freq_khz &&
Math.round(p.bwKhz * 1000) === rp.bw_hz &&
p.sf === rp.sf &&
p.cr === rp.cr,
)
rfPreset.value = match ? match.id : 'custom'
applyingPreset = false
}
},
{ immediate: true },
)
// RF preset dropdown: picking a named plan fills the four fields; hand-editing
// any field flips the dropdown to Custom so it never misrepresents the values.
const rfPreset = ref('')
let applyingPreset = false
function selectRfPreset(id: string) {
rfPreset.value = id
if (id === 'custom' || id === '') return
const p = MESHCORE_RF_PRESETS.find((x) => x.id === id)
if (!p) return
applyingPreset = true
form.value.rfFreqMhz = String(p.freqMhz)
form.value.rfBwKhz = String(p.bwKhz)
form.value.rfSf = String(p.sf)
form.value.rfCr = String(p.cr)
applyingPreset = false
}
watch(
() => [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr],
() => {
if (!applyingPreset && rfPreset.value && rfPreset.value !== 'custom') rfPreset.value = 'custom'
},
)
const selectedRegion = computed(() => regionByCode(form.value.region))
const deviceType = computed(() => mesh.status?.device_type ?? 'unknown')
// Firmware whose options apply: explicit pin wins, else the connected type.
@@ -62,12 +112,39 @@ async function saveSettings() {
saveError.value = null
saveDone.value = false
try {
// MeshCore RF params: a named preset sends its plan; Custom sends the
// four fields (all required partial input is an error); "Keep the
// radio's current settings" omits the key so the config is untouched.
let rfParams: { freq_khz: number; bw_hz: number; sf: number; cr: number } | undefined
if (rfPreset.value && rfPreset.value !== 'custom') {
const p = MESHCORE_RF_PRESETS.find((x) => x.id === rfPreset.value)
if (p) {
rfParams = {
freq_khz: Math.round(p.freqMhz * 1000),
bw_hz: Math.round(p.bwKhz * 1000),
sf: p.sf,
cr: p.cr,
}
}
} else if (rfPreset.value === 'custom') {
const rf = [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr]
if (!rf.every((v) => String(v).trim() !== '')) {
throw new Error('Fill in all four RF fields (frequency, bandwidth, SF, CR)')
}
rfParams = {
freq_khz: Math.round(parseFloat(form.value.rfFreqMhz) * 1000),
bw_hz: Math.round(parseFloat(form.value.rfBwKhz) * 1000),
sf: parseInt(form.value.rfSf, 10),
cr: parseInt(form.value.rfCr, 10),
}
}
await mesh.configure({
lora_region: form.value.region,
device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
broadcast_identity: form.value.broadcastIdentity,
...(rfParams ? { lora_radio_params: rfParams } : {}),
})
saveDone.value = true
setTimeout(() => { saveDone.value = false }, 3000)
@@ -93,18 +170,6 @@ async function saveSettings() {
<span class="mesh-stat-label">Node ID</span>
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Name</span>
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Region (radio)</span>
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Channel</span>
<span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Type</span>
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
@@ -128,10 +193,10 @@ async function saveSettings() {
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
</p>
<p v-else-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} the radio's flashed RF settings apply; adjust via a MeshCore client if they differ.
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} set the RF fields below to program the radio.
</p>
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
MeshCore radios keep their flashed RF settings verify the radio matches your region's band{{ selectedRegion ? ` (${selectedRegion.band} MHz)` : '' }}.
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
@@ -158,6 +223,51 @@ async function saveSettings() {
<input v-model="form.name" maxlength="24" placeholder="node name" 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" />
</div>
</div>
<!-- MeshCore LoRa PHY params every radio on the local mesh must match
or it hears RF energy but decodes nothing. Empty = leave the radio's
flashed settings untouched. -->
<div v-if="effectiveKind === 'meshcore'" class="mt-4">
<h5 class="text-xs font-semibold text-white/80 mb-2">MeshCore RF parameters</h5>
<div class="mb-3">
<label class="block text-xs text-white/60 mb-1">Frequency plan</label>
<select :value="rfPreset" 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" @change="selectRfPreset(($event.target as HTMLSelectElement).value)">
<option value="">Keep the radio's current settings</option>
<option v-for="p in MESHCORE_RF_PRESETS" :key="p.id" :value="p.id">{{ p.label }}</option>
<option value="custom">Custom</option>
</select>
</div>
<div v-if="rfPreset === 'custom'" class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (MHz)</label>
<input v-model="form.rfFreqMhz" inputmode="decimal" placeholder="869.618" 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" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Bandwidth (kHz)</label>
<select v-model="form.rfBwKhz" 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=""></option>
<option v-for="bw in ['62.5', '125', '250', '500']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="form.rfSf" 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=""></option>
<option v-for="sf in [5, 6, 7, 8, 9, 10, 11, 12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="form.rfCr" 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=""></option>
<option v-for="cr in [5, 6, 7, 8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
</p>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
@@ -8,7 +8,7 @@
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
<h2 class="text-xl font-semibold text-white">F*ck IPs Mesh</h2>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
@@ -362,6 +362,125 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.111-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.111-alpha</span>
<span class="text-xs text-white/40">July 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.</p>
<p>Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.)</p>
<p>Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.</p>
<p>Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.</p>
<p>The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.</p>
<p>Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.</p>
<p>Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.</p>
<p>Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.</p>
<p>Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.</p>
<p>On the phone home screen, the wallet card moved up to sit right under My Apps.</p>
<p>Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably.</p>
</div>
</div>
<!-- v1.7.110-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.110-alpha</span>
<span class="text-xs text-white/40">July 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.</p>
<p>The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.</p>
<p>You can now choose how big the interface renders on your node's attached screen: Settings Display offers Auto (recommended), Large UI, Balanced, and Native changing it applies immediately.</p>
<p>The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.</p>
<p>The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).</p>
<p>Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.</p>
<p>The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."</p>
<p>Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.</p>
</div>
</div>
<!-- v1.7.109-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.109-alpha</span>
<span class="text-xs text-white/40">July 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.</p>
<p>Your node can now program its MeshCore radio's RF settings frequency, bandwidth, spreading factor, and coding rate from Mesh Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.</p>
<p>The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.</p>
</div>
</div>
<!-- v1.7.108-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.108-alpha</span>
<span class="text-xs text-white/40">July 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.</p>
<p>Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen it failed with a permissions error because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.</p>
<p>Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.</p>
<p>The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).</p>
<p>More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down hold it to power off on purpose.</p>
<p>Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.</p>
</div>
</div>
<!-- v1.7.106-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.106-alpha</span>
<span class="text-xs text-white/40">July 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.</p>
<p>On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.</p>
<p>Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.</p>
<p>When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.</p>
<p>Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.</p>
</div>
</div>
<!-- v1.7.105-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.105-alpha</span>
<span class="text-xs text-white/40">July 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.</p>
<p>Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.</p>
<p>Fixed the white screen some laptop displays showed right after the intro on v1.7.104.</p>
<p>The companion phone app no longer suggests installing the companion app from inside itself.</p>
<p>The Tor page now lists onion addresses only for apps you actually have installed fresh installs no longer come with six pre-made addresses for apps that were never set up.</p>
<p>Running archipelago --version or --help on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.</p>
<p>Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.</p>
</div>
</div>
<!-- v1.7.104-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.104-alpha</span>
<span class="text-xs text-white/40">July 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.</p>
<p>If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself no manual rescue needed.</p>
<p>The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.</p>
<p>While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.</p>
</div>
</div>
<!-- v1.7.103-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.103-alpha</span>
<span class="text-xs text-white/40">July 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.</p>
<p>Pressing Enter in any password box now does what you expect it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.</p>
<p>The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro demo visitors simply get the newest version on their next visit.</p>
</div>
</div>
<!-- v1.7.102-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">

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