Compare commits

...

56 Commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:46:51 -04:00
42e98d6a86 Merge pull request 'fix(ci): demo-images path filter watched .github, file lives in .gitea' (#105) from fix/demo-images-path-filter into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m4s
2026-07-23 20:30:13 +00:00
3e5feebd49 fix(ci): demo-images path filter watched .github, file lives in .gitea
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:29:48 +00:00
archipelago
4199c43cc9 Merge gitea-ai/main — companion native-scan PR #104 (newer scanner rev wins in WalletScanModal)
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:25:02 -04:00
archipelago
54ddd043bd feat(tv-input): gamepad->keyboard bridge daemon + controller-navigable modals
- archipelago-gamepad-keys: evdev->uinput daemon (pure python3 stdlib) that
  mirrors any attached controller as a virtual keyboard — D-pad/stick to
  arrows, A/B to Enter/Escape, X/Y to Space/f, shoulders to Tab/Shift+Tab.
  The browser sees real keys, so controllers work inside EVERY app iframe
  (IndeeHub, Jellyfin…) with zero per-app code. Ships via the ISO splice and
  bootstrap self-heal (kiosk nodes only), hotplug via 5s rescans.
  Implements docs/tv-input-iframe-apps.md.
- useControllerNav: an open [role=dialog][aria-modal] owns navigation —
  focus is pulled inside, arrows move spatially between its controls, Enter
  activates, Escape stays with the modal's own close handling. One standard
  mapping for every modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:17:11 -04:00
archipelago
402183c0ae feat(appsession): mode switches never reload the iframe + strictly per-app display modes + TV focus
- The session subtree now ALWAYS lives under <body>; inline panel mode is
  emulated with a fixed-position rect synced from the layout placeholder
  (ResizeObserver + resize), so toggling panel/overlay/fullscreen no longer
  re-parents — and therefore no longer reloads — the app iframe.
- Display modes are strictly per-app: saved per-app pick → app default
  (IndeeHub: fullscreen) → panel. The global fallback is gone, so one app's
  mode can never change how another opens.
- The iframe takes focus when the app loads, so keyboard / the gamepad
  bridge work inside it immediately (TV).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:15:43 -04:00
archipelago
80875a2ed4 feat(wallet): invoice-first lightning send — amount locks from the pasted invoice
A pasted fixed-amount invoice auto-fills and locks the amount ('set by
invoice' — nothing to type, just review and send); zero-amount invoices get
an explicit 'enter how many sats' hint. Plus a clipboard Paste button on the
destination field (hidden where clipboard read can't work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:15:43 -04:00
archipelago
f72d4b92ac feat(content): seller-picked payment methods + music always in the bottom bar + video PiP
- Paid sharing: AccessControl::Paid gains an accepted-methods list
  (lightning/onchain/ecash/fedimint; empty = all, back-compat). Sellers pick
  methods in ShareModal, gated on what the node can actually receive (LND
  running/channel open, ecash wallet, fedimint joined) with an ⓘ that
  explains exactly how to enable a missing rail. Enforced server-side (the
  invoice/onchain mints refuse non-accepted methods; the serve gate only
  honors tokens/hashes for accepted rails) and the buyer's pay modal only
  offers what the seller accepts.
- Purchased music: the two remaining lightbox paths now use the bottom-bar
  player — the immediate post-ecash-purchase viewer and the Paid Files
  tab's window.open.
- Picture-in-picture buttons on the peer video player and the cloud media
  lightbox (utils/pip.ts; Chromium/Safari, no-op elsewhere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:15:11 -04:00
5d2c28da7b Merge pull request 'Companion: three-finger menu gesture, native wallet QR scanner, scan/upload chooser' (#104) from feat/companion-3finger-native-scan into main
Some checks failed
Demo images / Build & push demo images (push) Failing after 33s
2026-07-23 20:01:08 +00:00
Dorian
fbed32f95d feat(wallet): hand live scanning to the companion's native camera when present
The scan/upload chooser (already on main) keeps the first move; when the
Android companion's window.ArchipelagoQr bridge exists, 'Scan with
camera' opens the native CameraX modal instead of getUserMedia — which
the companion's plain-http origin doesn't even have, so the button now
shows there too. Decodes come back via window.__archyQrResult, status
lines mirror out via setStatus, and stopScanning closes the native
modal once a code is accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:00:28 +01:00
archipelago
d5fc3d01a4 Merge companion native-scan branch — scan/upload choose pane supersedes today's interstitial
Another agent's feat/companion-3finger-native-scan: native wallet QR scanner
in the companion (WalletQrScannerModal.kt), three-finger menu gesture,
WebView file uploads, and the same every-open scan/upload chooser as a
dedicated pane with native-scanner handoff. Conflict in WalletScanModal.vue
resolved in the branch's favor — its choose-pane implementation replaces the
in-pane interstitial from 6502f13e (same UX, plus the native handoff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:58:17 -04:00
Dorian
87c324e36c feat(companion): native wallet QR scanner, gesture hint overlay, WebView file uploads
- WalletQrScannerModal: native CameraX+zxing scanner styled like the web
  wallet modal, with an upload-image decode path; opened by the web UI
  through the new window.ArchipelagoQr JS bridge. Decodes stream back via
  window.__archyQrResult; the page mirrors status lines out and closes
  the modal when it accepts a code. Detection/spend logic stays in the
  web modal.
- onShowFileChooser: <input type=file> was silently ignored by the
  WebView — file pickers (incl. the wallet's upload option) now work.
- GestureHintOverlay: one-time animation teaching the three-finger hold,
  armed ~2 minutes after login (gesture_hint_seen flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:56:01 +01:00
Dorian
15954aec14 fix(companion): smooth camera preview + no flash on scanner open
ZXing TRY_HARDER (plus the inverted retry) ran on every camera frame,
pegging a core — that CPU contention is what made the preview stutter.
Decode attempts are now gated to ~7/s; KEEP_ONLY_LATEST drops the rest.
PreviewView switches to TextureView (COMPATIBLE): the SurfaceView
default punches a window hole that black-flashes inside Compose fades
and ignores rounded-corner clipping. CameraQrPreview is now shared with
the wallet scan modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:56:01 +01:00
Dorian
1df075f7b1 feat(companion): menu gesture is now a three-finger hold
Two-finger hold collided with two-finger scrolling — any scroll longer
than 500ms popped the NESMenu. Three fingers hold for the menu; two
fingers scroll, on the trackpad, both controllers and the gamepad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:56:01 +01:00
Dorian
ff4013bd3b feat(wallet): scan/upload chooser on every open + native scanner handoff
The scan modal now always lands on a chooser pane — Scan with camera /
Upload image / paste — instead of auto-starting the camera. When the
Android companion's ArchipelagoQr bridge is present, live scanning is
delegated to the native camera modal (WebView getUserMedia lags);
animated-QR progress and errors mirror onto it via setStatus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:52:42 +01:00
Dorian
37c8992d21 feat(companion): native wallet QR scanner, gesture hint overlay, WebView file uploads
- WalletQrScannerModal: native CameraX+zxing scanner styled like the web
  wallet modal, with an upload-image decode path; opened by the web UI
  through the new window.ArchipelagoQr JS bridge. Decodes stream back via
  window.__archyQrResult; the page mirrors status lines out and closes
  the modal when it accepts a code. Detection/spend logic stays in the
  web modal.
- onShowFileChooser: <input type=file> was silently ignored by the
  WebView — file pickers (incl. the wallet's upload option) now work.
- GestureHintOverlay: one-time animation teaching the three-finger hold,
  armed ~2 minutes after login (gesture_hint_seen flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:52:42 +01:00
Dorian
1ecb5c9aa3 fix(companion): smooth camera preview + no flash on scanner open
ZXing TRY_HARDER (plus the inverted retry) ran on every camera frame,
pegging a core — that CPU contention is what made the preview stutter.
Decode attempts are now gated to ~7/s; KEEP_ONLY_LATEST drops the rest.
PreviewView switches to TextureView (COMPATIBLE): the SurfaceView
default punches a window hole that black-flashes inside Compose fades
and ignores rounded-corner clipping. CameraQrPreview is now shared with
the wallet scan modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:51:57 +01:00
Dorian
5bae8273a2 feat(companion): menu gesture is now a three-finger hold
Two-finger hold collided with two-finger scrolling — any scroll longer
than 500ms popped the NESMenu. Three fingers hold for the menu; two
fingers scroll, on the trackpad, both controllers and the gamepad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:51:49 +01:00
archipelago
265196bc2c docs: TV input design — keyboard/gamepad inside iframe apps, globally
Some checks failed
Demo images / Build & push demo images (push) Failing after 36s
evdev->uinput gamepad-as-keyboard daemon on kiosk nodes (works in any
iframe, any origin) + shell-side focus handoff via useControllerNav;
CDP/per-app injection rejected. Implementation order included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:31:03 -04:00
archipelago
9f1daa0f8c feat(ui): per-app display-mode defaults (IndeeHub fullscreen) + gamepad plays media + node list scales
- Per-app default display mode: explicit user pick (remembered per app) ->
  app default -> last global -> panel; IndeeHub defaults to fullscreen.
- data-controller-primary: a media file card's gamepad-select clicks the
  card (audio -> bottom-bar player, video -> lightbox) instead of the
  card's download link.
- Discovered-nodes list scales with the viewport (max(14rem,45vh)) instead
  of cramming a long list into a fixed 224px box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:31:02 -04:00
archipelago
960e41e164 fix(content): audio always plays in the bottom-bar player + ecash purchase is explicit two-step
- Owned/purchased and cloud audio never opens a lightbox: PeerFiles'
  owned-content viewer and the Cloud/CloudFolder preview route audio to the
  global bottom-bar player (video keeps the lightbox).
- Web5 shared-content 'Buy' no longer fires the node-ecash payment on
  click: it opens a confirmation with balance -> price -> balance-after,
  and pays only on explicit confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:31:01 -04:00
archipelago
6502f13e29 feat(wallet): paste-send confirmation step + every-time scan/upload chooser
- Pasting an invoice/address now gets the same second-step confirmation the
  scan flow has: method, destination, live balance -> amount -> balance
  after (invoice-fixed amounts win over the typed one), for every rail
  including token minting. Nothing pays until Confirm & Send.
- The scan modal opens on a chooser every time: scan with camera or
  upload/take a photo of the QR — the camera no longer auto-starts, and
  Back only relights it if camera was the user's choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:42 -04:00
archipelago
c58f84c6e9 feat(companion): npub-first pairing — node self-anchor + guaranteed public anchor in the QR
The pairing QR was effectively IP-first: the phone dialed the scanned LAN
host and went dark when the LAN renumbered or it left home. FIPS peers on
npubs; IPs are only dial hints. fanchors now leads with the paired node
ITSELF (fnpub @ current host, npub-keyed so LAN contact is direct p2p over
FIPS and survives DHCP), and fips.pair-info always includes the Archipelago
vps2 public anchor (pairing hint only — the node's own anchor file is not
modified) so the phone can rendezvous through the public mesh when away.
Contract doc updated with the REQUIRED app-side changes (built on the Mac).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:40 -04:00
archipelago
e59ea1da69 fix(mesh): never show mojibake device names — strict decode with readable fallback
Name fields sit at firmware-version-dependent offsets; when an offset lands
in binary data, from_utf8_lossy handed the UI replacement-character soup in
the mesh modals and settings panel. decode_mesh_name(): strict UTF-8 to the
first NUL, no control chars, else a readable fallback (short pubkey /
node-<id>).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 -04:00
archipelago
be6f06a573 fix(kiosk): overlay scrollbars — no more fat X11 scrollbar on scrollable views
--enable-features=OverlayScrollbar gives the thin auto-hiding scrollbars a
remote browser shows (Peers view had a permanent classic scrollbar on the
TV). Rides the existing kiosk self-heal + ISO splice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 -04:00
archipelago
8a450bb8f8 feat(audio): HDMI audio works out of the box — pipewire in ISO + router daemon + ELD boot-race heal
Root cause of silent HDMI on kiosk TVs (framework-pt, LG TV):
1) fresh ISOs installed NO audio stack even though the kiosk launcher
   expects PipeWire-Pulse — add pipewire/pipewire-pulse/pipewire-alsa/
   wireplumber/alsa-utils to the rootfs and put the archipelago user in
   'audio' (linger user manager gets no logind seat ACLs on /dev/snd);
2) the kiosk's boot-time Xorg modeset races the i915→HDA audio-component
   bind, the ELD notify is lost, and every HDMI profile stays
   'available: no' forever. Verified live: one off/on modeset re-delivers
   the ELD and WirePlumber immediately switches to HDMI.

New archipelago-audio-router daemon (same splice-from-configs pattern as
the kiosk, ISO + include_str! self-heal): routes the card to the best
available HDMI profile, keeps the default sink on it, migrates live
streams on hot-plug, unmutes (HDMI forced 100% — the TV owns volume), and
re-modesets a connected external output once when no ELD reports a
monitor. bootstrap::ensure_audio_stack() installs packages + router on
already-deployed kiosk nodes via OTA (apt through systemd-run — the
service sandbox keeps /usr and dpkg read-only). main.rs also spawns the
pine satellite keeper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:28:47 -04:00
archipelago
852ffc5b18 feat(pine): wyoming satellite keeper — re-point speaker entries when DHCP strands them
HA stores a voice satellite as a pinned LAN IP and never re-resolves; when
the LAN renumbers (framework-pt: entry at 192.168.1.241, LAN now
192.168.63.0/24) the speaker silently drops forever. A periodic keeper
probes IP-pinned wyoming entries, sweeps the node's local /24s for the
satellite's port when one dies, rewrites core.config_entries (HA stopped
around the edit so its shutdown flush can't clobber it) and starts HA again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:28:07 -04:00
archipelago
db6003aef6 fix(lightning): actionable expired-invoice error + payment failures reach the user
- payments.rs: an expired invoice can never succeed on retry, so the
  error now says so and tells the payer the way out (ask the recipient
  for a fresh invoice) instead of echoing LND's raw reason.
- middleware.rs: the error sanitizer masked every Lightning payment
  failure as 'Operation failed. Check server logs' — but LND's reasons
  ('invoice expired', 'unable to find a path') are written for the
  payer and are all actionable. 'Payment failed', 'Invalid payment
  request' and 'Missing payment_request' now pass through, with a
  regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 04:51:57 -04:00
archipelago
b991c18e73 fix(aiui): color-scheme meta keeps iframe transparent so background art survives dark shell
Some checks failed
Demo images / Build & push demo images (push) Failing after 34s
Browsers only honor a transparent same-origin iframe canvas when the
embedder and the iframe agree on color-scheme; the neode-ui shell's
:root{color-scheme:dark} was forcing the AIUI frame opaque and hiding
the background art behind the chat.

Also adds neode-ui/vite.preview.config.mts (preview on :8100 against
the mock backend on :5959).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 04:43:42 -04:00
archipelago
fb72e3e159 fix(fedimint): gateway/guardian follow the running bitcoin container via {{BITCOIN_HOST}}
The gateway crash-looped dialing bitcoind at host.archipelago:8332 (the
host gateway IP, where bitcoind does not listen). Root-cause fix, three
parts:

- fedimint-gateway manifest: --bitcoind-url now comes from
  $FM_BITCOIND_URL, filled by a {{BITCOIN_HOST}} derived-env — works on
  Knots, Core, or any future Bitcoin distro.
- fedimint manifest: same derived-env replaces the hardcoded
  FM_BITCOIND_URL=http://bitcoin-knots:8332 environment entry.
- orchestrator: removed the hardcoded FM_BITCOIND_URL=bitcoin-knots
  override that clobbered the derived-env, and bitcoin_host() now
  accepts any running bitcoin*/bitcoin container (excluding -ui and
  archy-* sidecars), preferring the known names in order.

Catalog regenerated + signed (nodes install from the signed catalog, so
the manifest fixes only reach them via this regen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:07:15 -04:00
archipelago
f339358109 fix(content): double-pay is now impossible + purchases auto-file + Paid Files tab
Some checks failed
Demo images / Build & push demo images (push) Failing after 32s
The double-share/double-pay chain (user hit it live, 2026-07-22):
ShareModal's already-shared lookup compared the slash-stripped stored
path against the leading-slash filepath — never matched — so every
re-share minted a NEW catalog entry with a new id, and the buyer's
owned-guard (keyed by id) saw the duplicate as unowned and paid again.
Three independent walls now: (1) ShareModal normalizes both sides so
re-shares reuse the entry; (2) content_server::add_item dedupes by
filename server-side (updates in place, keeps the id so buyers' owned
records stay valid); (3) the buyer REFUSES to pay for content it
already owns — matched by (onion, content_id) OR (onion, filename) —
and serves the cached copy instead, before any ecash is minted.

Purchases also auto-file into Photos/Music/Documents on the node (same
buckets as the Cloud view, collision-safe naming) so bought files show
up where files live on every device, and Cloud gains a Paid Files tab
listing every purchase (name, size, sats paid, date) with in-app view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:10:48 -04:00
archipelago
df9b9905b6 feat(wallet): LND wedge watchdog + Fedi send rail + token QRs + Auto hidden
Some checks failed
Demo images / Build & push demo images (push) Failing after 33s
- LND watchdog: framework-pt's LND sat wedged 14h (RPC up, server never
  ready, channels inactive) with nothing noticing. 15 consecutive bad
  minutes now bounce the container automatically (30min cooldown) —
  silent multi-hour Lightning outages become self-healed blips.
- Send modal: Auto tab hidden; Ecash splits into Cashu and Fedi (new
  wallet.fedimint-send RPC wrapping the existing spend_from_any); both
  rails render the generated token as a scannable QR alongside the
  copyable text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:55:37 -04:00
archipelago
e68fe071a7 docs: v1.7.112-alpha changelog + What's New sync + flasher integration point
Some checks failed
Demo images / Build & push demo images (push) Failing after 32s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:40:05 -04:00
archipelago
04876f3bef feat(iso): bundle archy-reticulum-daemon — RNode radios work on fresh images
The mesh listener spawns /usr/local/bin/archy-reticulum-daemon for
Reticulum sticks, but the ISO never shipped it (framework-pt connected
its RNode only after a hand-copy, 2026-07-22). Bundled from the build
host with a loud warning when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:30:37 -04:00
archipelago
05fc49820c fix(rpc): stop masking user-actionable wallet errors
'Insufficient balance: need 80 sats, have 0 sats' reached the UI as
'Operation failed. Check server logs.' — the sanitizer allowlist didn't
know wallet errors. Also allowlists the calm Lightning still-starting
notice, which it would have swallowed the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:22:41 -04:00
archipelago
cbb108a636 fix(wallet): photo QR decode uses native BarcodeDetector first — dense Lightning invoices scan reliably
Some checks failed
Demo images / Build & push demo images (push) Failing after 33s
On the companion (plain-http LAN = no secure context = no live camera)
the photo path IS the scanner, and qr-scanner's single wasm pass often
misses the dense QR of a full BOLT11 invoice. Android WebView's native
BarcodeDetector handles them easily — try it first, wasm as fallback,
plus a 'Reading photo…' status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:16:35 -04:00
archipelago
bfd8bc5b9a fix(wallet): channel funding-tx link uses the explorer flow + consent modal above nested modals
Some checks failed
Demo images / Build & push demo images (push) Failing after 35s
The Channels panel's 'Funding tx in Mempool' link still hardcoded the
local app (missed in the explorer rollout): now it routes like every
other tx link — local Mempool when running, else the saved external
explorer, with the first-time consent modal setting it up and then
opening the tx. The consent modal moves to z-3600 so it renders above
the Wallet Settings modal that can trigger it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:02:35 -04:00
archipelago
6347d16a2f fix(recovery): quiet the fleet logs — skip running containers, back off failed companion repairs
Some checks failed
Demo images / Build & push demo images (push) Failing after 32s
Fleet log sweep (2026-07-22): (1) recovery paths podman-start'ed
containers that were already running, producing hundreds of benign but
scary conmon 'Failed to create container' + cgroup Permission-denied
pairs per node per day — both paths now check state first; (2) the 30s
companion-reconcile loop retried registry pulls/builds forever against
unreachable registries (~174×/image/day on an offline node) — failing
rounds now back off exponentially to a 1h cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:44:32 -04:00
archipelago
b193e64ffb fix(ui): kiosk dropdown flash + never inherit the OS light theme
Outside-close listeners move from click to pointerdown: pointerdown
fires before the open-toggle re-render, so the kiosk's slow software
compositing can no longer detach the click target mid-flight and close
the menu the instant it opens. color-scheme:dark (CSS + meta) pins every
native control — select popups, scrollbars, pickers — dark regardless of
the user's OS theme; explicit select/option colors cover Firefox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:44:32 -04:00
archipelago
6a3b46b5a9 fix(handshake): peer requests actually arrive — background poll, unified relays, real send errors
Three silent failure modes killed nostr peer requests (analyzed from the
live code 2026-07-22): (1) nothing ever polled the relays except the
manual Federation Poll button — a 5-minute background poll now fetches
inbound requests and nudges the websocket when something lands (the
handler's discoverability gate still applies); (2) handshake send/poll
used only the two hardcoded config relays (one defunct) and ignored the
user-managed relay list — every nostr op now uses the merged set;
(3) publish errors were swallowed (let _ =) so 'ok' was reported even
when no relay accepted the event — now a delivery failure is an error
the UI shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:44:32 -04:00
archipelago
770e3386f4 perf(nodes): Connected Nodes refresh — parallel probes, instant list, 12s health timeout
Reachability probes ran one at a time with a 30s Tor timeout each, so
Refresh sat disabled for N-offline-peers × 30s. Now the list renders and
the button re-enables immediately; probes run concurrently and fill the
dots in as they land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:44:31 -04:00
archipelago
8f4c32b93f feat(ui): modal contract — pinned title+tabs, scrolling middle, pinned footer
Some checks failed
Demo images / Build & push demo images (push) Failing after 33s
BaseModal becomes a flex column: title row and the new #header slot
(tabs) stay fixed at the top, #footer (action buttons) fixed at the
bottom, only the default slot scrolls (default max-h 90vh unless the
caller sets one). Wallet Settings migrates: tabs pinned, four duplicated
in-pane Close rows collapse into one pinned footer that carries the
active tab's primary action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:08:38 -04:00
archipelago
30a4343b83 fix(ui): kill console spam — pine/mempool icon 404s + filebrowser 401 loop
pine/mempool get explicit icon entries (their extensions aren't .png, so
the default guess 404'd on every render). filebrowser-client: central
authedFetch does ONE transparent re-login when the short-lived JWT
expires (an expired cookie previously kept _authenticated=true and every
Files-card poll 401'd forever), plus a 60s login cooldown so a missing
filebrowser doesn't spam either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:08:31 -04:00
archipelago
cad68eb941 fix(mesh): setup modal re-fires on EVERY plug — dismissals key on plug time
detected_device_info now carries plugged_at (the /dev node's creation
time; udev recreates it per plug). Dismissals store (path -> plugged_at),
so swapping sticks or a fast unplug/replug between status polls can no
longer be suppressed by an old 'Not Now' (v1 path-only dismissals are
abandoned wholesale).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:08:18 -04:00
archipelago
197e351880 fix(companion): pairing QR advertises the LAN address, never a tailnet IP
The QR mirrored the browser origin — an operator browsing over Tailscale
handed the phone a 100.x address it has no route to, so pairing silently
never connected (reported 2026-07-22; the 'random password' alongside it
was the device token working as designed). system.get-hostname now also
returns the default-route LAN IPv4; the QR substitutes it (or the mDNS
name) whenever the origin is localhost or a CGNAT/tailnet 100.64/10 IP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:08:17 -04:00
92 changed files with 3652 additions and 373 deletions

View File

@ -18,7 +18,7 @@ on:
paths: paths:
- 'neode-ui/**' - 'neode-ui/**'
- 'docker-compose.demo.yml' - 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml' - '.gitea/workflows/demo-images.yml'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

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

View File

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

View File

@ -78,6 +78,9 @@ object ServerQrParser {
password = credential, password = credential,
name = uri.getQueryParameter("name") ?: "", name = uri.getQueryParameter("name") ?: "",
meshIp = fips?.ula ?: "", meshIp = fips?.ula ?: "",
// npub is the durable identity — saved-server upserts match on
// it, so re-scanning after a LAN renumber updates in place.
npub = fips?.npub ?: "",
), ),
fips = fips, fips = fips,
) )

View File

@ -61,6 +61,13 @@ class ArchyVpnService : VpnService() {
.setMtu(1280) .setMtu(1280)
.addAddress(identity.address, 128) .addAddress(identity.address, 128)
.addRoute("fd00::", 8) .addRoute("fd00::", 8)
// The TUN is IPv6-only. Android blocks every address family
// the VPN has no address for — without this, bringing the
// mesh up cut ALL of the phone's IPv4 internet.
.allowFamily(android.system.OsConstants.AF_INET)
// And let apps that bind their own network skip the TUN
// entirely — this is mesh reachability, not a privacy VPN.
.allowBypass()
.establish() .establish()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "VPN establish failed", e) Log.e(TAG, "VPN establish failed", e)

View File

@ -12,6 +12,16 @@ import androidx.datastore.preferences.preferencesDataStore
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs") private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
// both paths — direct LAN p2p to the node AND a public rendezvous for
// away-from-home — even when the scanned node is old enough that its QR
// carries no fanchors. Keep in lockstep with
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
internal const val ARCHY_ANCHOR_NPUB =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
/** /**
* Mesh identity + known node peers. Follows the same plaintext-DataStore * Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the * storage model as ServerPreferences (the server password lives there the
@ -91,6 +101,21 @@ class FipsPreferences(private val context: Context) {
})) }))
} }
} }
// Guarantee the public anchor: without it, a QR from an older node
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
if (info.npub != ARCHY_ANCHOR_NPUB &&
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "Archipelago anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet() val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs -> context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]") val current = JSONArray(prefs[peersKey] ?: "[]")

View File

@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
@Composable @Composable
fun GamepadLayout( fun GamepadLayout(
onKey: (String) -> Unit, onKey: (String) -> Unit,
onTwoFingerHold: () -> Unit, onThreeFingerHold: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val surface = Neo.surface() val surface = Neo.surface()
@ -54,9 +54,9 @@ fun GamepadLayout(
do { do {
val ev = awaitPointerEvent() val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() } val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() } if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
if (a.size < 2) t = 0L if (a.size < 3) t = 0L
} while (ev.changes.any { it.pressed }) } while (ev.changes.any { it.pressed })
} }
} }

View File

@ -0,0 +1,147 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
/**
* First-launch teaching overlay for the three-finger hold gesture. Three
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
* short caption explains what the gesture opens. Dismissed by tapping
* anywhere (or automatically after a few seconds) shown once, ever.
*/
@Composable
fun GestureHintOverlay(onDismiss: () -> Unit) {
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
LaunchedEffect(Unit) {
delay(6500)
onDismiss()
}
val transition = rememberInfiniteTransition(label = "gesture-hint")
// Fingertips press down together…
val press by transition.animateFloat(
initialValue = 1f,
targetValue = 0.86f,
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
label = "press",
)
// …while a ring ripples outward on each press cycle.
val ripple by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
label = "ripple",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Hand: three fingertip dots in a natural arc + ripple ring.
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(150.dp)
.scale(0.4f + ripple * 0.6f)
.border(
2.dp,
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
CircleShape,
),
)
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
FingerDot(x = 44.dp, y = 8.dp, scale = press)
}
Spacer(Modifier.height(28.dp))
Text(
text = stringResource(R.string.gesture_hint_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(R.string.gesture_hint_body),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 48.dp),
)
Spacer(Modifier.height(32.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.clickable(onClick = onDismiss)
.padding(horizontal = 28.dp, vertical = 12.dp),
) {
Text(
text = stringResource(R.string.gesture_hint_got_it),
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
}
}
}
}
@Composable
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
Box(
Modifier
.offset(x = x, y = y)
.size(26.dp)
.scale(scale)
.background(Color.White.copy(alpha = 0.92f), CircleShape),
)
}

View File

@ -116,7 +116,7 @@ fun NESController(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.twoFingerHold(onMenu) .threeFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp), .padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
} }
} }
/** Two-finger hold gesture modifier */ /** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) { fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
awaitFirstDown(requireUnconsumed = false) awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false var t = 0L; var fired = false
do { do {
val ev = awaitPointerEvent() val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() } val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
if (a.size < 2) t = 0L if (a.size < 3) t = 0L
} while (ev.changes.any { it.pressed }) } while (ev.changes.any { it.pressed })
} }
} }

View File

@ -50,7 +50,7 @@ fun NESPortraitController(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.twoFingerHold(onMenu) .threeFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp), .padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -87,7 +87,7 @@ fun NESPortraitController(
onMove = { dx, dy -> onMouseMove(dx, dy) }, onMove = { dx, dy -> onMouseMove(dx, dy) },
onClick = { onMouseClick(it) }, onClick = { onMouseClick(it) },
onScroll = { dy -> onMouseScroll(dy) }, onScroll = { dy -> onMouseScroll(dy) },
onTwoFingerHold = onMenu, onThreeFingerHold = onMenu,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),

View File

@ -217,13 +217,20 @@ fun QrScannerOverlay(
} }
} }
/** Shared by the pairing scanner and the wallet scan modal. */
@Composable @Composable
private fun CameraQrPreview(onDecoded: (String) -> Unit) { internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded) val currentOnDecoded by rememberUpdatedState(onDecoded)
val previewView = remember { val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
// TextureView, not the SurfaceView default: SurfaceView punches a
// hole in the window, which black-flashes inside Compose fades and
// ignores rounded-corner clipping (wallet modal).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -282,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
) )
} }
private var lastAttempt = 0L
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
// retry) pegs a core when run at camera rate, and that CPU contention
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
// frames skipped here are simply dropped, so decodes stay current.
val now = System.currentTimeMillis()
if (now - lastAttempt < 140) {
image.close()
return
}
lastAttempt = now
try { try {
val plane = image.planes[0] val plane = image.planes[0]
val buffer = plane.buffer val buffer = plane.buffer

View File

@ -32,7 +32,7 @@ fun Trackpad(
onMove: (dx: Int, dy: Int) -> Unit, onMove: (dx: Int, dy: Int) -> Unit,
onClick: (button: Int) -> Unit, onClick: (button: Int) -> Unit,
onScroll: (dy: Int) -> Unit, onScroll: (dy: Int) -> Unit,
onTwoFingerHold: () -> Unit, onThreeFingerHold: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var fingers by remember { mutableIntStateOf(0) } var fingers by remember { mutableIntStateOf(0) }
@ -53,7 +53,7 @@ fun Trackpad(
val t0 = System.currentTimeMillis() val t0 = System.currentTimeMillis()
var maxPtrs = 1 var maxPtrs = 1
var holdFired = false var holdFired = false
var twoStart = 0L var threeStart = 0L
var scrollAcc = 0f var scrollAcc = 0f
fingers = 1 fingers = 1
@ -64,20 +64,25 @@ fun Trackpad(
fingers = active.size fingers = active.size
when { when {
active.size >= 2 -> { // Three fingers = hold for menu; two = scroll. Kept
if (twoStart == 0L) twoStart = System.currentTimeMillis() // on separate counts so a long two-finger scroll can
if (!holdFired && System.currentTimeMillis() - twoStart > 500) { // never fire the menu mid-gesture.
active.size >= 3 -> {
if (threeStart == 0L) threeStart = System.currentTimeMillis()
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
holdFired = true holdFired = true
onTwoFingerHold() onThreeFingerHold()
} }
if (!holdFired) { ev.changes.forEach { it.consume() }
}
active.size == 2 -> {
threeStart = 0L
val dy = active.map { it.positionChange().y }.average().toFloat() val dy = active.map { it.positionChange().y }.average().toFloat()
scrollAcc += dy scrollAcc += dy
if (kotlin.math.abs(scrollAcc) > 12f) { if (kotlin.math.abs(scrollAcc) > 12f) {
onScroll(if (scrollAcc > 0) 1 else -1) onScroll(if (scrollAcc > 0) 1 else -1)
scrollAcc = 0f scrollAcc = 0f
} }
}
ev.changes.forEach { it.consume() } ev.changes.forEach { it.consume() }
} }
active.size == 1 && maxPtrs == 1 -> { active.size == 1 && maxPtrs == 1 -> {
@ -99,7 +104,11 @@ fun Trackpad(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = if (fingers >= 2) "hold for menu" else "", text = when {
fingers >= 3 -> "hold for menu"
fingers == 2 -> "scroll"
else -> ""
},
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = muted.copy(alpha = 0.4f), color = muted.copy(alpha = 0.4f),
) )

View File

@ -0,0 +1,294 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Native replacement for the web wallet's scan pane same visual design as
* neode-ui's WalletScanModal (dark glass card, square preview, orange
* viewfinder, status strip) but the camera and decoding run natively, so the
* preview doesn't lag the way getUserMedia does inside a WebView.
*
* Decoded text is handed back to the page ([onDecoded]) which does all the
* detection/spend logic; the page in turn streams status lines (animated-QR
* progress, "not recognised" errors) back in via [status] and closes the
* modal through the JS bridge once it accepts a code.
*/
@Composable
fun WalletQrScannerModal(
visible: Boolean,
status: Pair<String, Boolean>?, // message from the web page + isError
onDecoded: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
// Local error from a failed image upload; a fresh web status replaces it.
var uploadError by remember { mutableStateOf<String?>(null) }
val noQrMessage = stringResource(R.string.no_qr_in_image)
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
val decoded = decodeQrFromUri(context, uri)
if (decoded != null) {
uploadError = null
onDecoded(decoded)
} else {
uploadError = noQrMessage
}
}
}
LaunchedEffect(visible) {
if (visible) {
uploadError = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
LaunchedEffect(status) { if (status != null) uploadError = null }
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(16.dp)
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF212151C))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}, // swallow — only the scrim dismisses
)
.padding(24.dp),
) {
// Header — mirrors the web modal's title row
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_to_send),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
}
Spacer(Modifier.height(8.dp))
// Square camera preview with the orange viewfinder
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.4f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
// Throttle repeat frames: a static QR decodes ~20x/s but
// the page only needs one; animated QRs still stream
// because each frame's text differs.
var lastText by remember { mutableStateOf("") }
var lastSentAt by remember { mutableStateOf(0L) }
CameraQrPreview(onDecoded = { text ->
val now = System.currentTimeMillis()
if (text != lastText || now - lastSentAt > 250) {
lastText = text
lastSentAt = now
onDecoded(text)
}
})
Box(
Modifier
.fillMaxSize(0.62f)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
// Status strip — same slot the web modal uses for hints/errors
val message = uploadError ?: status?.first
val isError = uploadError != null || status?.second == true
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp)
.defaultMinSize(minHeight = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message?.takeIf { it.isNotBlank() }
?: stringResource(R.string.scan_wallet_hint),
style = MaterialTheme.typography.bodySmall,
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
GlassButton(
text = stringResource(R.string.upload_qr_image),
onClick = { imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
}
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
return try {
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
while (maxDim / (sample * 2) >= 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
?: return null
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
)
)
}
try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
} catch (_: NotFoundException) {
// Light-on-dark QRs (dark-themed wallets) decode inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
}
} catch (_: Exception) {
null
}
}

View File

@ -172,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) }, onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) }, onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true }, onThreeFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f) modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) )

View File

@ -85,6 +85,7 @@ import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.HttpURLConnection import java.net.HttpURLConnection
@ -171,10 +172,31 @@ fun ServerConnectScreen(
errorMessage = null errorMessage = null
scope.launch { scope.launch {
val result = testConnection(server) var reachable = testConnection(server)
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
// node. The scanned IP was only ever a dial hint; the node's real
// identity is its npub and its ULA is reachable from anywhere over
// the mesh. Bring the tunnel up and probe the ULA before failing.
if (!reachable && server.meshIp.isNotBlank()) {
FipsManager.autoStartIfReady(context)
val meshServer = server.copy(
address = server.meshIp,
useHttps = false,
port = "",
)
// The tunnel + mesh route need a moment on cold start — and on
// a first-ever pairing the VPN consent dialog is on screen at
// the same time, so give the user time to tap it.
for (attempt in 0 until 6) {
if (attempt > 0) delay(2000)
reachable = testConnection(meshServer)
if (reachable) break
}
}
isConnecting = false isConnecting = false
if (result) { if (reachable) {
prefs.setActiveServer(server) prefs.setActiveServer(server)
onConnected(server.toUrl()) onConnected(server.toUrl())
} else { } else {

View File

@ -53,11 +53,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -68,13 +71,21 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
/** Open a URL in the phone's default browser (genuinely external links). */ /** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) { private fun openExternalUrl(context: android.content.Context, url: String) {
@ -176,6 +187,39 @@ fun WebViewScreen(
// while this is shown, so closing it returns instantly with no reload. // while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) } var inAppUrl by remember { mutableStateOf<String?>(null) }
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) }
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
// One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) }
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
var gestureHintDismissed by remember { mutableStateOf(false) }
// Don't teach the gesture on top of the login/splash — arm the overlay
// ~2 minutes after the kiosk first finishes loading, once the user has
// settled in.
var gestureHintReady by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { isLoading }.first { !it }
delay(120_000)
gestureHintReady = true
}
val scope = rememberCoroutineScope()
// <input type="file"> support — without a chooser implementation the
// WebView silently ignores file inputs (broke the wallet's upload path).
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
val fileChooserLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
pendingFileChooser?.onReceiveValue(
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
)
pendingFileChooser = null
}
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) { BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
webView?.goBack() webView?.goBack()
} }
@ -301,6 +345,35 @@ fun WebViewScreen(
"ArchipelagoNative", "ArchipelagoNative",
) )
// Wallet QR bridge. The web scan modal calls:
// window.ArchipelagoQr.open() — show the native scanner
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
// window.ArchipelagoQr.close() — code accepted, tear down
// Decodes flow back through window.__archyQrResult(text);
// a user cancel calls window.__archyQrCancelled().
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun open() {
webViewRef.post {
walletScannerStatus = null
walletScannerVisible = true
}
}
@android.webkit.JavascriptInterface
fun setStatus(message: String, isError: Boolean) {
webViewRef.post { walletScannerStatus = message to isError }
}
@android.webkit.JavascriptInterface
fun close() {
webViewRef.post { walletScannerVisible = false }
}
},
"ArchipelagoQr",
)
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true isLoading = true
@ -412,6 +485,27 @@ fun WebViewScreen(
loadProgress = newProgress loadProgress = newProgress
} }
override fun onShowFileChooser(
view: WebView?,
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
fileChooserParams: FileChooserParams?,
): Boolean {
pendingFileChooser?.onReceiveValue(null)
pendingFileChooser = filePathCallback
val intent = fileChooserParams?.createIntent()
if (intent == null) {
pendingFileChooser = null
return false
}
return try {
fileChooserLauncher.launch(intent)
true
} catch (_: Exception) {
pendingFileChooser = null
false
}
}
// Wallet QR scanner: grant the page camera access. // Wallet QR scanner: grant the page camera access.
// Only video capture is granted — anything else the // Only video capture is granted — anything else the
// page asks for is denied as before. // page asks for is denied as before.
@ -467,22 +561,24 @@ fun WebViewScreen(
} }
} }
// Two-finger hold (500ms) → navigate to remote input // Three-finger hold (500ms) → navigate to remote input.
var twoFingerStart = 0L // Three fingers, not two: two-finger scroll/pinch on the
var twoFingerFired = false // page collided with the old two-finger hold.
var threeFingerStart = 0L
var threeFingerFired = false
setOnTouchListener { _, event -> setOnTouchListener { _, event ->
val pointerCount = event.pointerCount val pointerCount = event.pointerCount
when (event.actionMasked) { when (event.actionMasked) {
android.view.MotionEvent.ACTION_POINTER_DOWN -> { android.view.MotionEvent.ACTION_POINTER_DOWN -> {
if (pointerCount >= 2) { if (pointerCount >= 3) {
twoFingerStart = System.currentTimeMillis() threeFingerStart = System.currentTimeMillis()
twoFingerFired = false threeFingerFired = false
} }
} }
android.view.MotionEvent.ACTION_MOVE -> { android.view.MotionEvent.ACTION_MOVE -> {
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) { if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - twoFingerStart > 500) { if (System.currentTimeMillis() - threeFingerStart > 500) {
twoFingerFired = true threeFingerFired = true
onRemoteInput() onRemoteInput()
} }
} }
@ -490,8 +586,8 @@ fun WebViewScreen(
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_UP,
android.view.MotionEvent.ACTION_POINTER_UP, android.view.MotionEvent.ACTION_POINTER_UP,
android.view.MotionEvent.ACTION_CANCEL -> { android.view.MotionEvent.ACTION_CANCEL -> {
if (event.pointerCount <= 2) { if (event.pointerCount <= 3) {
twoFingerStart = 0L threeFingerStart = 0L
} }
} }
} }
@ -527,6 +623,38 @@ fun WebViewScreen(
onClose = { inAppUrl = null }, onClose = { inAppUrl = null },
) )
} }
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
WalletQrScannerModal(
visible = walletScannerVisible,
status = walletScannerStatus,
onDecoded = { text ->
webView?.evaluateJavascript(
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
null,
)
},
onDismiss = {
walletScannerVisible = false
webView?.evaluateJavascript(
"window.__archyQrCancelled && window.__archyQrCancelled()",
null,
)
},
)
// First-launch teaching overlay for the three-finger hold — armed
// ~2 minutes after login so it never fights the splash/first look.
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
!isLoading && inAppUrl == null
) {
GestureHintOverlay(
onDismiss = {
gestureHintDismissed = true
scope.launch { prefs.markGestureHintSeen() }
},
)
}
} }
} }
} }

View File

@ -41,4 +41,11 @@
<string name="edit_server_title">Edit Server</string> <string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string> <string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="gesture_hint_title">Hold with three fingers</string>
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
<string name="gesture_hint_got_it">Got it</string>
<string name="scan_to_send">Scan to send</string>
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
<string name="upload_qr_image">Upload image</string>
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
</resources> </resources>

View File

@ -1,5 +1,26 @@
# Changelog # Changelog
## v1.7.112-alpha (2026-07-22)
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
## v1.7.111-alpha (2026-07-22) ## 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. - 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.

View File

@ -9,13 +9,22 @@ app:
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
entrypoint: ["sh", "-lc"] entrypoint: ["sh", "-lc"]
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
# container is actually running (Knots, Core, or any future distro archy
# ships), so the gateway is never pinned to one node's container name.
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
custom_args: custom_args:
- >- - >-
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon; exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
else else
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway; exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
fi fi
derived_env:
- key: FM_BITCOIND_URL
template: "http://{{BITCOIN_HOST}}:8332"
# The gateway's admin API is gated by a bcrypt password hash. Generate it on # The gateway's admin API is gated by a bcrypt password hash. Generate it on
# first install (random password + its bcrypt hash, both 0600 rootless-owned) # first install (random password + its bcrypt hash, both 0600 rootless-owned)
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds # so the app installs from its manifest alone — `fedimint-gateway-hash` holds

View File

@ -21,6 +21,11 @@ app:
template: fedimint://{{HOST_MDNS}}:8173 template: fedimint://{{HOST_MDNS}}:8173
- key: FM_API_URL - key: FM_API_URL
template: ws://{{HOST_MDNS}}:8174 template: ws://{{HOST_MDNS}}:8174
# Resolves to whichever bitcoin container is running (Knots/Core/future
# distro) instead of a hardcoded name — the guardian works on any node
# regardless of which Bitcoin software it runs.
- key: FM_BITCOIND_URL
template: "http://{{BITCOIN_HOST}}:8332"
secret_env: secret_env:
- key: FM_BITCOIND_PASSWORD - key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password secret_file: bitcoin-rpc-password
@ -62,7 +67,8 @@ app:
environment: environment:
- FM_DATA_DIR=/data - FM_DATA_DIR=/data
- FM_BITCOIND_URL=http://bitcoin-knots:8332 # FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
# hardcoded name — do not re-add it here.
- FM_BITCOIND_USERNAME=archipelago - FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin - FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173 - FM_BIND_P2P=0.0.0.0:8173

View File

@ -188,7 +188,7 @@ impl ApiHandler {
} }
}; };
let price_sats = match &item.access { let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats, content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
_ => { _ => {
// Not a paid item — no invoice to issue. // Not a paid item — no invoice to issue.
return Ok(build_response( return Ok(build_response(
@ -198,6 +198,13 @@ impl ApiHandler {
)); ));
} }
}; };
if !content_server::method_accepted(&item.access, "lightning") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
));
}
let memo = format!("Archipelago peer file {content_id}"); let memo = format!("Archipelago peer file {content_id}");
match self match self
@ -315,7 +322,18 @@ impl ApiHandler {
.unwrap_or_default(); .unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) { let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access { Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats, content_server::AccessControl::Paid { price_sats, .. } => {
if !content_server::method_accepted(&i.access, "onchain") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
),
));
}
*price_sats
}
_ => { _ => {
return Ok(build_response( return Ok(build_response(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,

View File

@ -179,7 +179,24 @@ impl RpcHandler {
if price == 0 { if price == 0 {
return Err(anyhow::anyhow!("Paid content requires price_sats > 0")); return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
} }
AccessControl::Paid { price_sats: price } // Optional list of payment methods the sharer accepts.
// Absent/empty = all methods (backward compatible).
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
let accepted: Vec<String> = params
.get("accepted_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.as_str())
.filter(|m| KNOWN_METHODS.contains(m))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
AccessControl::Paid {
price_sats: price,
accepted,
}
} }
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)), _ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
}; };
@ -412,6 +429,55 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address")); return Err(anyhow::anyhow!("Invalid v3 onion address"));
} }
// NEVER pay twice for content we already own (2026-07-22: a file
// shared twice produced two catalog ids for the same bytes and the
// buyer paid both). Guard BEFORE any ecash is minted, matching both
// by exact (onion, content_id) and by (onion, filename) — the latter
// catches duplicate ids pointing at the same file on the same
// seller. The owned copy is served from the local cache instead.
{
let filename = params.get("filename").and_then(|v| v.as_str());
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
let already = owned.iter().find(|o| {
o.onion == onion
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
}))
});
if let Some(o) = already {
tracing::info!(
onion,
content_id,
owned_as = %o.content_id,
"paid download: already owned — serving cached copy, NOT paying again"
);
if let Some((mime, bytes)) = crate::content_owned::read_owned(
&self.config.data_dir,
&o.onion,
&o.content_id,
)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
"owned": true,
"already_owned": true,
"filename": o.filename,
"mime_type": mime,
"size_bytes": bytes.len(),
"paid_sats": 0,
"data_base64":
base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
}
// Cache record exists but bytes are gone — fall through and
// repurchase rather than stranding the user.
}
}
// `method` pins the backend the user confirmed in the UI ("cashu" | // `method` pins the backend the user confirmed in the UI ("cashu" |
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's // "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
// verify_payment_token accepts either, so a node whose balance lives in // verify_payment_token accepts either, so a node whose balance lives in
@ -590,6 +656,54 @@ impl RpcHandler {
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}"); tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
} }
// Auto-file the purchase into the user's Files area (2026-07-22):
// Photos for images/video, Music for audio, Documents otherwise —
// same buckets the Cloud view uses. The in-app viewer still plays
// from the purchase cache; this makes the file ALSO show up where
// files live, on every device, without relying on a browser
// download. Best-effort: never fail a paid download over it.
{
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
"Photos"
} else if mime_type.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let base = std::path::Path::new(&filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let dir = self.config.data_dir.join("filebrowser").join(folder);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (base.clone(), String::new()),
};
let mut n = 2;
while target.exists() {
target = dir.join(format!("{stem} ({n}){ext}"));
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
),
}
}
}
use base64::Engine; use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);

View File

@ -260,6 +260,7 @@ impl RpcHandler {
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await, "wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await, "wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await, "wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
// Ark protocol (via barkd sidecar) // Ark protocol (via barkd sidecar)
"wallet.ark-status" => self.handle_wallet_ark_status().await, "wallet.ark-status" => self.handle_wallet_ark_status().await,

View File

@ -118,6 +118,31 @@ impl RpcHandler {
Ok(serde_json::json!({ "removed": removed })) Ok(serde_json::json!({ "removed": removed }))
} }
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
/// with sufficient balance. Returns the notes token for the recipient
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
/// split from Cashu 2026-07-22). The heavy lifting already existed in
/// `fedimint_client::spend_from_any`; it was simply never exposed.
pub(super) async fn handle_wallet_fedimint_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let amount_sats = params
.as_ref()
.and_then(|p| p.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
let (token, federation_id) =
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
.await?;
Ok(serde_json::json!({
"token": token,
"federation_id": federation_id,
"amount_sats": amount_sats,
}))
}
/// `wallet.fedimint-balance` — total sats across all joined federations. /// `wallet.fedimint-balance` — total sats across all joined federations.
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> { pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified // Soft-fail to zero when clientd isn't installed/running, so the unified

View File

@ -30,9 +30,21 @@ impl RpcHandler {
// The node's seed anchors ride along so the phone can rendezvous // The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint // through the same public mesh points when the node's LAN endpoint
// isn't directly dialable (phone away from home, node behind NAT). // isn't directly dialable (phone away from home, node behind NAT).
let anchors = fips::anchors::load(&self.config.data_dir) let mut anchor_list = fips::anchors::load(&self.config.data_dir)
.await .await
.unwrap_or_default() .unwrap_or_default();
// Pairing must always carry a public rendezvous point: without one the
// phone is IP-bound to the LAN host it scanned and goes dark the
// moment it leaves that network. This is a pairing hint only — the
// node's own anchor file is not modified, so an operator's removal of
// the default anchors still sticks for the node itself.
if !anchor_list
.iter()
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
{
anchor_list.push(fips::anchors::archy_anchor());
}
let anchors = anchor_list
.into_iter() .into_iter()
.map(|a| { .map(|a| {
serde_json::json!({ serde_json::json!({

View File

@ -86,7 +86,7 @@ impl RpcHandler {
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey) let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default(); .unwrap_or_default();
let version = data.server_info.version.clone(); let version = data.server_info.version.clone();
let relays = self.config.nostr_relays.clone(); let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone(); let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence( if let Err(e) = nostr_handshake::publish_presence(
@ -106,6 +106,17 @@ impl RpcHandler {
Ok(serde_json::json!({ "enabled": enabled })) Ok(serde_json::json!({ "enabled": enabled }))
} }
/// The relay set every handshake operation uses: the user-managed relay
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
/// hardcoded config relays (one of which is defunct) and ignored user
/// relay edits entirely — so a sender publishing where the receiver
/// never read was a routine, silent way for peer requests to vanish.
pub(super) async fn handshake_relays(&self) -> Vec<String> {
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
.await
}
/// Discover discoverable nodes via Nostr presence events. /// Discover discoverable nodes via Nostr presence events.
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion. /// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> { pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
@ -113,9 +124,10 @@ impl RpcHandler {
// to query relays as long as the user is actively browsing — they're // to query relays as long as the user is actively browsing — they're
// an anonymous observer of presence events, not publishing anything. // an anonymous observer of presence events, not publishing anything.
let identity_dir = self.config.data_dir.join("identity"); let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let nodes = nostr_handshake::discover_nodes( let nodes = nostr_handshake::discover_nodes(
&identity_dir, &identity_dir,
&self.config.nostr_relays, &relays,
self.config.nostr_tor_proxy.as_deref(), self.config.nostr_tor_proxy.as_deref(),
) )
.await?; .await?;
@ -161,7 +173,7 @@ impl RpcHandler {
our_version, our_version,
our_name, our_name,
message, message,
&self.config.nostr_relays, &self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(), self.config.nostr_tor_proxy.as_deref(),
) )
.await?; .await?;
@ -191,6 +203,40 @@ impl RpcHandler {
/// - `PeerReject` → mark matching outbound row as `Rejected` /// - `PeerReject` → mark matching outbound row as `Rejected`
/// ///
/// Never auto-adds peers, never auto-responds, never sends our onion. /// Never auto-adds peers, never auto-responds, never sends our onion.
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
/// ONLY when a user opened Federation and pressed the Poll button — a
/// peer request sat on the relay until the target's operator happened to
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
/// and nudges the websocket revision when anything new lands so open UIs
/// refresh immediately.
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
match self.handle_handshake_poll().await {
Ok(res) => {
let new = res
.get("new_requests")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let applied = res
.get("applied_invites")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
if new > 0 || applied > 0 {
tracing::info!(
new_requests = new,
applied_invites = applied,
"handshake poll: inbound peer activity"
);
let (data, _) = self.state_manager.get_snapshot().await;
self.state_manager.update_data(data).await;
}
}
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
}
}
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> { pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
// Runtime gate: if the user hasn't enabled discoverability, don't // Runtime gate: if the user hasn't enabled discoverability, don't
// touch the relays. The poll endpoint is a hard no-op until they // touch the relays. The poll endpoint is a hard no-op until they
@ -207,9 +253,10 @@ impl RpcHandler {
})); }));
} }
let identity_dir = self.config.data_dir.join("identity"); let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let handshakes = nostr_handshake::poll_handshakes( let handshakes = nostr_handshake::poll_handshakes(
&identity_dir, &identity_dir,
&self.config.nostr_relays, &relays,
self.config.nostr_tor_proxy.as_deref(), self.config.nostr_tor_proxy.as_deref(),
None, None,
) )

View File

@ -120,6 +120,108 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
Ok(()) Ok(())
} }
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
/// for 14 HOURS with its RPC answering but the server never finishing
/// startup — synced_to_chain=false, zero peers, every channel inactive —
/// and nothing noticed until a human tried to open a channel. The wedge
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
/// with channels that need a peer) persists. A restart reliably clears it
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
/// after 15 consecutive bad minutes we bounce the container ourselves, with
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the
/// operator.
pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move {
let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None;
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet)
continue;
};
let macaroon_hex = hex::encode(bytes);
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
else {
continue;
};
let Ok(resp) = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
else {
bad_minutes = 0; // down/locked — not the wedge signature
continue;
};
let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0;
continue;
};
let synced = info
.get("synced_to_chain")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
let channels = info
.get("num_active_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_inactive_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_pending_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let wedged = !synced || (channels > 0 && peers == 0);
if !wedged {
bad_minutes = 0;
continue;
}
bad_minutes += 1;
if bad_minutes < 15 {
continue;
}
if last_restart
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
.unwrap_or(false)
{
continue;
}
tracing::warn!(
synced_to_chain = synced,
num_peers = peers,
channels,
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
);
let out = tokio::process::Command::new("podman")
.args(["restart", "lnd"])
.output()
.await;
match out {
Ok(o) if o.status.success() => {
tracing::info!("LND watchdog restart complete");
}
Ok(o) => tracing::warn!(
"LND watchdog restart failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
}
last_restart = Some(tokio::time::Instant::now());
bad_minutes = 0;
}
});
}
impl RpcHandler { impl RpcHandler {
/// Helper: create an authenticated LND REST client. /// Helper: create an authenticated LND REST client.
/// Returns an HTTP client configured for LND's self-signed TLS and the /// Returns an HTTP client configured for LND's self-signed TLS and the

View File

@ -62,6 +62,14 @@ impl RpcHandler {
.get("message") .get("message")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("Unknown error"); .unwrap_or("Unknown error");
// Invoices are short-lived; retrying the same one can never
// succeed, so tell the user the way out instead of just the fact.
if msg.contains("invoice expired") {
return Err(anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
msg.trim_start_matches("invoice expired. ")
));
}
return Err(anyhow::anyhow!("Payment failed: {}", msg)); return Err(anyhow::anyhow!("Payment failed: {}", msg));
} }

View File

@ -101,12 +101,21 @@ impl RpcHandler {
detected.iter().any(|d| d == &path), detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port" "{path} is not a detected mesh-radio candidate port"
); );
// Only hold the mesh_service lock long enough for the quick
// active-path guard check — NEVER across the actual probe, which
// can take 15-60s across its internal collision retries. Confirmed
// live 2026-07-23: holding this read lock for the full probe starved
// a concurrent firmware-flash job's MeshService::stop() (which needs
// the write lock) well past its own bounded timeout, surfacing as
// "Mesh listener did not release the serial port" even though
// stop() itself was fast.
{
let service = self.mesh_service.read().await; let service = self.mesh_service.read().await;
let probe = match service.as_ref() { if let Some(svc) = service.as_ref() {
Some(svc) => svc.probe_device(&path).await?, svc.ensure_probe_allowed(&path).await?;
// No mesh service yet (radio never enabled) — probe directly. }
None => mesh::listener::probe_device(&path).await?, }
}; let probe = mesh::listener::probe_device(&path).await?;
Ok(serde_json::to_value(probe)?) Ok(serde_json::to_value(probe)?)
} }

View File

@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Mempool requires", "Mempool requires",
"Container", "Container",
"Image", "Image",
// Wallet-actionable errors: masking "Insufficient balance: need 80
// sats, have 0 sats" behind "Operation failed. Check server logs."
// sent the operator to journalctl for a message that was written for
// them in the first place (ecash send, 2026-07-22).
"Insufficient balance",
"Insufficient funds",
// Lightning payment failures carry LND's reason ("invoice expired.
// Valid until …", "no route", …) — the user can act on every one of
// them, and masking sent the operator to journalctl (invoice-expired
// send, 2026-07-23).
"Payment failed",
"Invalid payment request",
"Missing 'payment_request'",
"Your Lightning node is still finishing",
"Bitcoin address", "Bitcoin address",
"No router", "No router",
"No OpenWrt", "No OpenWrt",
@ -151,6 +165,25 @@ mod sanitize_tests {
} }
} }
#[test]
fn lightning_payment_errors_pass_through() {
// LND's payment-failure reasons are written for the payer — masking
// "invoice expired" as "Check server logs" left a user retrying a
// dead invoice (framework-pt, 2026-07-23).
for msg in [
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
"Payment failed: unable to find a path to destination",
"Invalid payment request: must be a Lightning invoice (lnbc...)",
"Missing 'payment_request' parameter",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test] #[test]
fn internal_errors_stay_generic() { fn internal_errors_stay_generic() {
assert_eq!( assert_eq!(

View File

@ -26,6 +26,7 @@ mod node;
mod nostr; mod nostr;
mod openwrt; mod openwrt;
mod package; mod package;
pub(crate) use package::wyoming_satellite_keeper;
mod peers; mod peers;
mod pine_status; mod pine_status;
mod response; mod response;

View File

@ -4,6 +4,7 @@ mod dependencies;
mod install; mod install;
mod lifecycle; mod lifecycle;
mod pine_ha; mod pine_ha;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress; mod progress;
mod runtime; mod runtime;
mod set_config; mod set_config;

View File

@ -697,6 +697,189 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
false false
} }
// ---------------------------------------------------------------------------
// Wyoming satellite keeper
// ---------------------------------------------------------------------------
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
///
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
/// renumbering — or the whole node moving to a different network — strands
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
/// never re-resolves on its own. This keeper probes each satellite entry and,
/// when one stops answering, sweeps the node's local /24s for the same
/// Wyoming port and rewrites the entry to the address that answers.
pub(crate) async fn wyoming_satellite_keeper() {
loop {
if home_assistant_installed().await {
heal_wyoming_satellites().await;
}
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
}
}
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
matches!(
tokio::time::timeout(
std::time::Duration::from_millis(ms),
tokio::net::TcpStream::connect((host, port)),
)
.await,
Ok(Ok(_))
)
}
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
/// excluded — satellites live on real LANs.
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
let Ok(out) = tokio::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "scope", "global"])
.output()
.await
else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let cidr = l.split_whitespace().nth(3)?;
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
let o = ip.octets();
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
if o[0] == 100 && (64..128).contains(&o[1]) {
return None;
}
Some(ip)
})
.collect()
}
/// Sweep the /24 of every local interface for something answering `port`.
/// First responder wins; the node's own addresses are skipped.
async fn find_satellite(port: u16) -> Option<String> {
let self_ips = local_ipv4_addresses().await;
for base in self_ips.iter().map(|ip| ip.octets()) {
let mut set = tokio::task::JoinSet::new();
for i in 1..255u8 {
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
if self_ips.contains(&ip) {
continue;
}
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
}
while let Some(res) = set.join_next().await {
if let Ok(Some(ip)) = res {
return Some(ip);
}
}
}
None
}
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
/// port now answers. Stops HA before editing the store (HA flushes its own
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
/// again after.
async fn heal_wyoming_satellites() {
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
let Some(store) = read_store(&path).await else {
return;
};
let Some(entries) = store
.get("data")
.and_then(|d| d.get("entries"))
.and_then(|e| e.as_array())
else {
return;
};
// (host, port, new_host) for every stranded satellite we can re-resolve.
let mut moves: Vec<(String, u16, String)> = Vec::new();
for e in entries {
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
continue;
}
let Some(host) = e
.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
else {
continue;
};
// Engines use host.containers.internal — only IP-pinned entries drift.
if host.parse::<std::net::Ipv4Addr>().is_err() {
continue;
}
let port = e
.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
.unwrap_or(0) as u16;
if port == 0 || tcp_alive(host, port, 1500).await {
continue;
}
let Some(new_host) = find_satellite(port).await else {
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
continue;
};
if new_host != host {
moves.push((host.to_string(), port, new_host));
}
}
if moves.is_empty() {
return;
}
// Stop HA, re-read + rewrite the store, start HA.
let _ = tokio::process::Command::new("podman")
.args(["stop", "homeassistant"])
.output()
.await;
if let Some(mut store) = read_store(&path).await {
let mut changed = false;
if let Some(entries) = store
.get_mut("data")
.and_then(|d| d.get_mut("entries"))
.and_then(|e| e.as_array_mut())
{
for e in entries.iter_mut() {
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
continue;
}
let host = e
.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let port = e
.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
.unwrap_or(0) as u16;
if let Some((_, _, new_host)) =
moves.iter().find(|(h, p, _)| *h == host && *p == port)
{
if let Some(data) = e.get_mut("data") {
data["host"] = json!(new_host);
}
e["modified_at"] = json!(ha_now());
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
changed = true;
}
}
}
if changed {
write_store(&path, &store).await;
}
}
let _ = tokio::process::Command::new("podman")
.args(["start", "homeassistant"])
.output()
.await;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Presence probes + HA restart // Presence probes + HA restart
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -139,9 +139,17 @@ impl RpcHandler {
.await .await
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.unwrap_or_else(|_| "archipelago".to_string()); .unwrap_or_else(|_| "archipelago".to_string());
// LAN IPv4 rides along for the companion pairing QR: when the
// operator's browser reaches this node over Tailscale/VPN or
// localhost, that origin is useless to a phone on the LAN — the QR
// must advertise an address the phone can actually dial
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
// companion could never connect).
let lan_ip = crate::host_ip::primary_host_ipv4().await;
Ok(serde_json::json!({ Ok(serde_json::json!({
"hostname": hostname, "hostname": hostname,
"mdns_hostname": format!("{hostname}.local"), "mdns_hostname": format!("{hostname}.local"),
"lan_ip": lan_ip,
})) }))
} }

View File

@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service"; const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher"; const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
// The router daemon handles routing + the ELD re-modeset nudge; this heal
// installs the packages, group membership, script and unit on deployed nodes.
const AUDIO_ROUTER: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
const AUDIO_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
// splice-from-configs + self-heal pattern as the audio router.
const GAMEPAD_KEYS: &str =
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
const GAMEPAD_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs // Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
// write the identical file at build time (image-recipe/_archived/ // write the identical file at build time (image-recipe/_archived/
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA. // build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
@ -794,6 +816,109 @@ pub async fn ensure_kiosk_hardened() {
} }
} }
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
/// under the lingering user manager — no logind seat, so no udev ACLs on
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
/// nudge) installed and current. No-op on nodes without the kiosk — audio
/// only matters where media plays on an attached display.
pub async fn ensure_audio_stack() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return; // no kiosk → no display audio to route
}
// Package install runs via systemd-run (host_sudo), outside the service
// sandbox — /usr and the dpkg database are read-only in our namespace.
if fs::metadata("/usr/bin/pactl").await.is_err() {
info!("audio: PipeWire stack missing — installing packages");
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
match host_sudo(&[
"apt-get",
"install",
"-y",
"-qq",
"--no-install-recommends",
"pipewire",
"pipewire-pulse",
"pipewire-alsa",
"wireplumber",
"alsa-utils",
])
.await
{
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
Ok(s) => {
warn!("audio: package install exited with {} — will retry next start", s);
return;
}
Err(e) => {
warn!("audio: package install failed: {:#} — will retry next start", e);
return;
}
}
}
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
}
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("audio: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
// First install on this node — bring it up now and on every boot.
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
} else if script_changed || unit_changed {
// Content update: restart only if it's running — never re-enable a
// unit an operator deliberately disabled.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
info!("audio: router updated");
}
}
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
/// daemon (controller works inside every app iframe on the TV) installed and
/// current. Same gating as audio: no kiosk → no display input to bridge.
pub async fn ensure_gamepad_keys() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return;
}
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
}
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
info!("gamepad: bridge installed and enabled (TV controller input)");
} else if script_changed || unit_changed {
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
info!("gamepad: bridge updated");
}
}
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO /// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing /// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend /// endpoints silently fell through to the SPA `index.html` and the frontend

View File

@ -108,17 +108,33 @@ impl BootReconciler {
let orchestrator = self.orchestrator.clone(); let orchestrator = self.orchestrator.clone();
let interval = self.interval; let interval = self.interval;
Some(tokio::spawn(async move { Some(tokio::spawn(async move {
let mut failure_rounds: u32 = 0;
loop { loop {
let installed = orchestrator.manifest_ids().await; let installed = orchestrator.manifest_ids().await;
for (companion, err) in crate::container::companion::reconcile(&installed).await let failures =
{ crate::container::companion::reconcile(&installed).await;
for (companion, err) in &failures {
tracing::warn!( tracing::warn!(
companion = %companion, companion = %companion,
error = %err, error = %err,
"companion reconcile failed" "companion reconcile failed"
); );
} }
time::sleep(interval).await; // A failed repair can involve registry pulls and full
// image builds; retrying every 30s hammered unreachable
// registries ~174×/image/day on an offline node
// (archy-x250-dev log sweep, 2026-07-22). Back off
// exponentially while rounds keep failing — 30s doubling
// to a 1h cap — and reset the moment a round is clean.
failure_rounds = if failures.is_empty() {
0
} else {
failure_rounds.saturating_add(1)
};
let backoff = interval
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
.min(Duration::from_secs(3600));
time::sleep(backoff).await;
} }
})) }))
} else { } else {

View File

@ -3094,8 +3094,9 @@ impl ProdContainerOrchestrator {
.unwrap_or_else(|| "bitcoin-knots".to_string()); .unwrap_or_else(|| "bitcoin-knots".to_string());
} }
#[allow(unreachable_code)] #[allow(unreachable_code)]
// Mirrors api::rpc::package::dependencies (the legacy install path); // The known Bitcoin node containers, preferred in order. Any archy
// both Bitcoin node variants are reachable on archy-net by name. // Bitcoin distribution runs as a container named `bitcoin-<distro>`
// (or bare `bitcoin`), all reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"]; const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = tokio::process::Command::new("podman") let names = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"]) .args(["ps", "--format", "{{.Names}}"])
@ -3105,12 +3106,23 @@ impl ProdContainerOrchestrator {
.filter(|o| o.status.success()) .filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default(); .unwrap_or_default();
names let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
.lines() // Prefer a known name in priority order…
.map(|l| l.trim()) if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
.find(|name| BITCOIN_NAMES.contains(name)) return hit.to_string();
.map(|name| name.to_string()) }
.unwrap_or_else(|| "bitcoin-knots".to_string()) // …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
} }
#[cfg(test)] #[cfg(test)]
@ -3247,10 +3259,10 @@ impl ProdContainerOrchestrator {
let mut env = manifest.app.environment.clone(); let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts)); env.extend(manifest.app.container.resolve_derived_env(&facts));
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" { // FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL=")); // derived_env (works on Knots/Core/any distro). The old hardcoded
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string()); // `bitcoin-knots` override was removed 2026-07-22 — it defeated the
} // derived-env and broke Core nodes.
let provider = FileSecretsProvider { let provider = FileSecretsProvider {
root: self.secrets_dir.clone(), root: self.secrets_dir.clone(),

View File

@ -51,9 +51,25 @@ pub enum AccessControl {
PeersOnly, PeersOnly,
Paid { Paid {
price_sats: u64, price_sats: u64,
/// Payment methods the sharer accepts: "lightning", "onchain",
/// "ecash", "fedimint". Empty = everything — which is also what
/// catalogs written before this field deserialize to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
accepted: Vec<String>,
}, },
} }
/// Does the sharer accept this payment method for the item? Empty list =
/// all methods (pre-field catalogs and "no preference").
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
match access {
AccessControl::Paid { accepted, .. } => {
accepted.is_empty() || accepted.iter().any(|m| m == method)
}
_ => true,
}
}
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct ContentCatalog { pub struct ContentCatalog {
pub items: Vec<ContentItem>, pub items: Vec<ContentItem>,
@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
} }
/// Add a content item to the catalog. /// Add a content item to the catalog.
///
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
/// every call, so id-only dedup let the same file be shared twice as two
/// separately-priced entries — and a buyer paid twice for one file
/// (2026-07-22). Same filename → update the existing entry in place and
/// keep its id, so existing buyers' owned records stay valid.
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> { pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
let mut catalog = load_catalog(data_dir).await?; let mut catalog = load_catalog(data_dir).await?;
if catalog.items.iter().any(|i| i.id == item.id) { if catalog.items.iter().any(|i| i.id == item.id) {
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id)); return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
} }
let norm = |f: &str| f.trim_start_matches('/').to_string();
if let Some(existing) = catalog
.items
.iter_mut()
.find(|i| norm(&i.filename) == norm(&item.filename))
{
let keep_id = existing.id.clone();
*existing = item;
existing.id = keep_id;
} else {
catalog.items.push(item); catalog.items.push(item);
}
save_catalog(data_dir, &catalog).await?; save_catalog(data_dir, &catalog).await?;
Ok(catalog) Ok(catalog)
} }
@ -252,20 +285,26 @@ pub async fn serve_content(
// Check access control // Check access control
match &item.access { match &item.access {
AccessControl::Paid { price_sats } => { AccessControl::Paid { price_sats, .. } => {
// Two ways to satisfy payment: // Two ways to satisfy payment:
// (a) a valid ecash token (the local-wallet fast path), or // (a) a valid ecash token (the local-wallet fast path), or
// (b) a Lightning-invoice payment hash this node issued and has // (b) a Lightning-invoice payment hash this node issued and has
// since confirmed settled (the "pay from any wallet" path, #46). // since confirmed settled (the "pay from any wallet" path, #46).
// Each path only counts when the sharer accepts that method.
let mut authorized = false; let mut authorized = false;
if let Some(token) = payment_token { if let Some(token) = payment_token {
if verify_payment_token(data_dir, token, *price_sats).await { if (method_accepted(&item.access, "ecash")
|| method_accepted(&item.access, "fedimint"))
&& verify_payment_token(data_dir, token, *price_sats).await
{
authorized = true; authorized = true;
} }
} }
if !authorized { if !authorized {
if let Some(hash) = invoice_hash { if let Some(hash) = invoice_hash {
if crate::content_invoice::is_paid_for(hash, id).await { if method_accepted(&item.access, "lightning")
&& crate::content_invoice::is_paid_for(hash, id).await
{
authorized = true; authorized = true;
} }
} }

View File

@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
pending_boot_starts_add(containers.iter().map(|r| r.name.clone())); pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
for (i, record) in containers.iter().enumerate() { for (i, record) in containers.iter().enumerate() {
// Skip containers that are already up — `podman start` on a running
// container produces the noisy benign conmon "Failed to create
// container" + cgroup Permission-denied journal pair (fleet log
// sweep 2026-07-22).
if container_state(&record.name).await.as_deref() == Some("running") {
report.recovered += 1;
continue;
}
info!( info!(
"Recovering container: {} (image: {})", "Recovering container: {} (image: {})",
record.name, record.image record.name, record.image
@ -936,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
} }
async fn start_existing_container(container: &str) -> bool { async fn start_existing_container(container: &str) -> bool {
info!("Recovering stack container: {}", container);
let timeout = match container { let timeout = match container {
"immich_server" | "netbird-server" => Duration::from_secs(120), "immich_server" | "netbird-server" => Duration::from_secs(120),
_ => Duration::from_secs(90), _ => Duration::from_secs(90),
}; };
if container_state(container).await.as_deref() == Some("initialized") { match container_state(container).await.as_deref() {
cleanup_container_runtime_state(container).await; // Already up — `podman start` on a running container makes conmon
// try to join the live cgroup and fail with a scary "Failed to
// create container: exit status 1" + cgroup.procs Permission denied
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
// these per day per node, all benign. Skip instead.
Some("running") => return true,
Some("initialized") => cleanup_container_runtime_state(container).await,
_ => {}
} }
info!("Recovering stack container: {}", container);
match podman_output(&["start", container], timeout).await { match podman_output(&["start", container], timeout).await {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
tokio::time::sleep(Duration::from_secs(3)).await; tokio::time::sleep(Duration::from_secs(3)).await;

View File

@ -386,6 +386,19 @@ async fn main() -> Result<()> {
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed. // flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened()); tokio::spawn(bootstrap::ensure_kiosk_hardened());
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
// nodes (older ISOs shipped no audio stack; the router also heals the
// boot-time ELD race that leaves HDMI silently unavailable).
tokio::spawn(bootstrap::ensure_audio_stack());
// TV input: gamepad→keyboard bridge so controllers work inside every app
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
tokio::spawn(bootstrap::ensure_gamepad_keys());
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
// DHCP renumbering strands them — HA never re-resolves on its own.
tokio::spawn(api::rpc::wyoming_satellite_keeper());
// Spawn periodic container snapshot (for crash recovery) // Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone()); crash_recovery::spawn_snapshot_task(config.data_dir.clone());

View File

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

View File

@ -1064,19 +1064,17 @@ impl MeshService {
self.state.peers.read().await.values().cloned().collect() self.state.peers.read().await.values().cloned().collect()
} }
/// Probe a serial port for a mesh radio without provisioning or keeping /// Refuse to probe the port the live session currently occupies (the
/// it — powers the hot-swap "device detected" modal's current-details /// probe would steal the serial port from under the session); a
/// view. Refuses to probe the port the live session currently occupies
/// (the probe would steal the serial port from under the session); a
/// detected-but-not-connected port is fair game, accepting a benign race /// detected-but-not-connected port is fair game, accepting a benign race
/// with the reconnect loop (whichever loses just retries). /// with the reconnect loop (whichever loses just retries). Split out from
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> { /// the actual probe on purpose — see `probe_device`'s doc comment.
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
let status = self.state.status.read().await; let status = self.state.status.read().await;
if status.device_connected && status.device_path.as_deref() == Some(path) { if status.device_connected && status.device_path.as_deref() == Some(path) {
anyhow::bail!("{path} is the active mesh radio — already connected"); anyhow::bail!("{path} is the active mesh radio — already connected");
} }
drop(status); Ok(())
listener::probe_device(path).await
} }
/// Get message history. /// Get message history.

View File

@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
// ─── Response parsers ─────────────────────────────────────────────────── // ─── Response parsers ───────────────────────────────────────────────────
/// Decode a device/contact name from raw frame bytes, defensively.
///
/// Name fields sit at firmware-version-dependent offsets; when an offset
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
/// hand the UI replacement-character soup ("<22>\u{618}…"). Strict rules: valid
/// UTF-8 up to the first NUL, no control characters, at least one
/// non-whitespace char — anything else gets the caller's readable fallback.
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
match std::str::from_utf8(&bytes[..end]) {
Ok(s) => {
let s = s.trim();
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
s.to_string()
} else {
fallback.to_string()
}
}
Err(_) => fallback.to_string(),
}
}
/// Parse RESP_DEVICE_INFO (0x0D) response. /// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities. /// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> { pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII // Name follows after fixed fields. A firmware whose fixed-field layout
// differs would put binary here — decode defensively so the settings
// panel never shows byte soup.
let name_start = 4; let name_start = 4;
let name = if data.len() > name_start { let name = if data.len() > name_start {
let name_end = data[name_start..] decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
} else { } else {
String::new() String::new()
}; };
@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
// name at data[99..131] (32 bytes) // name at data[99..131] (32 bytes)
let name_start = 99.min(data.len()); let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len()); let name_end = (name_start + 32).min(data.len());
let short_id = format!("{}...", &public_key_hex[..8]);
let advert_name = if data.len() > name_start { let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end]) decode_mesh_name(&data[name_start..name_end], &short_id)
.trim_end_matches('\0')
.to_string()
} else { } else {
format!("{}...", &public_key_hex[..8]) short_id
}; };
// last_advert at data[131..135] // last_advert at data[131..135]

View File

@ -556,14 +556,38 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// Scan for serial devices that could be Meshcore radios. /// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files. /// Returns paths to existing serial device files.
///
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
/// primary radio currently enumerates as, so both names always pointed at
/// the same candidate list entry and both passed this scan — confirmed live
/// 2026-07-23, this made an already-connected, working radio (connected via
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
/// hot-swap UI's active-session guard compares path strings, so it didn't
/// recognize the two aliases as the same port, showed the "device detected"
/// modal for a radio that was already set up, and probing it there opened
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
/// with — a continuous, UI-driven reset loop that only ran while that view
/// was open (matches the reported "stops when I leave, resumes when I come
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
/// dedup and is what's reported when both alias and target are present.
pub async fn detect_serial_devices() -> Vec<String> { pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new(); let mut devices = Vec::new();
let mut seen_real_paths = std::collections::HashSet::new();
for path in SERIAL_CANDIDATES { for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() { if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) { if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device"); debug!(path = %path, "Skipping known non-mesh serial device");
continue; continue;
} }
let real_path = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| std::path::PathBuf::from(path));
if !seen_real_paths.insert(real_path.clone()) {
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
continue;
}
devices.push(path.to_string()); devices.push(path.to_string());
} }
} }
@ -581,6 +605,12 @@ pub struct DetectedDeviceInfo {
pub pid: Option<String>, pub pid: Option<String>,
pub product: Option<String>, pub product: Option<String>,
pub manufacturer: Option<String>, pub manufacturer: Option<String>,
/// Unix epoch seconds of the /dev node's creation — udev recreates the
/// node on every plug, so this changes on each replug. The UI keys its
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
/// unplug/replug faster than a status poll) invalidates old dismissals
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
pub plugged_at: Option<u64>,
} }
/// Like `detect_serial_devices`, but with USB metadata per port. /// Like `detect_serial_devices`, but with USB metadata per port.
@ -588,12 +618,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
let mut out = Vec::new(); let mut out = Vec::new();
for path in detect_serial_devices().await { for path in detect_serial_devices().await {
let usb = usb_info_for_tty(&path).await; let usb = usb_info_for_tty(&path).await;
let plugged_at = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
out.push(DetectedDeviceInfo { out.push(DetectedDeviceInfo {
path, path,
vid: usb.0, vid: usb.0,
pid: usb.1, pid: usb.1,
product: usb.2, product: usb.2,
manufacturer: usb.3, manufacturer: usb.3,
plugged_at,
}); });
} }
out out

View File

@ -406,7 +406,10 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
validate_onion(onion)?; validate_onion(onion)?;
match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health") match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health")
.service(crate::settings::transport::PeerService::Messaging) .service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(30)) // 12s, not 30s: this is a liveness dot, not a data transfer. A Tor
// circuit that hasn't answered /health in 12s is "offline" for UI
// purposes; the old 30s made the Connected Nodes probes crawl.
.timeout(std::time::Duration::from_secs(12))
.send_get() .send_get()
.await .await
{ {

View File

@ -307,9 +307,19 @@ async fn send_handshake_message(
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
.tag(Tag::public_key(recipient_pk)); .tag(Tag::public_key(recipient_pk));
let _ = client.send_event_builder(builder).await; // Surface real delivery failures. Before 2026-07-22 this was `let _ =`,
// so a request no relay accepted still reported ok:true to the UI and
// the sender believed it was delivered.
let send = client.send_event_builder(builder).await;
client.disconnect().await; client.disconnect().await;
Ok(()) match send {
Ok(output) if !output.success.is_empty() => Ok(()),
Ok(output) => anyhow::bail!(
"no relay accepted the handshake event (tried {}, all failed)",
output.failed.len()
),
Err(e) => anyhow::bail!("failed to publish handshake event: {e}"),
}
} }
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never /// Send a `PeerRequest` to a discovered node's nostr pubkey. We never

View File

@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.current.fyi", "wss://relay.current.fyi",
]; ];
/// The union of the boot-config relay list and the user-managed (enabled)
/// relays — the set every nostr-facing operation should use, so senders and
/// receivers always overlap regardless of which list the operator edited.
pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec<String> {
let mut relays: Vec<String> = config_relays.to_vec();
if let Ok(store) = load_relays(data_dir).await {
for r in store.relays {
if r.enabled && !relays.contains(&r.url) {
relays.push(r.url);
}
}
}
relays
}
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> { pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
let path = data_dir.join(RELAYS_FILE); let path = data_dir.join(RELAYS_FILE);
if !path.exists() { if !path.exists() {

View File

@ -106,6 +106,9 @@ impl Server {
// seconds of hitting the mempool (works whenever LND is up; retries // seconds of hitting the mempool (works whenever LND is up; retries
// forever otherwise). User req 2026-07-22. // forever otherwise). User req 2026-07-22.
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone()); crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
// LND wedge watchdog — self-heal the silent "RPC up, server never
// ready" state instead of waiting for a human (100%-uptime req).
crate::api::rpc::lnd::spawn_lnd_health_watchdog();
// Retry Tor address in background — Tor may not be ready at startup // Retry Tor address in background — Tor may not be ready at startup
if data.server_info.tor_address.is_none() { if data.server_info.tor_address.is_none() {
@ -209,9 +212,15 @@ impl Server {
let did = let did =
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let version = data.server_info.version.clone(); let version = data.server_info.version.clone();
let relays = config.nostr_relays.clone(); // Merged relay set (config + user-managed) — publish presence
// where handshake peers actually read (2026-07-22 unification).
let data_dir_for_relays = config.data_dir.clone();
let config_relays = config.nostr_relays.clone();
let tor_proxy = config.nostr_tor_proxy.clone(); let tor_proxy = config.nostr_tor_proxy.clone();
tokio::spawn(async move { tokio::spawn(async move {
let relays =
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
.await;
if let Err(e) = nostr_handshake::publish_presence( if let Err(e) = nostr_handshake::publish_presence(
&identity_dir, &identity_dir,
&did, &did,
@ -253,6 +262,23 @@ impl Server {
.await?, .await?,
); );
// Background handshake poll: fetch inbound nostr peer requests every
// 5 minutes instead of only when a user presses the Federation Poll
// button (requests used to sit on relays unseen — 2026-07-22). The
// handler's own discoverability gate makes this a no-op until the
// user opts in.
{
let rpc = api_handler.rpc_handler().clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
rpc.background_handshake_poll().await;
}
});
}
// Initialize mesh networking service (if config has enabled: true) // Initialize mesh networking service (if config has enabled: true)
{ {
let data_dir = config.data_dir.clone(); let data_dir = config.data_dir.clone();

View File

@ -3,6 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- Must agree with the embedding neode-ui shell (color-scheme: dark):
browsers only honor a transparent same-origin iframe canvas when
embedder and iframe use the SAME color-scheme — without this, the
shell's :root{color-scheme:dark} forces this frame opaque and the
background art behind the chat vanishes (2026-07-23). -->
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />

View File

@ -0,0 +1,56 @@
# HANDOFF — deploy companion APK 0.5.1 (vc21) to nodes
**For: the agent on archi-dev-box.** User-reported failure this evening:
pairing flow on Framework PT — downloaded the companion from the node's
QR, then the pairing scan didn't work. The APK the node serves predates
today's scanner fixes; the pipeline below gets the fixed build into the
user's hands.
## What changed on main today (all merged)
- **Pairing-scanner fix** (`QrScannerOverlay.kt`): ZXing decode attempts are
frame-gated (~7/s, was every frame — the CPU contention made the preview
stutter badly enough to never decode) and PreviewView uses TextureView (no
more black flash on open). This is the likely fix for "doesn't scan".
- Three-finger menu gesture (was two-finger, collided with scroll) + one-time
teaching overlay ~2 min after login.
- Native wallet QR scanner behind `window.ArchipelagoQr` + WebView file-chooser
support; web scan modal hands live scanning to it.
- npub-keyed saved servers (pairing contract item 1, PR #106).
- Served APK refreshed: `neode-ui/public/packages/archipelago-companion.apk`
is now **0.5.3 / versionCode 23**. On top of the 0.5.1 scanner fixes it
guarantees dual-path peering — the node's LAN endpoint (direct p2p, npub-
keyed dial hints) AND the Archipelago public anchor (vps2, baked into the
app so even an old node's QR can't leave the phone LAN-only) — and fixes
the two field failures from the user's 5G test (screenshots, 21:54):
- **Mesh VPN no longer kills the phone's internet** — the IPv6-only TUN
never called `allowFamily(AF_INET)`, so Android blocked all IPv4 while
the mesh was up. Now allowed (+ `allowBypass`).
- **Off-LAN connect works**`connect()` no longer hard-fails when the
scanned LAN IP doesn't answer; it brings the mesh up and probes the
node's ULA (`meshIp`) with retries before reporting failure.
## What to do
1. Redeploy the web-ui bundle from current main to the active nodes —
web root `/opt/archipelago/web-ui/` (NOT a neode-ui/ subfolder), all
nodes the user pairs against, at minimum the one Framework PT scans.
2. Verify the served artifact really updated:
`curl -sI http://<node>/packages/archipelago-companion.apk` — size should
change (~27 MB build of 2026-07-23), or pull it and check
`aapt dump badging` shows `versionCode='21' versionName='0.5.1'`.
3. The demo stack gets its images from CI (run 100 pushed today with the new
web bundle) — confirm the Portainer stack re-pulled, or trigger its
redeploy, so the demo QR also serves vc21.
4. **Node side is half the 5G story**: away-from-home reachability needs the
NODE connected to the public anchor too. On Framework PT (and any test
node): deploy current main (node-side npub-first `fips.pair-info`), then
verify `sudo -n fipsctl show status` reports the anchor connected —
`fips.reconnect` RPC if not. A phone can dial the anchor perfectly and
still fail if the node never enrolled with it.
5. Re-test the user's exact flows with vc23 (updates any older install in
place): (a) pair ON the LAN, then switch the phone to 5G — the UI must
come up via the mesh ULA; (b) pair while ALREADY on 5G (never on the
node's LAN) — scan, VPN consent, and the connect must succeed through
the anchor. If either fails, capture `adb logcat` around the attempt and
`fipsctl show sessions` on the node, and report back.

View File

@ -38,7 +38,7 @@ Query parameters:
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). | | `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). | | `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). | | `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors (the node's seed-anchor list, capped at 4). The phone peers with these too so it can route to the node via the public mesh when the direct endpoint is unreachable (away from home / NAT). | | `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors, capped at 4. **The FIRST entry is the paired node itself** (`fnpub` at its current LAN host — the addr is a dial *hint*, the npub is the identity). The remaining entries are the node's seed anchors, and the node guarantees the Archipelago public anchor (vps2, `146.59.87.168:8444/tcp`) is present even if the operator trimmed their own list — so the phone can always rendezvous through the public mesh when the LAN endpoint is unreachable (away from home / NAT). |
Examples the web UI actually emits: Examples the web UI actually emits:
@ -87,6 +87,38 @@ Examples the web UI actually emits:
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
automatically when the LAN address stops answering. automatically when the LAN address stops answering.
## npub-first connectivity (2026-07-23 — REQUIRED app-side changes)
The QR used to be effectively IP-first: the app connected to `url`/`fhost` and
broke as soon as the LAN renumbered or the phone left home. FIPS peers on
**npubs**; IPs are only dial hints. The app must treat them that way:
1. **Identity = `fnpub`.** The saved server entry is keyed by the node's npub
(fall back to origin only when the QR has no FIPS params). Re-scanning the
same npub updates the entry even if every address changed.
2. **Peer with the node itself as the first anchor** (`fanchors[0]`): on LAN
this is direct p2p over FIPS (mDNS/known-endpoint dial via archy-fips-core
v0.4+, which discovers LAN peers without any IP pinning), so it keeps
working after DHCP renumbering.
3. **Peer with the public anchors too** (remaining `fanchors` entries — the
Archipelago vps2 anchor is always included by the node): away from LAN the
phone routes to the node's npub via the public mesh and reaches the UI at
`http://[<fip>]` (the fips0 ULA).
4. **Address selection order** for the WebView: LAN `url` when it answers →
ULA `fip` over the mesh otherwise. Never hard-fail because the scanned LAN
IP stopped existing.
(Node-side counterpart shipped 2026-07-23: `fips.pair-info` always includes
the vps2 public anchor, and the web UI prepends the node's self-anchor to
`fanchors`.)
App-side status: items 2/3 were already covered by `FipsPreferences.
upsertNodePeer` (npub-matched peers, self-anchor dedup) and item 4 by the
WebView's `meshFallbackUrl` retry. Item 1 shipped 2026-07-23: `ServerEntry`
carries `npub` (trailing serialization field, legacy entries still parse) and
`ServerPreferences` matches saved/active entries via `sameNode` — npub first,
address/port/scheme only as the LAN-only fallback.
## Testing checklist (app side) ## Testing checklist (app side)
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session. - [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.

View File

@ -0,0 +1,84 @@
# TV input: keyboard/gamepad inside iframe apps — design
**Goal (2026-07-23, user requirement):** on a TV/kiosk node, keyboard and
gamepad control must work *inside iframe apps* (IndeeHub, Jellyfin, fedimint
UI, AIUI…), easily and globally — no per-app hacks.
## What already exists
- `neode-ui/src/composables/useControllerNav.ts` — a complete spatial-nav
system for the shell: reads gamepads (`navigator.getGamepads`), moves focus
between `data-controller-container` regions, plays nav sounds. It stops at
iframe boundaries: nothing is forwarded into frames.
- Keyboard focus DOES enter iframes natively (click/Tab into the frame), and a
focused iframe receives all keys — same- or cross-origin. The gap is
gamepad→app and deliberate focus handoff shell↔frame.
- Kiosk Chromium is our process (archipelago-kiosk-launcher), X11, and the ISO
already ships `xdotool`. Most app iframes are **cross-origin**
(`http://host:port`), so shell-side script injection is impossible for them;
only the nginx-proxied `/app/...` ones are same-origin.
## Recommended architecture — two layers, both global
### Layer 1 (OS, kiosk nodes): gamepad → virtual keyboard, kernel-level
A small host daemon (`archipelago-gamepad-keys`) on kiosk nodes:
- reads game controllers via evdev (`/dev/input/event*`, capability
BTN_GAMEPAD), hotplug-aware (udev monitor or 5s rescan — same pattern as the
audio router);
- emits a **uinput virtual keyboard**: D-pad/left-stick → arrow keys, A →
Enter, B → Escape, X → Space (play/pause), Y → `f` (fullscreen in most
players), shoulders → Tab / Shift+Tab, Start → Enter, Select → Escape;
- ships exactly like the audio router: `image-recipe/configs/` script + unit,
spliced into the ISO, `include_str!` self-heal in `bootstrap.rs`, gated on
the kiosk being installed. The `archipelago` user is in `input` group OR the
unit runs as root (uinput needs it anyway — run as root, it's ~100 lines of
evdev→uinput with no network).
Why this layer wins: the browser sees a real keyboard, so **every iframe —
any origin, any app — just works** the way it does for a physical keyboard
today. Video players, web games, AIUI: all of them already have keyboard
bindings. Zero app cooperation, zero web-platform security fights.
### Layer 2 (shell): deliberate focus handoff into/out of frames
Small extension to `useControllerNav`:
- When spatial nav selects an app-session container and the user presses
A/Enter: call `iframe.focus()` (works cross-origin) — keys (real or
virtual) now flow into the app.
- A dedicated **exit chord** the daemon maps from the gamepad (e.g. Home
button → F12 or a rarely-used key): the shell listens with a *capturing*
window listener; on seeing it, `iframe.blur()` + return focus to the shell
nav. Keyboard users get the same via a documented chord (e.g. long
Escape / Ctrl+Escape — plain Escape stays with the app, players use it).
- Same-origin frames (the `/app/...` proxied set) can additionally get the
full spatial-nav treatment by running the existing nav over
`iframe.contentDocument` — nice-to-have after the layers above land.
### Optional layer 3 (per-app polish): postMessage contract
For OUR app UIs only (AIUI, fedimint, launcher pages): a tiny
`archipelago:input` postMessage contract for semantic actions (back, home,
context-menu) where raw keys aren't expressive enough. Documented in the app
packaging docs; never required for an app to be usable.
## What NOT to do
- ❌ CDP (`--remote-debugging-port` + Input.dispatchKeyEvent): works but adds
a privileged debug port to the kiosk and a daemon↔browser coupling; the
uinput route gets the same result at kernel level with no attack surface.
- ❌ Per-app nav scripts injected into iframes: cross-origin makes this
impossible for most apps, and it's exactly the per-app hack the requirement
rules out.
## Implementation order
1. `archipelago-gamepad-keys` daemon (evdev→uinput, ~python3 stdlib or small
Rust bin) + unit + ISO splice + bootstrap self-heal. Test on Framework PT
with any USB/BT controller.
2. `useControllerNav`: A-button → `iframe.focus()` on the focused app session;
exit-chord capture listener to reclaim focus.
3. (Later) same-origin spatial nav inside `/app/...` frames; postMessage
contract for our own app UIs.

View File

@ -387,6 +387,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
xorg \ xorg \
xdotool \ xdotool \
chromium \ chromium \
pipewire \
pipewire-pulse \
pipewire-alsa \
wireplumber \
alsa-utils \
unclutter \ unclutter \
fonts-liberation \ fonts-liberation \
fonts-noto-color-emoji \ fonts-noto-color-emoji \
@ -425,7 +430,10 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
# Create archipelago user with password "archipelago" # Create archipelago user with password "archipelago"
RUN useradd -m -s /bin/bash -G sudo,dialout archipelago && \ # audio group: PipeWire runs under the lingering user manager (no logind seat
# session), so udev's seat ACLs on /dev/snd never apply — group access is the
# only way the kiosk's audio can open the hardware.
RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \
echo "archipelago:archipelago" | chpasswd && \ echo "archipelago:archipelago" | chpasswd && \
echo "root:archipelago" | chpasswd && \ echo "root:archipelago" | chpasswd && \
echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago
@ -1162,6 +1170,20 @@ if [ "$BACKEND_CAPTURED" = "0" ] && [ "$BUILD_FROM_SOURCE" != "1" ]; then
fi fi
fi fi
# Bundle the Reticulum RNode daemon alongside the backend. install-to-disk
# copies everything in archipelago/bin/ to /usr/local/bin, and the mesh
# listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios —
# a node imaged without it can never connect a Reticulum stick
# (framework-pt, 2026-07-22: silent connect failures until hand-copied).
RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}"
if [ -f "$RETICULUM_DAEMON" ]; then
cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon"
chmod +x "$ARCH_DIR/bin/archy-reticulum-daemon"
echo " ✅ Reticulum daemon bundled ($(du -h "$ARCH_DIR/bin/archy-reticulum-daemon" | cut -f1))"
else
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
fi
if [ "$BACKEND_CAPTURED" = "0" ]; then if [ "$BACKEND_CAPTURED" = "0" ]; then
if [ "$BUILD_FROM_SOURCE" != "1" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then
echo " ⚠️ Could not capture from live server, building from source..." echo " ⚠️ Could not capture from live server, building from source..."
@ -2867,6 +2889,62 @@ fi
echo "KIOSKSVC" echo "KIOSKSVC"
} >> "$ARCH_DIR/auto-install.sh" } >> "$ARCH_DIR/auto-install.sh"
# Audio router: same splice-from-configs/ pattern as the kiosk (single source
# of truth, also embedded in the binary for bootstrap self-heal). Keeps HDMI
# audio working: profile/default-sink routing + the ELD boot-race re-modeset.
AUDIO_ROUTER_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.sh"
AUDIO_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.service"
for _audio_src in "$AUDIO_ROUTER_SRC" "$AUDIO_SERVICE_SRC"; do
if [ ! -f "$_audio_src" ]; then
echo "ERROR: audio config file missing: $_audio_src" >&2
exit 1
fi
done
if grep -qx 'AUDIOROUTER' "$AUDIO_ROUTER_SRC" || grep -qx 'AUDIOSVC' "$AUDIO_SERVICE_SRC"; then
echo "ERROR: audio config contains a reserved heredoc terminator line (AUDIOROUTER/AUDIOSVC)" >&2
exit 1
fi
{
echo ""
echo "# Audio router — HDMI profile/default-sink follow + ELD boot-race heal."
echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)."
echo "cat > /mnt/target/usr/local/bin/archipelago-audio-router <<'AUDIOROUTER'"
cat "$AUDIO_ROUTER_SRC"
echo "AUDIOROUTER"
echo "chmod +x /mnt/target/usr/local/bin/archipelago-audio-router"
echo ""
echo "cat > /mnt/target/etc/systemd/system/archipelago-audio-router.service <<'AUDIOSVC'"
cat "$AUDIO_SERVICE_SRC"
echo "AUDIOSVC"
} >> "$ARCH_DIR/auto-install.sh"
# Gamepad->keyboard bridge: same pattern (configs/ single source of truth,
# also self-healed via the binary). TV input for every app iframe.
GAMEPAD_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.py"
GAMEPAD_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.service"
for _pad_src in "$GAMEPAD_SRC" "$GAMEPAD_SERVICE_SRC"; do
if [ ! -f "$_pad_src" ]; then
echo "ERROR: gamepad config file missing: $_pad_src" >&2
exit 1
fi
done
if grep -qx 'GAMEPADPY' "$GAMEPAD_SRC" || grep -qx 'GAMEPADSVC' "$GAMEPAD_SERVICE_SRC"; then
echo "ERROR: gamepad config contains a reserved heredoc terminator line (GAMEPADPY/GAMEPADSVC)" >&2
exit 1
fi
{
echo ""
echo "# Gamepad->keyboard bridge — controller input inside every app iframe on the TV."
echo "cat > /mnt/target/usr/local/bin/archipelago-gamepad-keys <<'GAMEPADPY'"
cat "$GAMEPAD_SRC"
echo "GAMEPADPY"
echo "chmod +x /mnt/target/usr/local/bin/archipelago-gamepad-keys"
echo ""
echo "cat > /mnt/target/etc/systemd/system/archipelago-gamepad-keys.service <<'GAMEPADSVC'"
cat "$GAMEPAD_SERVICE_SRC"
echo "GAMEPADSVC"
} >> "$ARCH_DIR/auto-install.sh"
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT' cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
# Toggle script: sudo archipelago-kiosk enable|disable|status # Toggle script: sudo archipelago-kiosk enable|disable|status
@ -3246,6 +3324,8 @@ chroot /mnt/target systemctl enable archipelago-first-boot-secrets.service 2>/de
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-gamepad-keys.service 2>/dev/null || true
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail) # Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \ chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \

View File

@ -0,0 +1,20 @@
[Unit]
Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal)
# Talks to the archipelago user's PipeWire (running under the lingering user
# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start
# after both are plausibly up. Missing/inactive units here are harmless.
After=user@1000.service archipelago-kiosk.service
Wants=user@1000.service
ConditionPathExists=/usr/local/bin/archipelago-audio-router
[Service]
Type=simple
User=archipelago
ExecStart=/usr/local/bin/archipelago-audio-router
Restart=always
RestartSec=10
# Polls a few pactl calls every 5s — keep it invisible to the scheduler.
Nice=10
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,145 @@
#!/bin/bash
# Archipelago audio router — keep audio flowing out of the display the user is
# actually looking at.
#
# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock
# profile priorities rank the laptop's analog output above HDMI, so a node
# driving a TV plays video sound out of its own tiny speakers (or nowhere).
# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening
# monitor; this daemon polls that via the card's profile availability and:
#
# - switches the card to the best *available* HDMI stereo profile (the
# "+input:analog-stereo" combined variant when offered, so the mic keeps
# working), and back to analog when HDMI is unplugged;
# - keeps the default sink pointed at the routed output and migrates any
# live streams so playback follows a hot-plug without a page reload;
# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns
# the real volume control; analog volume is left where the user set it).
#
# Runs as the archipelago user (systemd system unit with User=archipelago),
# talks only to the user-session PipeWire; polling is a few pactl calls every
# 5s — negligible. Surround profiles are deliberately ignored: stereo is the
# lowest-common-denominator every TV decodes.
# - re-modesets an external output once when it is connected but no ELD
# reports a monitor: the kiosk's boot-time Xorg modeset can beat the
# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI
# profile stays "available: no" forever (no sound, no error). One
# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV).
RUNTIME_DIR="/run/user/$(id -u)"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}"
export DISPLAY="${DISPLAY:-:0}"
# The user manager socket-activates pipewire, but after a live package install
# (bootstrap self-heal) nothing has poked it yet — start best-effort.
systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true
systemctl --user start wireplumber.service 2>/dev/null || true
LAST_SINK=""
NUDGED_OUTPUTS=""
# Re-deliver a lost ELD by cycling the connected external output once. Guarded:
# only when X answers, only when NO ELD anywhere reports a monitor, and only
# once per connector while it stays connected (a display with no audio support
# never produces an ELD — without the flag we would blank it every pass).
eld_nudge_once() {
# Any ELD already valid → audio path is live; reset the nudge memory.
if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then
NUDGED_OUTPUTS=""
return 0
fi
local xrandr_out conn name output mode
xrandr_out=$(xrandr --query 2>/dev/null) || return 0
for conn in /sys/class/drm/card*-*/status; do
[ -e "$conn" ] || continue
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
name=${conn%/status}; name=${name##*/card?-}
case "$name" in eDP*|LVDS*) continue ;; esac
case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac
# DRM connector names match the modesetting driver's output names.
output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}')
[ -n "$output" ] || continue
# Keep the mode the kiosk chose (it may have capped a 4K panel);
# --auto only as a fallback.
mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } }
active && /^[^[:space:]]/ { active = 0 }')
NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name"
xrandr --output "$output" --off 2>/dev/null || true
sleep 1
if [ -n "$mode" ]; then
xrandr --output "$output" --mode "$mode" 2>/dev/null \
|| xrandr --output "$output" --auto 2>/dev/null || true
else
xrandr --output "$output" --auto 2>/dev/null || true
fi
sleep 2
done
}
route_once() {
local cards_dump card active want sink default_sink
cards_dump=$(pactl list cards 2>/dev/null) || return 0
[ -n "$cards_dump" ] || return 0
# One line per card: "<card>\t<active>\t<wanted-profile>"
# wanted = highest-priority available output:hdmi-stereo* profile, else
# highest-priority available output:analog* profile.
while IFS=$'\t' read -r card active want; do
[ -n "$card" ] && [ -n "$want" ] || continue
if [ "$active" != "$want" ]; then
pactl set-card-profile "$card" "$want" 2>/dev/null || true
fi
done < <(printf '%s\n' "$cards_dump" | awk '
function flush() {
if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog)
card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1
}
/^Card #/ { flush() }
/^\tName: / { card=$2 }
/^\t\toutput:/ {
line=$0; sub(/^\t\t/, "", line)
prof=line; sub(/: .*/, "", prof)
prio=0
if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0
avail = (line ~ /available: yes/ || line ~ /availability unknown/)
if (!avail) next
if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } }
else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } }
}
/^\tActive Profile: / { active=$3 }
END { flush() }
')
# Point the default sink at HDMI when one exists, else the first sink.
sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}')
[ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}')
[ -n "$sink" ] || return 0
default_sink=$(pactl get-default-sink 2>/dev/null)
if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then
pactl set-default-sink "$sink" 2>/dev/null || true
pactl set-sink-mute "$sink" 0 2>/dev/null || true
case "$sink" in
*hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;;
esac
# Migrate live streams so playing audio follows the hot-plug.
pactl list short sink-inputs 2>/dev/null | while read -r id _; do
pactl move-sink-input "$id" "$sink" 2>/dev/null || true
done
LAST_SINK="$sink"
fi
}
while true; do
eld_nudge_once
route_once
sleep 5
done

View File

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Archipelago gamepad→keyboard bridge (kiosk nodes).
Reads every attached game controller via evdev and mirrors it as a virtual
uinput KEYBOARD, so gamepad input works in every app including cross-origin
iframes (IndeeHub, Jellyfin, ) where the web shell can never inject events.
The browser just sees arrow/Enter/Escape keys from a real-looking keyboard;
X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md.
Mapping (standard pad):
D-pad / left stick -> arrow keys
A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape
X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen)
LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab
Start -> Enter Select -> Escape
*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f either way
one is play/pause and one is fullscreen, which is fine for a TV.
Pure stdlib (struct/fcntl/select) no python3-evdev dependency on the node.
Runs as root (uinput + /dev/input need it); hotplug via 5s rescans.
"""
import fcntl
import os
import select
import struct
import time
# ---- kernel constants ------------------------------------------------------
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
SYN_REPORT = 0
KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57
KEY_LEFTSHIFT, KEY_F = 42, 33
KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108
BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B
BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value
BUTTON_MAP = {
BTN_SOUTH: (KEY_ENTER,),
BTN_EAST: (KEY_ESC,),
BTN_NORTH: (KEY_SPACE,),
BTN_WEST: (KEY_F,),
BTN_TL: (KEY_LEFTSHIFT, KEY_TAB),
BTN_TR: (KEY_TAB,),
BTN_START: (KEY_ENTER,),
BTN_SELECT: (KEY_ESC,),
BTN_DPAD_UP: (KEY_UP,),
BTN_DPAD_DOWN: (KEY_DOWN,),
BTN_DPAD_LEFT: (KEY_LEFT,),
BTN_DPAD_RIGHT: (KEY_RIGHT,),
}
EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys}
| {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT})
STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses"
def is_gamepad(fd) -> bool:
buf = bytearray(96)
try:
fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf)
except OSError:
return False
def has(code):
return bool(buf[code // 8] & (1 << (code % 8)))
return has(BTN_SOUTH) or has(BTN_START)
class VirtualKeyboard:
def __init__(self):
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
for key in EMITTED_KEYS:
fcntl.ioctl(self.fd, UI_SET_KEYBIT, key)
# Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects
# + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel.
name = b"Archipelago Gamepad Keys"
setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0)
setup += b"\0" * (64 * 4 * 4)
os.write(self.fd, setup)
fcntl.ioctl(self.fd, UI_DEV_CREATE)
def _emit(self, etype, code, value):
os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value))
def set_key(self, key, pressed):
self._emit(EV_KEY, key, 1 if pressed else 0)
self._emit(EV_SYN, SYN_REPORT, 0)
def chord(self, keys, pressed):
seq = keys if pressed else tuple(reversed(keys))
for k in seq:
self._emit(EV_KEY, k, 1 if pressed else 0)
self._emit(EV_SYN, SYN_REPORT, 0)
class PadState:
"""Per-device axis state → synthetic arrow presses."""
def __init__(self):
self.axis_keys = {} # axis -> currently-pressed arrow key (or None)
self.abs_range = {} # axis -> (min, max) for sticks
def arrow_for(self, axis, value):
if axis in (ABS_HAT0X, ABS_HAT0Y):
if value < 0:
return KEY_LEFT if axis == ABS_HAT0X else KEY_UP
if value > 0:
return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN
return None
lo, hi = self.abs_range.get(axis, (-32768, 32767))
span = (hi - lo) or 1
norm = (2 * (value - lo) / span) - 1
if norm <= -STICK_THRESHOLD:
return KEY_LEFT if axis == ABS_X else KEY_UP
if norm >= STICK_THRESHOLD:
return KEY_RIGHT if axis == ABS_X else KEY_DOWN
return None
def stick_range(fd, axis):
# EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res }
buf = bytearray(24)
try:
fcntl.ioctl(fd, 0x80184540 + axis, buf)
_, lo, hi = struct.unpack("iii", bytes(buf[:12]))
if hi > lo:
return (lo, hi)
except OSError:
pass
return (-32768, 32767)
def main():
os.system("modprobe uinput 2>/dev/null")
kbd = VirtualKeyboard()
pads = {} # path -> (fd, PadState)
last_scan = 0.0
while True:
now = time.monotonic()
if now - last_scan > 5:
last_scan = now
try:
names = sorted(os.listdir("/dev/input"))
except FileNotFoundError:
names = []
for name in names:
if not name.startswith("event"):
continue
path = "/dev/input/" + name
if path in pads:
continue
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
continue
if is_gamepad(fd):
state = PadState()
for axis in (ABS_X, ABS_Y):
state.abs_range[axis] = stick_range(fd, axis)
pads[path] = (fd, state)
print(f"gamepad attached: {path}", flush=True)
else:
os.close(fd)
if not pads:
time.sleep(2)
continue
readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0)
gone = []
for path, (fd, state) in list(pads.items()):
if fd not in readable:
continue
try:
data = os.read(fd, INPUT_EVENT.size * 64)
except OSError:
gone.append(path)
continue
for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1):
keys = BUTTON_MAP[code]
if len(keys) == 1:
kbd.set_key(keys[0], value == 1)
else:
kbd.chord(keys, value == 1)
elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y):
want = state.arrow_for(code, value)
held = state.axis_keys.get(code)
if want != held:
if held is not None:
kbd.set_key(held, False)
if want is not None:
kbd.set_key(want, True)
state.axis_keys[code] = want
for path in gone:
fd, state = pads.pop(path)
for held in state.axis_keys.values():
if held is not None:
kbd.set_key(held, False)
try:
os.close(fd)
except OSError:
pass
print(f"gamepad detached: {path}", flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,16 @@
[Unit]
Description=Archipelago gamepad->keyboard bridge (TV/kiosk input in every app iframe)
# Only meaningful where a display UI runs; the kiosk unit is the marker.
ConditionPathExists=/etc/systemd/system/archipelago-kiosk.service
ConditionPathExists=/usr/local/bin/archipelago-gamepad-keys
[Service]
Type=simple
# Root: /dev/uinput device creation + raw /dev/input readers.
ExecStart=/usr/bin/python3 /usr/local/bin/archipelago-gamepad-keys
Restart=always
RestartSec=10
Nice=5
[Install]
WantedBy=multi-user.target

View File

@ -174,6 +174,9 @@ while true; do
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native, # 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 # falls back to raw ALSA "default", fails to connect, and produces no audio
# at all with no visible error (--noerrdialogs suppresses it). # at all with no visible error (--noerrdialogs suppresses it).
# OverlayScrollbar: thin auto-hiding scrollbars (the Chrome-on-a-remote-
# device look) instead of classic X11 scrollbar chrome — a kiosk TV showed
# a permanent fat scrollbar on scrollable views (Peers).
# Force a DARK color-scheme preference. The main UI hardcodes its dark # Force a DARK color-scheme preference. The main UI hardcodes its dark
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)` # theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
# and defaults to its LIGHT variant (white panels) when the browser reports # and defaults to its LIGHT variant (white panels) when the browser reports
@ -191,6 +194,7 @@ while true; do
--no-first-run \ --no-first-run \
--check-for-update-interval=31536000 \ --check-for-update-interval=31536000 \
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \ --disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
--enable-features=OverlayScrollbar \
--disable-session-crashed-bubble \ --disable-session-crashed-bubble \
--disable-save-password-bubble \ --disable-save-password-bubble \
--disable-suggestions-service \ --disable-suggestions-service \

View File

@ -14,6 +14,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Archipelago - Your sovereign personal server" /> <meta name="description" content="Archipelago - Your sovereign personal server" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<!-- Dark-only app: pin native controls dark before CSS loads. -->
<meta name="color-scheme" content="dark" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

View File

@ -89,19 +89,43 @@ class FileBrowserClient {
return h return h
} }
/** Don't hammer app.filebrowser-token after a failed login one attempt
* per cooldown window, so a broken/missing filebrowser doesn't turn every
* poll of the Files card into a fresh login + 401 pair (console spam). */
private _lastLoginFailure = 0
private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000
/** Ensure we're authenticated before making a request. Auto-logins if needed. */ /** Ensure we're authenticated before making a request. Auto-logins if needed. */
private async ensureAuth(): Promise<void> { private async ensureAuth(): Promise<void> {
if (this._authenticated && this.getAuthCookie()) return if (this._authenticated && this.getAuthCookie()) return
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
const ok = await this.login() const ok = await this.login()
if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in') if (!ok) {
this._lastLoginFailure = Date.now()
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
}
/** fetch() with auth headers + ONE transparent re-login on 401. The JWT
* from app.filebrowser-token is short-lived; before this, an expired
* cookie kept `_authenticated` true and every Files-card poll 401'd
* forever (the console-spam bug, 2026-07-22). */
private async authedFetch(url: string, init?: RequestInit): Promise<Response> {
await this.ensureAuth()
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
if (res.status === 401) {
this._authenticated = false
await this.ensureAuth()
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
}
return res
} }
async listDirectory(path: string): Promise<FileBrowserItem[]> { async listDirectory(path: string): Promise<FileBrowserItem[]> {
await this.ensureAuth()
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
headers: this.headers(),
})
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`) if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and // When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502. // returns index.html (200, text/html); when it's down it returns 502.
@ -133,11 +157,8 @@ class FileBrowserClient {
* For large files (video/audio), prefer streamUrl() instead. * For large files (video/audio), prefer streamUrl() instead.
*/ */
async fetchBlobUrl(path: string): Promise<string> { async fetchBlobUrl(path: string): Promise<string> {
await this.ensureAuth()
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`) if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
return URL.createObjectURL(blob) return URL.createObjectURL(blob)
@ -171,17 +192,12 @@ class FileBrowserClient {
} }
async upload(dirPath: string, file: File): Promise<void> { async upload(dirPath: string, file: File): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(dirPath) const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name) const encodedName = encodeURIComponent(file.name)
const res = await fetch( const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`, `${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
{ { method: 'POST', body: file },
method: 'POST',
headers: this.headers(),
body: file,
},
) )
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
@ -190,35 +206,31 @@ class FileBrowserClient {
} }
async createFolder(parentPath: string, name: string): Promise<void> { async createFolder(parentPath: string, name: string): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(parentPath) const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '') const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
method: 'POST', method: 'POST',
headers: this.headers(),
}) })
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`) if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
} }
async deleteItem(path: string): Promise<void> { async deleteItem(path: string): Promise<void> {
await this.ensureAuth()
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE', method: 'DELETE',
headers: this.headers(),
}) })
if (!res.ok) throw new Error(`Delete failed: ${res.status}`) if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
} }
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> { async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
if (!this._authenticated || !this.getAuthCookie()) { let res: Response
const ok = await this.login() try {
if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } res = await this.authedFetch(`${this.baseUrl}/api/resources/`)
} catch {
// Not installed / login cooling down — the Files card shows zeros.
return { totalSize: 0, folderCount: 0, fileCount: 0 }
} }
const res = await fetch(`${this.baseUrl}/api/resources/`, {
headers: this.headers(),
})
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
const data: FileBrowserListResponse = await res.json() const data: FileBrowserListResponse = await res.json()
const items = data.items || [] const items = data.items || []
@ -242,17 +254,11 @@ class FileBrowserClient {
} }
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> { async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
if (!this._authenticated || !this.getAuthCookie()) {
const ok = await this.login()
if (!ok) throw new Error('FileBrowser authentication failed')
}
if (!this.isTextFile(path)) { if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`) throw new Error(`Cannot read binary file: ${path}`)
} }
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`) if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
const size = blob.size const size = blob.size
@ -266,12 +272,9 @@ class FileBrowserClient {
const safePath = sanitizePath(oldPath) const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '') const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: { 'Content-Type': 'application/json' },
...this.headers(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }), body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
}) })
if (!res.ok) throw new Error(`Rename failed: ${res.status}`) if (!res.ok) throw new Error(`Rename failed: ${res.status}`)

View File

@ -447,7 +447,8 @@ class RPCClient {
return this.call({ return this.call({
method: 'node-check-peer', method: 'node-check-peer',
params: { onion }, params: { onion },
timeout: 35000, // Backend health-dial timeout is 12s — 15s here covers RPC overhead.
timeout: 15000,
}) })
} }

View File

@ -104,11 +104,11 @@ function handleClickOutside(e: MouseEvent) {
} }
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('pointerdown', handleClickOutside)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('pointerdown', handleClickOutside)
}) })
</script> </script>

View File

@ -8,10 +8,16 @@
@click.self="close" @click.self="close"
> >
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div> <div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Column layout (2026-07-22 modal contract): title row and the
optional #header slot (tabs) stay pinned at the top, the #footer
slot (action buttons) stays pinned at the bottom, and ONLY the
default slot scrolls. Callers that previously made the whole
card scroll via contentClass keep working the inner region
simply never lets the card overflow. -->
<div <div
ref="modalRef" ref="modalRef"
class="glass-card p-6 w-full relative z-10" class="glass-card p-6 w-full relative z-10 flex flex-col"
:class="[maxWidth, contentClass]" :class="[maxWidth, contentClass, defaultMaxH]"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@click.stop @click.stop
@ -28,10 +34,17 @@
</svg> </svg>
</button> </button>
</div> </div>
<div v-if="$slots.header" class="shrink-0">
<slot name="header" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto">
<slot /> <slot />
</div>
<div v-if="$slots.footer" class="shrink-0 pt-4">
<slot name="footer" /> <slot name="footer" />
</div> </div>
</div> </div>
</div>
</Transition> </Transition>
</Teleport> </Teleport>
</template> </template>
@ -60,6 +73,13 @@ const emit = defineEmits<{
const modalRef = ref<HTMLElement | null>(null) const modalRef = ref<HTMLElement | null>(null)
const zClass = computed(() => props.zIndex) const zClass = computed(() => props.zIndex)
// The pinned-footer layout needs a height bound or tall content pushes the
// footer off-screen anyway. Callers that set their own max-h (e.g. the
// Transactions modal's visual-viewport calc on mobile) keep authority
// adding a second max-h class would make the CSS winner order-dependent.
const defaultMaxH = computed(() =>
props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]'
)
function close() { function close() {
emit('close') emit('close')

View File

@ -252,21 +252,39 @@ watch(visible, async (isVisible) => {
}) })
}, { immediate: true, flush: 'post' }) }, { immediate: true, flush: 'post' })
/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */
function isTailnetIp(host: string): boolean {
const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
}
/** /**
* The server URL the companion app should connect to. The demo advertises its * The server URL the companion app should connect to. The demo advertises its
* public https origin; a real node advertises whatever address the browser is * public https origin; a real node advertises the browser's own origin ONLY
* already using except on the kiosk, where that is localhost and useless to * when a phone could plausibly reach it too. Two origins that a phone on the
* a phone, so fall back to the node's mDNS .local name. * LAN can never dial get substituted with the node's real LAN address:
* - localhost/127.0.0.1 (the kiosk browses itself)
* - a tailnet 100.x address (operator browsing over Tailscale a scanned
* QR carried one of these on 2026-07-22 and the companion sat there
* dialing an IP the phone had no route to)
*/ */
async function resolveServerUrl(): Promise<string> { async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL if (IS_DEMO) return DEMO_SERVER_URL
const { hostname, origin } = window.location const { hostname, origin } = window.location
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin const phoneUnreachable =
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
if (!phoneUnreachable) return origin
try { try {
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' }) const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
method: 'system.get-hostname',
})
// Prefer the LAN IP (phones resolve it without mDNS support Android
// notoriously lacks .local); fall back to the mDNS name.
if (res?.lan_ip) return `http://${res.lan_ip}`
if (res?.mdns_hostname) return `http://${res.mdns_hostname}` if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
} catch { } catch {
// RPC unavailable fall through to the (localhost) origin // RPC unavailable fall through to the (unreachable) origin; the app
// still lets the user edit the address by hand.
} }
return origin return origin
} }
@ -330,9 +348,24 @@ async function buildPairingUrl(): Promise<string> {
if (info.udp_port) params.set('fudp', String(info.udp_port)) if (info.udp_port) params.set('fudp', String(info.udp_port))
if (info.tcp_port) params.set('ftcp', String(info.tcp_port)) if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
// Rendezvous anchors (compact: npub@addr/transport, comma-joined). // Rendezvous anchors (compact: npub@addr/transport, comma-joined).
// Cap keeps the QR at a camera-friendly density; the node lists its // FIRST entry is the paired node ITSELF: the phone peers with it by
// most reachable anchors first. // npub (the fhost addr is only a dial hint), so LAN contact is direct
const anchors = (info.anchors || []).slice(0, 4) // p2p over FIPS and survives DHCP renumbering; the node's public
// anchors follow for reaching it away from home. Cap keeps the QR at a
// camera-friendly density; the node lists its most reachable anchors
// first.
const selfAnchor = info.tcp_port
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
: []
// HARD CAP at 2 anchors: every extra npub adds ~100 URL-encoded chars
// and pushed the QR past what phone cameras decode off a screen
// (3 anchors 600 chars QR v23 observed unscannable 2026-07-23).
// Self + one public rendezvous is all the phone needs; prefer the
// Archipelago-operated vps2 anchor as the public one.
const others = (info.anchors || []).filter((a) => a.npub !== info.npub)
const publicAnchor =
others.find((a) => a.addr.startsWith('146.59.87.168')) ?? others[0]
const anchors = [...selfAnchor, ...(publicAnchor ? [publicAnchor] : [])].slice(0, 2)
if (anchors.length) { if (anchors.length) {
params.set( params.set(
'fanchors', 'fanchors',
@ -357,10 +390,13 @@ async function showPairScreen() {
pairingUrl.value = await buildPairingUrl() pairingUrl.value = await buildPairingUrl()
// Large source + a real quiet zone; this QR is scanned by the companion // Large source + a real quiet zone; this QR is scanned by the companion
// app's camera, so give it every advantage (see download QR note above). // app's camera, so give it every advantage (see download QR note above).
// EC level L: the payload is long (token + npub + anchors) and screen
// scans don't suffer the damage EC-M protects against L drops the
// module count a full version tier, which is what makes it scannable.
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, { pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
width: 512, width: 768,
margin: 3, margin: 3,
errorCorrectionLevel: 'M', errorCorrectionLevel: 'L',
color: { color: {
dark: '#111111', dark: '#111111',
light: '#ffffff', light: '#ffffff',

View File

@ -1,8 +1,12 @@
<template> <template>
<!-- z-3600: this consent prompt can be triggered from INSIDE another
modal (e.g. Wallet Settings Channels funding tx), so it must sit
above the standard modal layer (3000) but below the app overlay (4000). -->
<BaseModal <BaseModal
:show="!!explorer.pendingTx.value" :show="!!explorer.pendingTx.value"
title="Open on an external explorer?" title="Open on an external explorer?"
max-width="max-w-md" max-width="max-w-md"
z-index="z-[3600]"
@close="explorer.cancelPending()" @close="explorer.cancelPending()"
> >
<!-- Same visual language as the uninstall keep-your-data warning: amber <!-- Same visual language as the uninstall keep-your-data warning: amber

View File

@ -131,10 +131,10 @@ async function loadIdentities() {
onMounted(() => { onMounted(() => {
loadIdentities() loadIdentities()
document.addEventListener('click', onClickOutside) document.addEventListener('pointerdown', onClickOutside)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside) document.removeEventListener('pointerdown', onClickOutside)
}) })
</script> </script>

View File

@ -301,7 +301,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>() defineProps<{ compact?: boolean }>()
@ -390,10 +390,14 @@ function fundingTxid(ch: Channel): string {
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : '' return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
} }
const txExplorer = useTxExplorer()
function openInMempool(txid: string) { function openInMempool(txid: string) {
if (!txid) return if (!txid) return
// Overlay the explorer above the current page never navigate away. // Same routing as every other tx link (missed in the first pass
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` }) // 2026-07-22): local Mempool app when it's running, otherwise the saved
// external explorer, with the first-time consent modal setting it up and
// then opening the tx.
txExplorer.openTx(txid)
} }
function capacityPercent(amount: number, capacity: number): number { function capacityPercent(amount: number, capacity: number): number {

View File

@ -1,14 +1,63 @@
<template> <template>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close"> <BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
<template v-if="confirming">
<div class="mb-3 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Method</span>
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
</div>
<div v-if="dest.trim()" class="mb-1">
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
</div>
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share sats leave your balance when it's redeemed.</p>
</div>
<!-- Live balance impact -->
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '…' : confirmBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50 flex items-center gap-2">
Amount
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</span>
<span class="text-sm font-medium text-white/80">{{ confirmAmount.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
{{ confirmBalance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !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') : 'Confirm & Send' }}
</button>
</div>
</template>
<template v-else>
<!-- Method tabs --> <!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)" v-for="m in (['lightning', 'onchain', 'ecash', 'fedimint', 'ark'] as const)"
:key="m" :key="m"
@click="sendMethod = m" @click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors" class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'" :class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button> >{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}</button>
</div> </div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg"> <div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
@ -18,6 +67,7 @@
<div class="mb-3"> <div class="mb-3">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label> <label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
<button <button
v-if="sendMethod === 'onchain'" v-if="sendMethod === 'onchain'"
@click="toggleSendAll" @click="toggleSendAll"
@ -33,24 +83,44 @@
v-model.number="amount" v-model.number="amount"
type="number" type="number"
min="1" min="1"
:placeholder="sendAll ? '' : '1000'" :placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
:disabled="sendAll" :disabled="sendAll || pastedInvoiceAmount !== null"
class="w-full input-glass disabled:opacity-50" class="w-full input-glass disabled:opacity-50"
/> />
<p v-if="sendAll" class="text-xs text-white/50 mt-1"> <p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees. Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p> </p>
<p v-else-if="pastedInvoiceAmount !== null" class="text-xs text-white/50 mt-1">
This invoice fixes the amount nothing to type, just review and send.
</p>
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
Zero-amount invoice enter how many sats to pay.
</p>
</div> </div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3"> <div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1"> <div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }} {{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label> </label>
<button
v-if="canReadClipboard"
@click="pasteFromClipboard"
class="text-xs px-2 py-0.5 rounded border bg-white/5 border-white/15 text-white/60 hover:text-white/90 transition-colors"
>
{{ effectiveMethod === 'lightning' ? 'Paste invoice' : 'Paste' }}
</button>
</div>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea> <textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div> </div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg"> <div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p> <p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
(animated multi-frame not needed: qrcode handles these sizes). -->
<div class="flex justify-center my-2">
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p> <p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button> <button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div> </div>
@ -75,15 +145,16 @@
</svg> </svg>
Scan Scan
</button> </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"> <button @click="review" :disabled="processing" 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') }} {{ t('common.send') }}
</button> </button>
</div> </div>
</template>
</BaseModal> </BaseModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
@ -93,7 +164,9 @@ const { t } = useI18n()
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: []; scan: [] }>() const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto') // 'auto' remains in the type for the effectiveMethod logic but is no longer
// offered as a tab (hidden per operator request 2026-07-22).
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
const amount = ref<number>(0) const amount = ref<number>(0)
const dest = ref('') const dest = ref('')
const processing = ref(false) const processing = ref(false)
@ -120,6 +193,35 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere // Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false }) watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
async function pasteFromClipboard() {
try {
const text = (await navigator.clipboard.readText()).trim()
if (text) dest.value = text
} catch {
// Permission denied user can long-press/Ctrl+V into the field instead.
}
}
const effectiveMethod = computed(() => { const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0 const amt = amount.value || 0
@ -129,12 +231,95 @@ const effectiveMethod = computed(() => {
return 'lightning' return 'lightning'
}) })
// --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted.
const confirming = ref(false)
const confirmBalance = ref<number | null>(null)
const invoiceAmountSats = ref<number | null>(null)
const methodLabel = computed(() => ({
auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain',
ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark',
}[effectiveMethod.value]))
const methodColor = computed(() => ({
auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500',
ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400',
}[effectiveMethod.value]))
const destDisplay = computed(() => {
const d = dest.value.trim()
return d.length > 64 ? `${d.slice(0, 38)}${d.slice(-18)}` : d
})
/** 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)
}
// An invoice-fixed amount always wins over the typed one; a sweep is priced
// at the whole balance.
const confirmAmount = computed(() => {
if (isSweep.value) return confirmBalance.value ?? 0
if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value
return amount.value > 0 ? Math.floor(amount.value) : 0
})
const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value)
const insufficient = computed(() =>
confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value
)
async function loadConfirmBalance() {
confirmBalance.value = null
try {
if (effectiveMethod.value === 'ecash') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
confirmBalance.value = res.balance_sats ?? 0
} else if (effectiveMethod.value === 'fedimint') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
confirmBalance.value = res.balance_sats ?? 0
} else {
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
confirmBalance.value = effectiveMethod.value === 'onchain'
? (res.balance_sats ?? 0)
: (res.channel_balance_sats ?? 0)
}
} catch {
confirmBalance.value = null // balance preview is best-effort; send still guarded server-side
}
}
function review() {
error.value = ''
const method = effectiveMethod.value
const d = dest.value.trim()
if (method === 'lightning') {
if (!d) { error.value = t('web5.pasteInvoice'); return }
invoiceAmountSats.value = parseBolt11AmountSats(d)
} else {
invoiceAmountSats.value = null
if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return }
}
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
error.value = t('sendBitcoin.amountSats'); return
}
void loadConfirmBalance()
confirming.value = true
}
function close() { function close() {
error.value = '' error.value = ''
resultTxid.value = '' resultTxid.value = ''
resultHash.value = '' resultHash.value = ''
resultArk.value = '' resultArk.value = ''
ecashToken.value = '' ecashToken.value = ''
confirming.value = false
emit('close') emit('close')
} }
@ -142,9 +327,21 @@ function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {}) navigator.clipboard.writeText(text).catch(() => {})
} }
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
await nextTick()
if (!tokenQrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
} catch { /* QR is a convenience — the copyable text is authoritative */ }
})
async function send() { async function send() {
if (processing.value) return if (processing.value) return
if (!amount.value && !isSweep.value) return // Zero typed amount is fine when the invoice fixes the amount or we sweep.
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
processing.value = true processing.value = true
error.value = '' error.value = ''
ecashToken.value = '' ecashToken.value = ''
@ -169,6 +366,13 @@ async function send() {
params: { amount_sats: amount.value }, params: { amount_sats: amount.value },
}) })
ecashToken.value = res.token ecashToken.value = res.token
} else if (method === 'fedimint') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.fedimint-send',
params: { amount_sats: amount.value },
timeout: 60000,
})
ecashToken.value = res.token
} else if (method === 'lightning') { } else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return } if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({ const res = await rpcClient.call<{ payment_hash: string }>({
@ -187,6 +391,8 @@ async function send() {
resultTxid.value = res.txid resultTxid.value = res.txid
} }
emit('sent') emit('sent')
// Back to the form pane so the success/token panes are visible.
confirming.value = false
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed') error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally { } finally {

View File

@ -43,7 +43,24 @@
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in"> <Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
<!-- ============ SCAN PANE ============ --> <!-- ============ SCAN PANE ============ -->
<div v-if="pane === 'scan'" key="scan"> <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"> <!-- Chooser interstitial every open: the user picks live camera
or a photo upload; neither starts until chosen. -->
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
<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 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
the native bridge still provides a live camera -->
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Scan with camera
</button>
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Upload / take a photo of the QR
</button>
</div>
<div v-else 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 <!-- opacity (not v-if/v-show): the scanner needs the element, and a
source-less <video> flashes a native play glyph in Android WebViews --> 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> <video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
@ -73,6 +90,11 @@
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium"> <button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
Take photo of QR Take photo of QR
</button> </button>
</div>
</div>
<!-- Single always-mounted picker input, shared by the chooser and
the in-camera fallback button -->
<input <input
ref="photoInput" ref="photoInput"
type="file" type="file"
@ -81,8 +103,6 @@
class="hidden" class="hidden"
@change="onPhotoPicked" @change="onPhotoPicked"
/> />
</div>
</div>
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center"> <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'"> <p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
@ -253,6 +273,23 @@ type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join' type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
type Pane = 'scan' | 'amount' | 'success' type Pane = 'scan' | 'amount' | 'success'
// JS bridge the Android companion injects: when present, live scanning is
// delegated to a native camera modal (styled like this one) the WebView's
// getUserMedia preview lags, and over plain http it doesn't exist at all.
// Decodes come back through the window.__archyQr* callbacks; status lines
// (animated-QR progress, errors) mirror out to the native modal's strip.
interface ArchipelagoQrBridge {
open(): void
setStatus(message: string, isError: boolean): void
close(): void
}
interface NativeWindow extends Window {
ArchipelagoQr?: ArchipelagoQrBridge
__archyQrResult?: (text: string) => void
__archyQrCancelled?: () => void
}
const nativeWin = window as NativeWindow
const PRESETS = [21, 2100, 21000, 100000] const PRESETS = [21, 2100, 21000, 100000]
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
@ -282,7 +319,9 @@ function goBack() {
if (pane.value === 'amount') { if (pane.value === 'amount') {
error.value = '' error.value = ''
goTo('scan', 'back') goTo('scan', 'back')
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) // Only relight the camera when that's what the user chose; otherwise
// they land back on the scan/upload chooser.
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
} else if (pane.value === 'success') { } else if (pane.value === 'success') {
close() close()
} }
@ -295,6 +334,31 @@ const isScanning = ref(false)
// open) the fallback buttons stay hidden until it resolves, so they don't // open) the fallback buttons stay hidden until it resolves, so they don't
// flash for a second on every open. // flash for a second on every open.
const autoStarting = ref(false) const autoStarting = ref(false)
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
// nothing starts until the user picks, and Back from a later pane returns to
// the live camera only if that's what they chose.
const scanChoice = ref<'unset' | 'camera'>('unset')
function chooseCamera() {
if (startNativeScan()) return
scanChoice.value = 'camera'
void nextTick(() => startScanning())
}
// --- Native scanner (companion app) ---
const hasNativeQr = !!nativeWin.ArchipelagoQr
const nativeScanActive = ref(false)
function startNativeScan(): boolean {
const bridge = nativeWin.ArchipelagoQr
if (!bridge) return false
nativeWin.__archyQrResult = (text: string) => handleScanned(text)
nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false }
nativeScanActive.value = true
bridge.open()
return true
}
const scanStatus = ref('') const scanStatus = ref('')
const scanStatusIsError = ref(false) const scanStatusIsError = ref(false)
const cameraError = ref(false) const cameraError = ref(false)
@ -344,8 +408,20 @@ function stopScanning() {
qrScanner.value?.destroy() qrScanner.value?.destroy()
qrScanner.value = null qrScanner.value = null
isScanning.value = false isScanning.value = false
if (nativeScanActive.value) {
nativeScanActive.value = false
nativeWin.ArchipelagoQr?.close()
}
} }
// Mirror status lines onto the native modal while it's up it covers the
// page, so this strip is the only feedback the user can see.
watch([scanStatus, scanStatusIsError], () => {
if (nativeScanActive.value) {
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
}
})
function submitPaste() { function submitPaste() {
const text = pasteInput.value.trim() const text = pasteInput.value.trim()
if (!text) return if (!text) return
@ -363,9 +439,10 @@ async function onPhotoPicked(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
scanStatusIsError.value = false
scanStatus.value = 'Reading photo…'
try { try {
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }) handleScanned(await decodePhotoRobust(file))
handleScanned(result.data)
} catch { } catch {
scanStatusIsError.value = true scanStatusIsError.value = true
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit' scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
@ -374,6 +451,28 @@ async function onPhotoPicked(e: Event) {
} }
} }
/** Decode a QR photo with every engine we have. On the companion app the
* photo path IS the scan path (plain-http LAN = no secure context = no
* live camera), and Lightning invoices make DENSE codes that the wasm
* engine's single pass often misses (reported 2026-07-22: "camera not
* picking up the invoice"). Android WebView's native BarcodeDetector is
* far stronger on dense codes, so try it first; fall back to qr-scanner. */
async function decodePhotoRobust(file: File): Promise<string> {
try {
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).BarcodeDetector
if (Detector) {
const bmp = await createImageBitmap(file)
const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp)
const hit = codes.find(c => c.rawValue)
if (hit) return hit.rawValue
}
} catch {
// Native detector unavailable/failed wasm engine below.
}
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
return result.data
}
// --- Detection (ported from k484's scanner) --- // --- Detection (ported from k484's scanner) ---
const rail = ref<Rail>('lightning') const rail = ref<Rail>('lightning')
const action = ref<Action>('pay-invoice') const action = ref<Action>('pay-invoice')
@ -686,7 +785,8 @@ function close() {
watch(() => props.show, (open) => { watch(() => props.show, (open) => {
if (open) { if (open) {
resetAll() resetAll()
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) // No auto-start: the chooser interstitial owns the first move every time.
scanChoice.value = 'unset'
} else { } else {
stopScanning() stopScanning()
} }

View File

@ -1,6 +1,8 @@
<template> <template>
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close"> <BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh]" @close="close">
<!-- Protocol tabs --> <!-- Protocol tabs pinned via the header slot; only the pane below
scrolls (2026-07-22 modal contract). -->
<template #header>
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
@ -10,6 +12,7 @@
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'" :class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button> >{{ tab.label }}</button>
</div> </div>
</template>
<!-- ===================== Lightning Channels ===================== --> <!-- ===================== Lightning Channels ===================== -->
<div v-show="activeTab === 'channels'"> <div v-show="activeTab === 'channels'">
@ -17,9 +20,6 @@
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments. Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
</p> </p>
<LightningChannelsPanel v-if="show" compact /> <LightningChannelsPanel v-if="show" compact />
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</div> </div>
<!-- ===================== Cashu Mints ===================== --> <!-- ===================== Cashu Mints ===================== -->
@ -75,16 +75,6 @@
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div> <div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div> <div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="saveMints"
:disabled="savingMints || mints.length === 0"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ savingMints ? 'Saving…' : 'Save' }}
</button>
</div>
</template> </template>
</div> </div>
@ -133,16 +123,6 @@
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div> <div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div> <div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="joinFederation"
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ joiningFed ? 'Joining…' : 'Join federation' }}
</button>
</div>
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3"> <p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
Joining federations lands with the Fedimint client backend. Joining federations lands with the Fedimint client backend.
@ -179,9 +159,6 @@
Don't warn me each time before opening the external explorer Don't warn me each time before opening the external explorer
</label> </label>
<div class="flex gap-3 mt-6">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</div> </div>
<!-- ===================== Ark ===================== --> <!-- ===================== Ark ===================== -->
@ -269,16 +246,33 @@
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div> <div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div> <div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
<div class="flex gap-3 mt-4"> </template>
</div>
<!-- Pinned footer (2026-07-22 modal contract): Close always, plus the
active tab's primary action the buttons never scroll away. -->
<template #footer>
<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="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button <button
v-if="activeTab === 'cashu'"
@click="saveMints"
:disabled="savingMints || mints.length === 0"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingMints ? 'Saving…' : 'Save' }}</button>
<button
v-else-if="activeTab === 'fedimint'"
@click="joinFederation"
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ joiningFed ? 'Joining…' : 'Join federation' }}</button>
<button
v-else-if="activeTab === 'ark' && arkStatus?.available"
@click="saveArkConfig" @click="saveArkConfig"
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()" :disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingArk ? 'Saving…' : 'Save' }}</button> >{{ savingArk ? 'Saving…' : 'Save' }}</button>
</div> </div>
</template> </template>
</div>
</BaseModal> </BaseModal>
</template> </template>

View File

@ -2,6 +2,7 @@
<button <button
class="cloud-file-item group" class="cloud-file-item group"
data-controller-container data-controller-container
data-controller-primary
tabindex="0" tabindex="0"
@click="handleClick" @click="handleClick"
> >

View File

@ -17,12 +17,25 @@
</span> </span>
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p> <p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
</div> </div>
<div class="flex items-center gap-1">
<button
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture"
@click.stop="togglePip(videoEl)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<button class="lightbox-btn" @click="close"> <button class="lightbox-btn" @click="close">
<svg 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="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
</div>
<!-- Navigation arrows --> <!-- Navigation arrows -->
<button <button
@ -111,6 +124,7 @@
import { ref, computed, watch, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client' import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType' import { getFileCategory } from '@/composables/useFileType'
import { pipSupported, togglePip } from '@/utils/pip'
const props = defineProps<{ const props = defineProps<{
items: FileBrowserItem[] items: FileBrowserItem[]

View File

@ -87,6 +87,47 @@
/> />
<span class="share-price-unit">sats</span> <span class="share-price-unit">sats</span>
</div> </div>
<!-- Accepted payment methods (only for paid) gated on what this
node can actually receive; unavailable rails are disabled with
an that explains how to enable them. -->
<div v-if="accessType === 'paid'" class="mt-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
<div class="space-y-2">
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
<div class="flex-1 flex items-center gap-2 min-w-0">
<p class="text-sm text-white/90">{{ m.label }}</p>
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking</span>
<button
v-else-if="!capability[m.key]"
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
title="Why is this unavailable?"
@click="adviceFor = m.key"
>i</button>
</div>
<ToggleSwitch
:model-value="acceptedSet.has(m.key)"
:disabled="!capability[m.key]"
:aria-label="`Accept ${m.label}`"
@update:model-value="toggleMethod(m.key, $event)"
/>
</div>
</div>
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
Pick at least one payment method buyers can use.
</p>
</div>
</div>
<!-- Advice modal for an unavailable payment method -->
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
</div>
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
</ul>
</div> </div>
<!-- Status messages --> <!-- Status messages -->
@ -109,7 +150,7 @@
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button> <button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
<button <button
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save" class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))" :disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
@click="save" @click="save"
> >
{{ shared ? 'Share' : 'Stop Sharing' }} {{ shared ? 'Share' : 'Stop Sharing' }}
@ -144,17 +185,113 @@ const saving = ref(false)
const errorMsg = ref<string | null>(null) const errorMsg = ref<string | null>(null)
const successMsg = ref<string | null>(null) const successMsg = ref<string | null>(null)
// --- Accepted payment methods, gated on what this node can actually receive ---
const PAY_METHODS = [
{ key: 'lightning', label: 'Lightning' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'ecash', label: 'Cashu ecash' },
{ key: 'fedimint', label: 'Fedimint' },
] as const
type PayMethod = (typeof PAY_METHODS)[number]['key']
// undefined = probe in flight; then true/false per method.
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
const acceptedSet = ref<Set<PayMethod>>(new Set())
const adviceFor = ref<PayMethod | null>(null)
// Only default-select capable methods when the item had no saved list.
let acceptedLoadedFromItem = false
function toggleMethod(key: PayMethod, on: boolean) {
const next = new Set(acceptedSet.value)
if (on) next.add(key)
else next.delete(key)
acceptedSet.value = next
}
/** Probe the node's rails and build advice for the unavailable ones. */
async function probeCapabilities() {
// Lightning + on-chain both live on LND.
try {
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
method: 'lnd.getinfo', timeout: 8000,
})
capability.value.onchain = true
const channels = info?.num_active_channels ?? 0
capability.value.lightning = channels > 0
if (channels === 0) {
adviceLines.value.lightning = [
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
'Once the channel is active, come back and enable Lightning here.',
]
}
} catch {
capability.value.lightning = false
capability.value.onchain = false
adviceLines.value.lightning = [
'The Lightning (LND) app isn\'t running on this node.',
'Install/start Lightning from the App Store, let it sync, then open a channel.',
]
adviceLines.value.onchain = [
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
'Install/start Lightning from the App Store — no channel needed for on-chain.',
]
}
try {
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
capability.value.ecash = true
} catch {
capability.value.ecash = false
adviceLines.value.ecash = [
'The Cashu ecash wallet isn\'t set up on this node.',
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
]
}
try {
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
capability.value.fedimint = true
} catch {
capability.value.fedimint = false
adviceLines.value.fedimint = [
'This node hasn\'t joined a Fedimint federation.',
'Install the Fedimint app and join (or create) a federation, then enable it here.',
]
}
// Defaults: everything the node can receive unless the item already
// carried an explicit list. Never auto-enable an incapable rail.
if (!acceptedLoadedFromItem) {
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
} else {
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
}
}
// If we have an existing item, load its state // If we have an existing item, load its state
/** Catalog entries store the slash-stripped path; props carry a leading
* slash (filepath) or just the basename (filename). Normalize both sides
* the old exact compare never matched, so every re-share created a brand
* new priced entry and buyers could pay twice for one file (2026-07-22). */
function matchesThisFile(catalogFilename: string): boolean {
const strip = (v: string) => v.replace(/^\/+/, '')
return (
strip(catalogFilename) === strip(props.filepath || '') ||
strip(catalogFilename) === strip(props.filename || '')
)
}
onMounted(async () => { onMounted(async () => {
try { try {
const res = await rpcClient.call<{ items: Array<{ const res = await rpcClient.call<{ items: Array<{
id: string id: string
filename: string filename: string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
availability: string | { allpeers?: unknown; nobody?: unknown } availability: string | { allpeers?: unknown; nobody?: unknown }
}> }>({ method: 'content.list-mine' }) }> }>({ method: 'content.list-mine' })
const match = res.items.find( const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath (i) => matchesThisFile(i.filename)
) )
if (match) { if (match) {
shared.value = true shared.value = true
@ -166,6 +303,14 @@ onMounted(async () => {
if ('paid' in access && access.paid) { if ('paid' in access && access.paid) {
accessType.value = 'paid' accessType.value = 'paid'
priceSats.value = access.paid.price_sats || 100 priceSats.value = access.paid.price_sats || 100
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
acceptedLoadedFromItem = true
acceptedSet.value = new Set(
access.paid.accepted.filter((m): m is PayMethod =>
PAY_METHODS.some((p) => p.key === m),
),
)
}
} else if ('peersonly' in access) { } else if ('peersonly' in access) {
accessType.value = 'peers_only' accessType.value = 'peers_only'
} }
@ -174,6 +319,7 @@ onMounted(async () => {
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e) if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
} }
void probeCapabilities()
}) })
async function save() { async function save() {
@ -188,7 +334,7 @@ async function save() {
method: 'content.list-mine', method: 'content.list-mine',
}) })
const match = res.items.find( const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath (i) => matchesThisFile(i.filename)
) )
if (match) { if (match) {
await rpcClient.call({ method: 'content.remove', params: { id: match.id } }) await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
@ -200,7 +346,7 @@ async function save() {
method: 'content.list-mine', method: 'content.list-mine',
}) })
let itemId = res.items.find( let itemId = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath (i) => matchesThisFile(i.filename)
)?.id )?.id
// Add if not in catalog // Add if not in catalog
@ -227,6 +373,7 @@ async function save() {
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value } const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') { if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value pricingParams.price_sats = priceSats.value
pricingParams.accepted_methods = [...acceptedSet.value]
} }
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams }) await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })

View File

@ -76,6 +76,11 @@
</p> </p>
</div> </div>
<!-- INTEGRATION POINT (other developer, in progress): the web flasher
for all three firmwares (MeshCore / Meshtastic / RNode) belongs
HERE as a third action on this screen e.g. "Flash different
firmware" driven by the probe result above. Per the operator:
the flasher must live in this detection UI. -->
<div class="flex gap-2 mt-5"> <div class="flex gap-2 mt-5">
<button <button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"

View File

@ -105,6 +105,14 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
return !!el.closest(`[data-controller-zone="${zone}"]`) return !!el.closest(`[data-controller-zone="${zone}"]`)
} }
/** Topmost open modal dialog, if any — it owns navigation while visible. */
function getOpenModal(): HTMLElement | null {
const dialogs = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
).filter(el => el.offsetParent !== null)
return dialogs[dialogs.length - 1] ?? null
}
function isInsideContainer(el: HTMLElement | null): boolean { function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false if (!el) return false
const container = el.closest('[data-controller-container]') const container = el.closest('[data-controller-container]')
@ -250,6 +258,42 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement const activeEl = document.activeElement as HTMLElement
// ── MODAL SCOPE ──────────────────────────────────────────
// An open dialog owns navigation: focus is pulled inside, arrows move
// spatially between its controls, Enter activates. Escape stays with the
// modal's own close handling. Standard mapping, no per-modal code.
const modal = getOpenModal()
if (modal) {
if (e.key === 'Escape') return
const focusables = getFocusableElements(modal)
if (!focusables.length) return
if (!activeEl || !modal.contains(activeEl)) {
e.preventDefault()
const first = focusables[0]
if (first) focusEl(first)
return
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Typing keys stay with the field; Enter keeps the form-submit
// behavior below; only Up/Down leave the field spatially.
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
} else if (e.key === 'Enter') {
e.preventDefault()
playNavSound('action')
activeEl.click()
return
}
e.preventDefault()
const dir =
e.key === 'ArrowDown' ? ('down' as const)
: e.key === 'ArrowUp' ? ('up' as const)
: e.key === 'ArrowLeft' ? ('left' as const)
: ('right' as const)
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
if (nearest) focusEl(nearest)
return
}
// ── TEXT INPUT HANDLING ────────────────────────────────── // ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if ( if (
@ -364,6 +408,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
e.preventDefault() e.preventDefault()
if (isContainer(activeEl)) { if (isContainer(activeEl)) {
// Container declares its own click as THE action (e.g. a media file
// card whose click plays it) — without this the a[href] fallback
// below hits the card's download link and gamepad-select downloads
// a song instead of playing it.
if (activeEl.hasAttribute('data-controller-primary')) {
playNavSound('action')
activeEl.click()
return
}
// Prioritised action: install button // Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) { if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])') const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')

View File

@ -34,6 +34,8 @@ export interface MeshStatus {
pid?: string | null pid?: string | null
product?: string | null product?: string | null
manufacturer?: string | null manufacturer?: string | null
/** Epoch seconds the /dev node appeared — changes on every replug. */
plugged_at?: number | null
}> }>
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */ /** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
manage_radio?: boolean manage_radio?: boolean
@ -305,10 +307,20 @@ export const useMeshStore = defineStore('mesh', () => {
// Dismissals are cleared the moment the port disappears (unplug), so the // Dismissals are cleared the moment the port disappears (unplug), so the
// modal re-appears on EVERY plug-in — including swapping a different stick // modal re-appears on EVERY plug-in — including swapping a different stick
// into the same /dev path — per the hot-swap UX (2026-07-22). // into the same /dev path — per the hot-swap UX (2026-07-22).
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1' // v2: dismissals key on (path → plugged_at). udev recreates the /dev node
const dismissedDetectedPaths = ref<Set<string>>(new Set( // on every plug, so plugged_at changes on each replug — an old "Not Now"
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[] // can never suppress a NEWLY plugged stick, even when the swap happens
)) // faster than a status poll (which absence-pruning alone missed) or when
// the same /dev path is reused. v1 (a bare path set) is intentionally
// abandoned: stale one-time dismissals from before 2026-07-22 shouldn't
// suppress anything either.
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2'
const dismissedDetected = ref<Record<string, number>>(
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
)
function pluggedAt(s: MeshStatus, path: string): number {
return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0
}
// Consecutive polls each candidate port has been present-but-not-connected. // Consecutive polls each candidate port has been present-but-not-connected.
// The modal waits for 2 sightings so it doesn't flash during the couple of // The modal waits for 2 sightings so it doesn't flash during the couple of
// seconds an ordinary reconnect (same radio, transient blip) needs. // seconds an ordinary reconnect (same radio, transient blip) needs.
@ -317,15 +329,15 @@ export const useMeshStore = defineStore('mesh', () => {
const s = status.value const s = status.value
if (!s) return [] if (!s) return []
return (s.detected_devices || []).filter(p => return (s.detected_devices || []).filter(p =>
!dismissedDetectedPaths.value.has(p) && dismissedDetected.value[p] !== pluggedAt(s, p) &&
// The port the live session occupies is not a candidate… // The port the live session occupies is not a candidate…
!(s.device_connected && s.device_path === p) && !(s.device_connected && s.device_path === p) &&
// …and a port only qualifies once it has survived the blip debounce. // …and a port only qualifies once it has survived the blip debounce.
(detectSightings.value[p] ?? 0) >= 2 (detectSightings.value[p] ?? 0) >= 2
) )
}) })
/** Called after every status fetch: advance sighting counters and clear /** Called after every status fetch: advance sighting counters and drop
* dismissals/counters for ports that vanished (unplug replug reshows). */ * dismissals for ports that vanished (belt-and-braces with plugged_at). */
function trackDetectedDevices(s: MeshStatus) { function trackDetectedDevices(s: MeshStatus) {
const present = new Set(s.detected_devices || []) const present = new Set(s.detected_devices || [])
const next: Record<string, number> = {} const next: Record<string, number> = {}
@ -336,21 +348,24 @@ export const useMeshStore = defineStore('mesh', () => {
} }
detectSightings.value = next detectSightings.value = next
let dirty = false let dirty = false
for (const p of [...dismissedDetectedPaths.value]) { for (const p of Object.keys(dismissedDetected.value)) {
if (!present.has(p)) { if (!present.has(p)) {
dismissedDetectedPaths.value.delete(p) delete dismissedDetected.value[p]
dirty = true dirty = true
} }
} }
if (dirty) { if (dirty) {
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) dismissedDetected.value = { ...dismissedDetected.value }
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
} }
} }
function dismissDetectedDevice(path: string) { function dismissDetectedDevice(path: string) {
dismissedDetectedPaths.value.add(path) const s = status.value
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) dismissedDetected.value = {
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) ...dismissedDetected.value,
[path]: s ? pluggedAt(s, path) : 0,
}
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
} }
/** Read-only firmware/config probe of a detected port (hot-swap modal). */ /** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> { async function probeDevice(path: string): Promise<MeshDeviceProbe> {

View File

@ -2,6 +2,22 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* The app is dark-only NEVER let the OS/browser theme leak into native
controls. Without this, select popups / scrollbars / date pickers follow
the user's OS setting (a light-mode ThinkPad rendered white dropdown
lists inside the dark UI, 2026-07-22). color-scheme pins Chromium's
native select popup dark; the explicit select/option rules cover
Firefox and the closed control itself. */
:root {
color-scheme: dark;
}
select,
select option,
select optgroup {
background-color: #16181d;
color: #fff;
}
/* Montserrat - header font (used in neode present) */ /* Montserrat - header font (used in neode present) */
@font-face { @font-face {
font-family: 'Montserrat'; font-family: 'Montserrat';

19
neode-ui/src/utils/pip.ts Normal file
View File

@ -0,0 +1,19 @@
/** Video Picture-in-Picture helpers (Chromium/Safari; no-ops elsewhere).
* Kiosk note: PiP is skipped on the WM-less kiosk X session by callers that
* care an unmanaged popup there is unusable (docs/tv-input-iframe-apps.md
* sibling investigation, task #18). */
export const pipSupported =
typeof document !== 'undefined' &&
'pictureInPictureEnabled' in document &&
document.pictureInPictureEnabled
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
if (!video || !pipSupported) return
try {
if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
else await video.requestPictureInPicture()
} catch {
// Permission/transient failure — the button is best-effort.
}
}

View File

@ -1,8 +1,14 @@
<template> <template>
<div class="app-session-root"> <!-- The root stays in the layout as a rect placeholder; the session itself
<Teleport to="body" :disabled="inlinePanelMode"> ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
subtree on panel<->overlay switches, and moving an iframe node reloads
it inline mode is now emulated with a fixed-position rect synced from
this placeholder, so the iframe never moves in the DOM. -->
<div class="app-session-root" ref="rootRef">
<Teleport to="body">
<div <div
:class="backdropClasses" :class="backdropClasses"
:style="inlineRectStyle"
@click.self="handleBackdropClick" @click.self="handleBackdropClick"
> >
<div <div
@ -103,7 +109,7 @@ import AppSessionFrame from './appSession/AppSessionFrame.vue'
import MobileGamepad from './appSession/MobileGamepad.vue' import MobileGamepad from './appSession/MobileGamepad.vue'
import { import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS, type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle, initialDisplayMode, resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig' } from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig' import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity' import { useAppIdentity } from './appSession/useAppIdentity'
@ -142,11 +148,6 @@ let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let autoRetryId: ReturnType<typeof setTimeout> | null = null let autoRetryId: ReturnType<typeof setTimeout> | null = null
let iframeCheckId: ReturnType<typeof setTimeout> | null = null let iframeCheckId: ReturnType<typeof setTimeout> | null = null
// Display mode -- persisted in localStorage
const displayMode = ref<DisplayMode>(
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
)
const appId = computed(() => { const appId = computed(() => {
const id = props.appIdProp || (route.params.appId as string) const id = props.appIdProp || (route.params.appId as string)
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) { if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
@ -156,6 +157,9 @@ const appId = computed(() => {
return id return id
}) })
// Display mode -- per-app user choice per-app default last global panel
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
const appTitle = computed(() => resolveAppTitle(appId.value)) const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null) const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const appIcon = computed(() => const appIcon = computed(() =>
@ -230,7 +234,9 @@ function setMode(mode: DisplayMode) {
document.exitFullscreen().catch(() => {}) document.exitFullscreen().catch(() => {})
} }
displayMode.value = mode displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode) // Strictly per-app: the pick is remembered for THIS app only (no global
// key one app's mode must never change how another opens).
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
// Route-based sessions (deep links) hand off to the store-driven session so // Route-based sessions (deep links) hand off to the store-driven session so
// the app keeps floating above the dashboard instead of owning the route. // the app keeps floating above the dashboard instead of owning the route.
@ -251,12 +257,52 @@ function setMode(mode: DisplayMode) {
// Reactive classes based on display mode. The store-driven session honors the // Reactive classes based on display mode. The store-driven session honors the
// selected display mode in place: panel renders inline beside the page, // selected display mode in place: panel renders inline beside the page,
// overlay/fullscreen render above it (teleported to body) the underlying // overlay/fullscreen render above it the underlying route never changes.
// route never changes. Mobile always uses the full overlay. // Mobile always uses the full overlay.
const inlinePanelMode = computed(() => const inlinePanelMode = computed(() =>
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel' isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
) )
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
// to the placeholder's box so "inline" looks identical to the old in-place
// render while the iframe stays put in the DOM across mode switches.
const rootRef = ref<HTMLElement | null>(null)
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
let rectObserver: ResizeObserver | null = null
function syncInlineRect() {
const el = rootRef.value
if (!el) return
const r = el.getBoundingClientRect()
// A hidden/unmounted placeholder measures 0x0 keep the last good rect.
if (r.width > 0 && r.height > 0) {
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
}
}
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
if (!inlinePanelMode.value) return undefined
const r = inlineRect.value
// Never paint the inline backdrop over the whole viewport while unmeasured.
if (!r) {
const hidden: Record<string, string> = { visibility: 'hidden' }
return hidden
}
const style: Record<string, string> = {
position: 'fixed',
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
zIndex: '100',
}
return style
})
watch(inlinePanelMode, (on) => {
if (on) void nextTick(syncInlineRect)
})
const backdropClasses = computed(() => { const backdropClasses = computed(() => {
if (inlinePanelMode.value) return 'app-session-backdrop-inline' if (inlinePanelMode.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay' return 'app-session-backdrop-overlay'
@ -277,6 +323,9 @@ function onLoad() {
loading.value = false loading.value = false
isRefreshing.value = false isRefreshing.value = false
autoRetryCount.value = 0 autoRetryCount.value = 0
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
// virtual keyboard) flow into the iframe without needing a pointer click.
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
// Check if iframe actually loaded content (same-origin only) // Check if iframe actually loaded content (same-origin only)
iframeCheckId = setTimeout(() => { iframeCheckId = setTimeout(() => {
try { try {
@ -366,7 +415,7 @@ function onKeyDown(e: KeyboardEvent) {
function onFullscreenChange() { function onFullscreenChange() {
if (!document.fullscreenElement && displayMode.value === 'fullscreen') { if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
displayMode.value = 'overlay' displayMode.value = 'overlay'
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay') if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
} }
} }
@ -405,7 +454,16 @@ onMounted(() => {
window.addEventListener('keydown', onKeyDown, true) window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
window.addEventListener('resize', updateIsMobile) window.addEventListener('resize', updateIsMobile)
window.addEventListener('resize', syncInlineRect)
document.addEventListener('fullscreenchange', onFullscreenChange) document.addEventListener('fullscreenchange', onFullscreenChange)
// Track the placeholder's box (sidebar collapse, layout shifts) for the
// inline-mode fixed-position emulation. Sync before first paint so the
// inline backdrop never flashes at the wrong rect.
syncInlineRect()
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
rectObserver = new ResizeObserver(syncInlineRect)
rectObserver.observe(rootRef.value)
}
if (IFRAME_BLOCKED_APPS.has(appId.value)) { if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false loading.value = false
iframeBlocked.value = true iframeBlocked.value = true
@ -429,7 +487,10 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage) window.removeEventListener('message', onMessage)
window.removeEventListener('resize', updateIsMobile) window.removeEventListener('resize', updateIsMobile)
window.removeEventListener('resize', syncInlineRect)
document.removeEventListener('fullscreenchange', onFullscreenChange) document.removeEventListener('fullscreenchange', onFullscreenChange)
rectObserver?.disconnect()
rectObserver = null
screensaverStore.resume(screensaverReason.value) screensaverStore.resume(screensaverReason.value)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
}) })

View File

@ -148,6 +148,36 @@
</div> </div>
</div> </div>
<!-- Paid Files tab everything this node has purchased
Source of truth is the purchase cache (content.owned-list): filename,
type, price paid and when the file itself was also auto-filed into
Photos/Music/Documents at purchase time (2026-07-22). -->
<div v-else-if="activeTab === 'paid'">
<div v-if="paidLoading" class="glass-card p-8 text-center text-white/50 text-sm">Loading purchases</div>
<div v-else-if="paidItems.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
Nothing purchased yet files you buy from peers appear here and are saved into your folders automatically.
</div>
<div v-else class="space-y-2">
<div
v-for="it in paidItems"
:key="it.onion + it.content_id"
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
@click="viewPaidItem(it)"
>
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-white/90 truncate">{{ it.filename.split('/').pop() }}</p>
<p class="text-[11px] text-white/40">
{{ (it.size_bytes / 1024).toFixed(0) }} KB ·
<span class="text-orange-300/80">{{ it.paid_sats.toLocaleString() }} sats</span>
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
</p>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
</div>
</div>
</div>
<!-- Peer Files tab every file shared by every peer --> <!-- Peer Files tab every file shared by every peer -->
<div v-else-if="activeTab === 'peers'"> <div v-else-if="activeTab === 'peers'">
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3"> <div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
@ -374,13 +404,14 @@ const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false) const countsLoading = ref(false)
// Tabs / categories / search state // Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers' type TabId = 'folders' | 'mine' | 'peers' | 'paid'
type CategoryId = 'all' | 'photos' | 'music' | 'documents' type CategoryId = 'all' | 'photos' | 'music' | 'documents'
const TABS: Array<{ id: TabId; name: string }> = [ const TABS: Array<{ id: TabId; name: string }> = [
{ id: 'folders', name: 'Folders' }, { id: 'folders', name: 'Folders' },
{ id: 'mine', name: 'My Files' }, { id: 'mine', name: 'My Files' },
{ id: 'peers', name: 'Peer Files' }, { id: 'peers', name: 'Peer Files' },
{ id: 'paid', name: 'Paid Files' },
] ]
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'all', name: 'All' }, { id: 'all', name: 'All' },
@ -390,6 +421,44 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
] ]
const activeTab = ref<TabId>('folders') const activeTab = ref<TabId>('folders')
// Paid Files tab
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
const paidItems = ref<PaidItem[]>([])
const paidLoading = ref(false)
async function loadPaidItems() {
paidLoading.value = true
try {
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
paidItems.value = (res.items || []).slice().reverse()
} catch { paidItems.value = [] } finally { paidLoading.value = false }
}
async function viewPaidItem(it: PaidItem) {
try {
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
method: 'content.owned-get',
params: { onion: it.onion, content_id: it.content_id },
timeout: 60000,
})
const b64 = res.data_base64 || res.data
if (!b64) return
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
const mime = res.mime_type || it.mime_type
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
// Music ALWAYS plays in the global bottom-bar player never a popup/
// lightbox (blob URL stays alive for the bar; it owns playback now).
if (mime.startsWith('audio/')) {
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
return
}
window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
}
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
const selectedCategory = ref<CategoryId>('all') const selectedCategory = ref<CategoryId>('all')
const searchQuery = ref('') const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0) const searchActive = computed(() => searchQuery.value.trim().length > 0)
@ -576,6 +645,15 @@ async function handlePlay(path: string, name: string) {
} }
function handlePreview(path: string, context: FileBrowserItem[]) { function handlePreview(path: string, context: FileBrowserItem[]) {
// Audio never opens the lightbox it belongs to the bottom-bar player.
const clicked = context.find(item => item.path === path)
if (clicked) {
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
if (getFileCategory(ext, clicked.isDir) === 'audio') {
void handlePlay(path, clicked.name)
return
}
}
// MediaLightbox filters to media internally; index within that filtered list. // MediaLightbox filters to media internally; index within that filtered list.
const mediaItems = context.filter(item => { const mediaItems = context.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '' const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''

View File

@ -323,9 +323,18 @@ const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(n
const lightboxIndex = ref<number | null>(null) const lightboxIndex = ref<number | null>(null)
function handlePreview(path: string) { function handlePreview(path: string) {
const items = cloudStore.sortedItems
// Audio never opens the lightbox it belongs to the bottom-bar player.
const clicked = items.find(item => item.path === path)
if (clicked) {
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
if (getFileCategory(ext, clicked.isDir) === 'audio') {
void handlePlay(path, clicked.name)
return
}
}
// MediaLightbox internally filters items to media only, so startIndex // MediaLightbox internally filters items to media only, so startIndex
// must be the index within that filtered list // must be the index within that filtered list
const items = cloudStore.sortedItems
const mediaItems = items.filter(item => { const mediaItems = items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '' const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir) const cat = getFileCategory(ext, item.isDir)

View File

@ -350,7 +350,7 @@ function updateKeyboardInset() {
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
document.addEventListener('click', handleDocClickForMenu) document.addEventListener('pointerdown', handleDocClickForMenu)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession) window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) { if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateKeyboardInset) window.visualViewport.addEventListener('resize', updateKeyboardInset)
@ -403,7 +403,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleDocClickForMenu) document.removeEventListener('pointerdown', handleDocClickForMenu)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession) window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) { if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateKeyboardInset) window.visualViewport.removeEventListener('resize', updateKeyboardInset)
@ -1337,8 +1337,8 @@ function closeAttachMenuOnOutsideClick(ev: MouseEvent) {
const target = ev.target as HTMLElement const target = ev.target as HTMLElement
if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
} }
onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick)) onMounted(() => document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick))
onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick)) onUnmounted(() => document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick))
const attachError = ref<string | null>(null) const attachError = ref<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set()) const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(new Map()) const fetchedUrls = ref<Map<string, string>>(new Map())

View File

@ -251,9 +251,22 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
<!-- Picture-in-picture -->
<button
v-if="pipSupported"
class="absolute -top-10 right-10 text-white/60 hover:text-white transition-colors"
title="Picture-in-picture"
@click="togglePip(peerVideoRef)"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<!-- Video element --> <!-- Video element -->
<div class="relative"> <div class="relative">
<video <video
ref="peerVideoRef"
:src="videoPlayerUrl" :src="videoPlayerUrl"
class="w-full rounded-xl bg-black" class="w-full rounded-xl bg-black"
controls controls
@ -378,9 +391,11 @@
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats {{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
</p> </p>
<!-- Step 1: choose a payment method --> <!-- Step 1: choose a payment method only the methods the SELLER
accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3"> <div v-if="payMode === 'choose'" class="space-y-3">
<button <button
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id" :disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay" @click="prepareEcashPay"
@ -395,6 +410,7 @@
</button> </button>
<button <button
v-if="acceptsMethod(payItem.access, 'lightning')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying" :disabled="lnPaying"
@click="payWithLightning" @click="payWithLightning"
@ -409,6 +425,7 @@
</button> </button>
<button <button
v-if="acceptsMethod(payItem.access, 'lightning') || acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying" :disabled="lnPaying || onchainPaying"
@click="openQrPay" @click="openQrPay"
@ -423,6 +440,7 @@
</button> </button>
<button <button
v-if="acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying" :disabled="lnPaying || onchainPaying"
@click="payOnchain" @click="payOnchain"
@ -486,10 +504,11 @@
<!-- Step 2: pay from another wallet tabbed QR (on-chain default) --> <!-- Step 2: pay from another wallet tabbed QR (on-chain default) -->
<div v-else> <div v-else>
<!-- Method tabs, styled like the wallet Send/Receive modal --> <!-- Method tabs, styled like the wallet Send/Receive modal;
only tabs the seller accepts are shown -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
v-for="m in (['onchain', 'lightning'] as const)" v-for="m in (['onchain', 'lightning'] as const).filter(m => acceptsMethod(payItem!.access, m))"
:key="m" :key="m"
@click="selectQrTab(m)" @click="selectQrTab(m)"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors" class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
@ -580,6 +599,7 @@ import { useRouter } from 'vue-router'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useAudioPlayer } from '@/composables/useAudioPlayer' import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
const props = defineProps<{ const props = defineProps<{
@ -674,6 +694,13 @@ async function viewOwned(item: CatalogItem) {
}) })
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return } if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
const mime = res.mime_type || item.mime_type const mime = res.mime_type || item.mime_type
// Audio always plays in the global bottom-bar player never the lightbox
// (the blob URL is intentionally not revoked while the bar plays it).
if (mime.startsWith('audio/')) {
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
return
}
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value) if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime)) viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
viewerMime.value = mime viewerMime.value = mime
@ -746,6 +773,7 @@ let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
// Video player modal state // Video player modal state
const peerVideoRef = ref<HTMLVideoElement | null>(null)
const videoPlayerItem = ref<CatalogItem | null>(null) const videoPlayerItem = ref<CatalogItem | null>(null)
const videoPlayerUrl = ref<string | null>(null) const videoPlayerUrl = ref<string | null>(null)
const videoPlayerPaid = ref(false) const videoPlayerPaid = ref(false)
@ -903,6 +931,15 @@ function getItemPrice(access: CatalogItem['access']): number {
return 0 return 0
} }
/** Payment methods the seller accepts for this item. Missing/empty = all
* (items shared before sellers could restrict methods). */
function acceptsMethod(access: CatalogItem['access'], method: string): boolean {
if (typeof access !== 'object' || !('paid' in access)) return true
const accepted = (access.paid as { accepted?: string[] }).accepted
if (!Array.isArray(accepted) || accepted.length === 0) return true
return accepted.includes(method)
}
async function downloadFile(item: CatalogItem) { async function downloadFile(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
if (!onion) return if (!onion) return
@ -981,7 +1018,6 @@ function closePayModal() {
*/ */
function openQrPay() { function openQrPay() {
payMode.value = 'qr' payMode.value = 'qr'
qrTab.value = 'onchain'
invoiceData.value = null invoiceData.value = null
invoiceQr.value = '' invoiceQr.value = ''
invoiceError.value = '' invoiceError.value = ''
@ -989,7 +1025,14 @@ function openQrPay() {
onchainData.value = null onchainData.value = null
onchainQr.value = '' onchainQr.value = ''
onchainError.value = '' onchainError.value = ''
// Start on the first tab the seller actually accepts.
if (payItem.value && !acceptsMethod(payItem.value.access, 'onchain')) {
qrTab.value = 'lightning'
payWithInvoice()
} else {
qrTab.value = 'onchain'
loadOnchainQr() loadOnchainQr()
}
} }
/** Switch QR tab, lazily loading that method's QR the first time it's shown and /** Switch QR tab, lazily loading that method's QR the first time it's shown and
@ -1212,10 +1255,19 @@ async function confirmEcashPay() {
// forward; the viewer offers a Save button for an explicit download. // forward; the viewer offers a Save button for an explicit download.
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id)) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = result.mime_type || item.mime_type const mime = result.mime_type || item.mime_type
// A just-bought song goes straight to the bottom-bar player the
// owned-content lightbox is for images/video only.
if (mime.startsWith('audio/')) {
audioPlayer.play(
URL.createObjectURL(base64ToBlob(result.data, mime)),
item.filename.split('/').pop() || item.filename,
)
} else {
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value) if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime)) viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
viewerMime.value = mime viewerMime.value = mime
viewerItem.value = item viewerItem.value = item
}
closePayModal() closePayModal()
void loadOwned() void loadOwned()
} else if (result?.error) { } else if (result?.error) {

View File

@ -7,6 +7,22 @@ export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode' export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
/** Per-app default display mode. Used when the user hasn't explicitly picked
* a mode for that app (an explicit pick is remembered per app and wins).
* Apps not listed default to 'panel'. */
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
'indeedhub': 'fullscreen',
}
/** Initial display mode for an app session: per-app user choice per-app
* default panel. Strictly per-app deliberately NO global fallback, so
* one app's mode change can never affect how another app opens. */
export function initialDisplayMode(id: string): DisplayMode {
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
}
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */ /** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
export const APP_PORTS: Record<string, number> = { export const APP_PORTS: Record<string, number> = {
...GENERATED_APP_PORTS, ...GENERATED_APP_PORTS,

View File

@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean {
// are explicit because icon extensions vary (.png / .webp / .svg). // are explicit because icon extensions vary (.png / .webp / .svg).
const APP_ICON_FALLBACKS: Record<string, string> = { const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg', gitea: '/assets/img/app-icons/gitea.svg',
// Apps whose icon extension isn't .png: without an explicit entry the
// default `<id>.png` guess 404s on every render (console spam, and for
// mempool the .png→.svg fallback chain 404s TWICE before giving up).
pine: '/assets/img/app-icons/pine.svg',
mempool: '/assets/img/app-icons/mempool.webp',
'mempool-web': '/assets/img/app-icons/mempool.webp',
'fedimint-gateway': '/assets/img/app-icons/fedimint.png', 'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
'fedimint-clientd': '/assets/img/app-icons/fedimint.png', 'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
// immich stack // immich stack

View File

@ -362,6 +362,33 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.112-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.112-alpha</span>
<span class="text-xs text-white/40">July 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Your mesh messages now survive restarts. Chat history channels and DMs alike used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.</p>
<p>Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works including Reticulum RNodes, which fresh installer images now support out of the box.</p>
<p>Incoming bitcoin appears in your wallet within seconds of being sent balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.</p>
<p>The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.</p>
<p>Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.</p>
<p>Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings the new On-chain tab.</p>
<p>Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.</p>
<p>Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.</p>
<p>The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.</p>
<p>Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.</p>
<p>The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.</p>
<p>Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.</p>
<p>Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.</p>
<p>On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore dropdowns and other native controls stay dark on every device.</p>
<p>Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."</p>
<p>Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.</p>
<p>Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.</p>
<p>Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).</p>
</div>
</div>
<!-- v1.7.111-alpha --> <!-- v1.7.111-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">

View File

@ -379,20 +379,28 @@ async function loadPeers() {
peers.value = peerList peers.value = peerList
observers.value = observerList observers.value = observerList
for (const p of [...peers.value, ...observers.value]) { // The list is ready render it and re-enable Refresh NOW. The
// reachability dots fill in as probes resolve. Before 2026-07-22 the
// probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat
// disabled for N-offline-peers × 30s ("takes ages").
loadingPeers.value = false
void Promise.allSettled(
[...peers.value, ...observers.value].map(async (p) => {
try { try {
const check = await rpcClient.checkPeerReachable(p.onion) const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable peerReachableLocal.value[p.onion] = check.reachable
} catch { } catch {
peerReachableLocal.value[p.onion] = false peerReachableLocal.value[p.onion] = false
} }
} }),
).then(() => {
writeConnectedNodesCache({ writeConnectedNodesCache({
peers: peers.value, peers: peers.value,
observers: observers.value, observers: observers.value,
peerReachable: peerReachableLocal.value, peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value, connectionRequests: connectionRequests.value,
}) })
})
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e) if (import.meta.env.DEV) console.error('Failed to load peers:', e)
if (!hadPeers) { if (!hadPeers) {

View File

@ -73,7 +73,9 @@
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs"> <div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
No discoverable nodes found yet. Nodes appear here as relays gossip their presence. No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
</div> </div>
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1"> <!-- min 14rem, otherwise scale with the viewport so a long discovery
list uses the screen instead of cramming into a fixed 224px box -->
<div v-else class="space-y-2 max-h-[max(14rem,45vh)] overflow-y-auto pr-1">
<div <div
v-for="node in discoveredNodes" v-for="node in discoveredNodes"
:key="node.nostr_pubkey" :key="node.nostr_pubkey"

View File

@ -235,7 +235,7 @@
</button> </button>
<button <button
v-else-if="getItemPrice(pItem.access) > 0" v-else-if="getItemPrice(pItem.access) > 0"
@click="purchaseAndDownload(pItem)" @click="requestPurchase(pItem)"
:disabled="purchasingId === pItem.id" :disabled="purchasingId === pItem.id"
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1" class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
> >
@ -305,6 +305,41 @@
</div> </div>
</Teleport> </Teleport>
<!-- Purchase confirmation payment NEVER starts before the user confirms -->
<Teleport to="body">
<div v-if="pendingPurchase" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="pendingPurchase = null">
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true">
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
<p class="text-sm text-white/70 truncate mb-4">{{ pendingPurchase.filename.split('/').pop() }}</p>
<div class="p-3 bg-white/5 rounded-lg space-y-1.5 mb-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Ecash balance</span>
<span class="text-sm font-medium text-white/80">{{ pendingBalance === null ? '…' : pendingBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Price</span>
<span class="text-sm font-medium text-white/80">{{ getItemPrice(pendingPurchase.access).toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access) ? 'text-red-400' : 'text-white/80'">
{{ pendingBalance === null ? '…' : (pendingBalance - getItemPrice(pendingPurchase.access)).toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)" class="text-xs text-red-400 mb-3">Not enough ecash fund your wallet, or buy from the Peers page to pay another way.</p>
<div class="flex gap-3">
<button @click="pendingPurchase = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="confirmPendingPurchase"
:disabled="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>Pay from node ecash</button>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal --> <!-- Add Content Modal -->
<Teleport to="body"> <Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false"> <div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
@ -550,6 +585,26 @@ function downloadPeerContent(item: PeerContentItem) {
safeClipboardWrite(url) safeClipboardWrite(url)
} }
// Purchase is strictly two-step: the Buy click only opens the confirmation
// (with the balance impact); nothing is paid until the user confirms there.
const pendingPurchase = ref<PeerContentItem | null>(null)
const pendingBalance = ref<number | null>(null)
function requestPurchase(item: PeerContentItem) {
if (purchasingId.value) return
pendingPurchase.value = item
pendingBalance.value = null
rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
.then((r) => { pendingBalance.value = r?.balance_sats ?? 0 })
.catch(() => { pendingBalance.value = null })
}
async function confirmPendingPurchase() {
const item = pendingPurchase.value
pendingPurchase.value = null
if (item) await purchaseAndDownload(item)
}
async function purchaseAndDownload(item: PeerContentItem) { async function purchaseAndDownload(item: PeerContentItem) {
if (!browsePeerOnion.value || purchasingId.value) return if (!browsePeerOnion.value || purchasingId.value) return
const price = getItemPrice(item.access) const price = getItemPrice(item.access)
@ -557,18 +612,6 @@ async function purchaseAndDownload(item: PeerContentItem) {
purchasingId.value = item.id purchasingId.value = item.id
try { try {
// Check balance first
try {
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
const balance = balRes?.balance_sats ?? 0
if (balance < price) {
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
return
}
} catch {
// Balance check failed try the purchase anyway
}
const result = await rpcClient.call<{ data?: string; error?: string }>({ const result = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-paid', method: 'content.download-peer-paid',
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price }, params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },

View File

@ -0,0 +1,7 @@
import baseConfig from './vite.config'
const cfg = { ...(baseConfig as Record<string, unknown>) } as any
cfg.server = { ...(cfg.server || {}), port: 8100, strictPort: true }
const proxy = { ...(cfg.server.proxy || {}) }
for (const k of Object.keys(proxy)) proxy[k] = { ...proxy[k], target: 'http://localhost:5959' }
cfg.server.proxy = proxy
export default cfg

View File

@ -1256,6 +1256,10 @@
{ {
"key": "FM_API_URL", "key": "FM_API_URL",
"template": "ws://{{HOST_MDNS}}:8174" "template": "ws://{{HOST_MDNS}}:8174"
},
{
"key": "FM_BITCOIND_URL",
"template": "http://{{BITCOIN_HOST}}:8332"
} }
], ],
"entrypoint": [ "entrypoint": [
@ -1284,7 +1288,6 @@
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.", "description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"environment": [ "environment": [
"FM_DATA_DIR=/data", "FM_DATA_DIR=/data",
"FM_BITCOIND_URL=http://bitcoin-knots:8332",
"FM_BITCOIND_USERNAME=archipelago", "FM_BITCOIND_USERNAME=archipelago",
"FM_BITCOIN_NETWORK=bitcoin", "FM_BITCOIN_NETWORK=bitcoin",
"FM_BIND_P2P=0.0.0.0:8173", "FM_BIND_P2P=0.0.0.0:8173",
@ -1443,9 +1446,15 @@
}, },
"container": { "container": {
"custom_args": [ "custom_args": [
"if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;\nelse\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;\nfi" "if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url \"$FM_BITCOIND_URL\" --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;\nelse\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url \"$FM_BITCOIND_URL\" --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;\nfi"
], ],
"data_uid": "1000:1000", "data_uid": "1000:1000",
"derived_env": [
{
"key": "FM_BITCOIND_URL",
"template": "http://{{BITCOIN_HOST}}:8332"
}
],
"entrypoint": [ "entrypoint": [
"sh", "sh",
"-lc" "-lc"
@ -4157,7 +4166,9 @@
"--model", "--model",
"base-int8", "base-int8",
"--language", "--language",
"en" "en",
"--beam-size",
"1"
], ],
"image": "docker.io/rhasspy/wyoming-whisper:3.4.1", "image": "docker.io/rhasspy/wyoming-whisper:3.4.1",
"network": "archy-net", "network": "archy-net",
@ -4213,7 +4224,7 @@
"no_new_privileges": true, "no_new_privileges": true,
"readonly_root": false "readonly_root": false
}, },
"version": "3.4.1", "version": "3.4.2",
"volumes": [ "volumes": [
{ {
"options": [ "options": [
@ -4226,7 +4237,7 @@
] ]
} }
}, },
"version": "3.4.1" "version": "3.4.2"
}, },
"portainer": { "portainer": {
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4", "image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
@ -4729,7 +4740,7 @@
} }
}, },
"schema": 1, "schema": 1,
"signature": "5ca0f41589ba2a905e68d79388ea819ef983689f5c88a718723280d81782d534b8a1c9b0452f062b59dae3261224b98f2f136be564176a20729c55a748025100", "signature": "4054b2a8659b75a3810ed153798c0e8a08db49ce6237b8ae985e87ace8a35163c24a0190074abd2a8296309ebe9dd583f153642d46d4d7f0a6a4db255743380b",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"updated": "2026-07-22" "updated": "2026-07-23"
} }