Compare commits

..
Author SHA1 Message Date
archipelago fbb3ada87d chore: publish release v1.8.13-alpha
Demo images / Build & push demo images (push) Successful in 3m46s
2026-09-12 06:44:01 -04:00
archipelago 72e84439ee chore: prepare release v1.8.13-alpha 2026-09-12 06:40:21 -04:00
archipelago 5081a4fe7d docs: expand v1.8.13-alpha release notes 2026-09-12 05:41:13 -04:00
archipelago 39727dacbc style: format generated app ports 2026-09-12 05:37:55 -04:00
archipelago 1e409007d4 chore: regenerate app port metadata 2026-09-12 05:05:37 -04:00
archipelago 8f144c3038 chore: remove retired AdGuard app and refresh release docs 2026-09-12 05:05:33 -04:00
archipelago 8258705df7 chore: sync v1.8.13-alpha whats new 2026-09-12 04:44:34 -04:00
archipelago d13002e022 docs: add v1.8.13-alpha release notes 2026-09-12 04:44:20 -04:00
archipelago e625b29d9e fix: route GitWorkshop installs through orchestrator 2026-09-12 04:41:14 -04:00
archipelago c4ed9fb1fa release: sign app catalog for v1.8.12-alpha 2026-09-12 04:16:21 -04:00
archipelago 2bc5e98edb chore: publish release v1.8.12-alpha
Demo images / Build & push demo images (push) Successful in 3m49s
2026-09-11 15:17:17 -04:00
archipelago c1e14f7c7a chore: prepare release v1.8.12-alpha 2026-09-11 15:13:40 -04:00
archipelago 564ffe1c47 fix(indeedhub): generate per-node encryption root 2026-09-11 11:25:44 -04:00
archipelago c34d6ef76f docs(release): finalize 1.8.12 notes
Demo images / Build & push demo images (push) Successful in 4m22s
2026-09-11 06:55:17 -04:00
archipelago dac29baf97 fix(release): surface companion build and secure GitWorkshop deps 2026-09-11 06:10:59 -04:00
archipelago ef8c3a76be chore(release): define 1.8.12 publication gates 2026-09-11 05:37:21 -04:00
archipelago f5c0ba85cd feat(release): stage GitWorkshop and next node updates 2026-09-09 18:15:21 -04:00
archipelago 973356df16 fix(ecash): harden Minibits claim persistence 2026-09-08 21:16:57 -04:00
e5a0d95459 fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
Confirmed live 2026-09-08 against three real Lightning payments to a
registered @minibits.cash address: POST /claim (the only claim source
claim_and_redeem checked) always returned an empty array, no matter
how long or how often it was polled. Independently queried
wss://relay.minibits.cash and found all three payments sitting there
as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr
pubkey and authored by the Minibits service key — that is the actual
delivery channel for a payment made to the address, and this module
never looked at it.

fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus
two public fallbacks) for kind-4 events tagged to our pubkey, feeding
matching content into the existing pending_claims retry pipeline
unchanged. A new last_dm_seen_at watermark stops the same (immutable,
never-expiring) relay event from being re-fetched and re-attempted on
every poll. The REST /claim call stays in place alongside it in case
it serves some other payment path — this only adds the missing one.

fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token

Once the relay fix above surfaced the three real payments, all three
failed to redeem with "Invalid base64 in cashuB token" — the decrypted
NIP-04 content had a trailing space after the base64 payload (Minibits'
own encoding), which every base64 alphabet in decode_token_base64
rejects outright. CashuToken::deserialize now trims the whole token
string before touching the "cashuA"/"cashuB" prefix or payload. This is
a general robustness fix, not just a Minibits workaround — the same
stray-whitespace failure could hit a hand-pasted token from a clipboard
copy just as easily.

Both fixes verified end-to-end against production: all three stuck
payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll
after deploying this build to archy-x250-pa3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
b9862c7643 fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test
Same class of bug as the Minibits address label
(settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n
parses as linked-message syntax). This one is live in
ChangePasswordSection.vue's password-strength validator: typing a new
password with no special character throws this exact
SyntaxError the moment the message is rendered. Fixed the same way
({'@'} escaping).

Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every
string in every locale file and asks the real vue-i18n compiler to
parse it — confirmed it fails on both bad strings before their fixes
and passes clean now, with no other landmines left in either locale
file. This closes the whole bug class rather than just these two
instances; a future bad interpolation string fails `npm test` instead
of only a live crash report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
6fe9c5f81b fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.

Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
28454264ac test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal
Operator report (2026-09-08): clicking the Ecash tab appeared to close
the whole Receive modal. Added a regression test simulating the exact
click, both for wallet.ecash-lnaddress succeeding and failing — the
tab switch alone never emits `close` or unmounts the dialog in either
case, so this isn't reproduced by a plain component-level click; the
investigation continues with the reporter for a browser-console repro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
84b04d1634 fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since
removed) while a client had the ecash receive tab open. save_state's
write landed mid-truncate, leaving wallet/minibits.json at 0 bytes.
load_state then hard-failed every wallet.ecash-lnaddress call with
"EOF while parsing a value", surfaced in the UI as "Lightning address
unavailable" — permanently, since nothing ever cleared the bad file.

Registration is idempotent per pubkey (re-registering returns the same
lud16 Minibits already assigned), so there's no reason a corrupt local
mirror of that state should be fatal. load_state now treats an empty
or unparseable state file the same as a missing one — re-register and
recover the same address — instead of erroring. Manually cleared the
stuck file on archy-x250-pa3 as an immediate fix; this closes the gap
so it self-heals next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ce5c04d49d fix(ecash): stop Minibits LN-address claims from being silently lost
A Minibits /claim response consumes the payment server-side the instant
it's returned — it can never be re-fetched. claim_and_redeem previously
decrypted/redeemed each claim inline and just warn!-logged any failure,
so a mint-unreachable blip, a stale cached server key, or an operator
who'd edited their accepted-mints list to drop the default mint (via
streaming.configure-mints) could make a real payment vanish with
nothing but a log line to show for it — claimed_count/received_sats
still came back as a clean 0, identical to "nothing arrived."

Now: every fetched claim is persisted to MinibitsState.pending_claims
before decrypt/redeem is attempted, survives failures across polls
instead of being dropped, and claim_and_redeem no longer bails out on a
fetch error without first retrying whatever was already pending.
ensure_mint_accepted self-heals the accepted-mints allow-list so the
Minibits mint (the address is inherently backed by it) can't be
excluded out from under a claim. ClaimOutcome gains failed_count,
threaded through wallet.ecash-lnaddress-claim and shown in
ReceiveBitcoinModal so a stuck claim is visible instead of silent.

Also fixes the server_nostur_pubkey field-name typo (no live state to
migrate — this feature hasn't shipped yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ssmithxandarchipelago ce9fca1c38 feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
2026-09-08 21:16:57 -04:00
archipelago e661f237f1 fix(openwrt): harden TollGate PR integration 2026-09-08 21:06:36 -04:00
f9af30b08a feat(openwrt): make TollGate payout Lightning address configurable
Archipelago never touched /etc/tollgate/identities.json — the "owner"
payout identity was whatever the router's TollGate install happened to
default to. Confirmed live against archy-x250-pa3: an unmodified upstream
placeholder (tollgate@minibits.cash), meaning 79% of every customer payment
would auto-payout to an address the operator never chose and doesn't
control.

Adds TollGateConfig.payout_address (opt-in — None leaves the router
untouched), config::apply_payout_identity() to merge it into the "owner"
entry of identities.json without disturbing the merchant keypair or the
other profit-share identities, an RPC param on openwrt.provision-tollgate,
and a status field + reconfigure-form input in the OpenWrt Gateway panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
87a5025341 docs(tollgate-sweep): document two live-confirmed drain-CLI bugs
sweep_once() has never actually swept anything: `tollgate wallet drain
cashu` (no flags) blocks on an interactive y/N confirmation that Router::run
can never answer over a non-PTY SSH exec (empty stdin -> EOF -> defaults to
N -> "Operation cancelled." with exit code 0), so the drain_code != 0 check
can't catch it and every tick silently no-ops.

The obvious fix isn't safe either: `--json` skips the prompt, but confirmed
live against archy-x250-pa3 that on a wallet.db with a stale duplicate
per-mint entry (trailing-slash leftover from before the mint_url fix), it
completes a real swap against the good entry, then aborts on the second
(empty, stale) entry and reports "success": false without ever printing or
persisting the resulting token anywhere. 50 sats went from spendable balance
to gone in that one call. Documented so nobody "fixes" this by wiring in
--json before upstream fixes the partial-failure data loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
2947277205 fix(openwrt): close TollGate free-access gap and mint URL mismatch
Two bugs found live against archy-x250-pa3: TollGate-3458 (the upstream
tollgate-module-basic-go installer's own default AP, rebranded from
OpenWrt's factory default wireless.default_radioN sections) was left
bound to `network=lan` — wide open, unmetered, and sharing the router's
admin LAN — because install_ipk() runs the upstream package's own
uci-defaults scripts but nothing reconciled the AP they create with the
separate `tollgate` network/bridge/firewall this project's own
provision_ssid() sets up for the "archipelago" SSID. Fixed by folding any
default_radioN section left on `lan` onto the `tollgate` network right
after it's created.

Separately, a caller-supplied mint_url with a trailing slash
(https://mint.minibits.cash/Bitcoin/) got written byte-for-byte into
accepted_mints[0].url, which tollgate-wrt string-compares exactly against
a token's embedded (slash-less) mint URL — rejecting every otherwise-valid
token as an "untrusted mint". Fixed by trimming trailing slashes before
the value is used anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
archipelago db52c06a72 chore(catalog): sign Cuprate registry update 2026-09-07 05:12:05 -04:00
archipelago 4b14b62e74 chore: publish release v1.8.11-alpha
Demo images / Build & push demo images (push) Successful in 3m40s
2026-09-07 04:35:48 -04:00
archipelago 5da91e4099 chore: prepare release v1.8.11-alpha 2026-09-07 04:32:02 -04:00
archipelago 62731cc729 test(ui): use shipped app for generated launch port check 2026-09-07 03:30:29 -04:00
archipelago 5e17ace690 style(openwrt): format TollGate installer 2026-09-07 03:26:31 -04:00
archipelago b010471a4a chore(release): prep v1.8.11 notes and link checks 2026-09-07 03:26:01 -04:00
ai c4ede96517 Merge PR #154: docs(openwrt): OpenWrt Gateway setup guide + live-tested fixes
Demo images / Build & push demo images (push) Successful in 3m52s
2026-09-07 07:24:33 +00:00
ai be06e1a502 Merge PR #153: fix(cuprate): enable fast_sync and raise DB cache 2026-09-07 07:24:25 +00:00
ssmithxandClaude Sonnet 5 094f42312c docs(openwrt): document the confirmed working end-to-end install flow
Adds a verification checklist (service running, nodogsplash bound to
br-tollgate not br-lan via the rendered config not just UCI, LAN/SSH
untouched, mint probes succeeding) plus notes on the dev-build test-mint
injection and the default-route race between a router's LAN interface
and the node's other uplinks before the router's own WAN/WISP is live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 03:06:58 +00:00
ssmithxandClaude Sonnet 5 da8c3ec193 docs(openwrt): note the Ctrl+T/LuCI workaround for setting the initial root password
Archipelago's Connect form only authenticates with an existing password;
it has no flow for setting one on a fresh, passwordless router. On the
node's kiosk display there's no visible tab bar, so Ctrl+T to open a new
tab to LuCI is the way to set it before Connect will work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 02:53:21 +00:00
ssmithxandClaude Sonnet 5 4fdf8e8c58 fix(openwrt): bump pinned TollGate release v0.2.0 -> v0.5.0
The install code was hardcoded to the Oct 2025 v0.2.0 release —
nine releases behind. Its changelog covers exactly the failures hit
live against archy-x250-pa3: a mint with an empty/broken keyset
crash-looped tollgate-wrt forever (v0.5.0 adds "graceful degradation
when Cashu mints fail"), and the bundled captive-portal JS had zero
CBOR support, hard-rejecting the cashuB (NUT-00 V4) tokens modern
wallets like Minibits generate by default.

Also: v0.5.0 publishes native .apk packages for aarch64_cortex-a53
and x86_64. install_tollgate_apk_native now prefers those directly
(apk add handles deps/postinst/uci-defaults itself) instead of always
falling back to the manual ar/tar .ipk extraction dance, which only
exists because earlier releases had no native apk build at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 17:28:20 +00:00
ssmithxandClaude Sonnet 5 61b5d93b11 docs(openwrt): document the transient post-reboot apk-update failure
Observed live on archy-x250-pa3: right after WAN reconnects (fresh
boot or WAN reconfigure), the first Install attempt can fail with
"apk update failed ... router may have no internet access" purely
because the WiFi-uplink STA association hasn't finished yet — it's
not a real error, just retry a few seconds later. Also cross-referenced
the now-fixed /usr/bin/opkg hardcoding bug for anyone hitting it on an
older build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 16:36:53 +00:00
ssmithxandClaude Sonnet 5 be06b3ce2b fix(ui): stop sending an empty ssh_password over the saved router connection
provisionTollgate/saveTollgateConfig/scanWifi/configureWan all fell
back to the Connect form's local refs (host/sshUser/sshPassword) when
connectedParams was null. Those refs only get populated if the form
was actually submitted this session — on a normal page load the
router reconnects via the server-persisted config instead, leaving
sshPassword at its default ''. Sending that as an explicit
(empty-but-present) ssh_password overrides the backend's saved-config
fallback, so every action auths with a blank password instead of the
real saved one.

Added authParams(): omit host/ssh_user/ssh_password entirely unless
connectedParams is actually set, same as the status poll already does.
Caught live: dropbear on archy-x250-pa3's router logged a single bad
password attempt at the exact moment "Install TollGate" was clicked,
sandwiched between periodic status-poll connections succeeding with
the real saved password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:07 +00:00
ssmithxandClaude Sonnet 5 f3d96ae2ee fix(openwrt): resolve opkg/apk via $PATH, not a hardcoded /usr/bin path
opkg_check() and every opkg/apk invocation hardcoded /usr/bin/opkg and
/usr/bin/apk. Official OpenWrt images don't all symlink /bin into
/usr/bin — the glinet_gl-mt3000 24.10.2 build keeps them as separate
real directories with opkg living in /bin — so the check silently
missed a perfectly normal install and TollGate provisioning failed
with "this router's firmware may not support package management".

Switched every call to resolve through the router's own $PATH
(command -v / bare opkg / apk) instead. Reproduced and fixed live
against archy-x250-pa3, 2026-09-05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:00 +00:00
ssmithxandClaude Sonnet 5 a4ae375617 docs(openwrt): fix TollGate step — install is separate from configure
Step 4 described a single "Provision TollGate" action that prompts for
price/step/mint upfront. The real UI (OpenWrtGateway.vue) doesn't work
that way: "Install TollGate" is a one-click action with no config form
that installs with defaults, and price/step/mint/enabled are only
editable afterward via a separate "Edit" panel. Caught while walking
through a live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:32:39 +00:00
ssmithxandClaude Sonnet 5 0646bc4e85 docs(openwrt): add GL.iNet AX3000 → stock OpenWrt flashing steps
Worked example for the Beryl AX (GL-MT3000, mediatek/filogic) verified
against the OpenWrt wiki and firmware selector: exact sysupgrade image
filename, GL.iNet UI / LuCI flash path, post-flash SSH state, and the
U-Boot recovery procedure if the flash goes sideways.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:26:21 +00:00
ssmithxandClaude Sonnet 5 0faaf4577f docs: add OpenWrt Gateway setup guide
Walks a node operator through pairing an OpenWrt router over SSH,
running the WAN/WISP wizard, and provisioning TollGate pay-as-you-go
WiFi — plus an RPC/architecture reference for developers. Distills
the openwrt crate, RPC handlers, and Vue panel into user-facing steps
that didn't exist anywhere in docs/ before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:07:05 +00:00
ssmithxandClaude Sonnet 5 f9a1ef031c fix(cuprate): front the restricted RPC port with a Tor onion
The restricted-RPC port (18090) was `auth: none`, which the app gate
treats as fully exempt — no onion, no takeover, LAN/Tailscale IP only.
Flip it to `auth: open`: the gate still binds the external addresses
and fronts a Tor onion for the port, just without a dashboard login
challenge, since Monero wallet clients (Feather, monero-wallet-rpc,
GUI) speak plain HTTP JSON-RPC and can't hold a session cookie.

P2P (18183) stays `none` — no reason to Tor-front raw gossip.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it takes effect on any
node, since origin (catalog) wins over disk for catalog-covered apps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZnsiMtyJxiJBuvv7yLPUF
2026-09-03 14:51:24 +00:00
ssmithxandClaude Sonnet 5 cf240df4b6 fix(cuprate): enable fast_sync and raise DB cache — sustained 45% CPU
The default manifest baked in the exact broken config found on an
affected fleet node: no fast_sync (defaults false, forcing full ring-sig/
RandomX verification on every block) and target_max_memory capped at
~2.8GiB, which starved cuprated's DB cache into constant eviction/flush
(595GB/24h of block I/O on a node just appending ~2MB blocks every 2
minutes). A reference node with fast_sync = true and an 8GiB cache ran
at 2.8% CPU at the same chain height and block rate.

Set fast_sync = true and target_max_memory = 8GiB to match the healthy
reference config, and raise resources.memory_limit from 4Gi to 10Gi so
the container still has headroom above the larger cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR7jRaicvqsJaqQQ92jpPQ
2026-09-03 08:56:52 +00:00
archipelago d8320896c4 chore: publish release v1.8.10-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-01 19:01:54 -04:00
archipelago b87f1f0612 chore: prepare release v1.8.10-alpha 2026-09-01 18:58:33 -04:00
archipelago 1ca002661b fix(lnd): SendPaymentV2 needs an explicit fee budget — absent means ZERO
Demo images / Build & push demo images (push) Successful in 3m28s
v1.8.9's move to Router.SendPaymentV2 shipped without fee_limit_sat,
and the v2 route treats an ABSENT fee limit as zero allowed fees.
Every real route carries a routing fee (the 2-hop route here: 1.5
sats), so the pathfinder rejected them all and the wallet answered
"No route to the recipient" on EVERY send — all day, on healthy
channels with plenty of liquidity both ways.

The router debug log makes it unambiguous:
  wallet payment (v1.8.9 backend): fee_limit=0 mSAT     -> no route
  same payment by hand (lncli --fee_limit=100): fee_limit=100000 mSAT -> settles in 0.65s

My earlier "pipeline verified" claim was wrong — the manual lncli
verification set a fee limit by hand and masked this exact bug. The
400k that succeeded this morning went through the pre-update backend
on the pre-update LND.

Payments now carry lncli's own default budget — the payment amount
(100%), preferring the payer-supplied amount for zero-value invoices
and the invoice's own amount otherwise, with a nominal floor so the
limit can never be zero. Unit-pinned so it cannot regress.
2026-09-01 18:42:37 -04:00
archipelago 0d0e2e243a feat(lnd): channel-peer watchdog — a dropped peer link heals itself
Demo images / Build & push demo images (push) Successful in 3m49s
LND normally reconnects channel peers after a restart, but not reliably:
after long or repeated downtime (an app update, a node reboot,
reconciler churn) the peer link can stay down for hours while BOTH
endpoints keep the channel flagged disabled in the routing graph. The
node looks perfectly healthy, the wallet shows balance, and every
payment in either direction fails "no route to the recipient" —
observed live on framework-pt (2026-09-01): its only channel sat
disabled on both policy sides for ~17 hours after the LND 0.21.2
update, while shorty had 583k spendable and the user was told, by a
mis-mapped modal, that they had 'no payment channel'.

The channel graph is desired state — every open channel should have a
live peer connection. A daemon-side watchdog now enforces it:

- every 2 minutes, list channels + peers over LND REST
- for each channel whose remote peer is not connected, look the peer's
  advertised addresses up in the public graph and dial one
- per-peer retries throttled to 10 minutes so an unreachable peer is
  not hammered; 'already connected' counts as done; a peer with no
  advertised address is logged once per pass (cannot be dialed)
- no-ops quietly on nodes without LND (missing macaroon) and while a
  wallet is locked (503 body has no channels)

Unit tests pin the selection against the live REST shapes
(remote_pubkey in /v1/channels vs pub_key in /v1/peers).

v1.8.10 CHANGELOG + What's New entries staged so the next release run
is clean first time.
2026-09-01 17:51:15 -04:00
archipelago 9c49b502e3 docs: post-1.8.9 verification — pipeline confirmed, routing failure root-caused to framework-pt's disabled channel 2026-09-01 16:36:27 -04:00
archipelago d68a013e35 docs: tracker — v1.8.9 published, NPM live-healed on shorty via the signed catalog; funding-gate fix staged for v1.8.10 2026-09-01 11:43:33 -04:00
archipelago 1464b1b24d fix(wallet): the Lightning funding gate states the node's real channel state
Demo images / Build & push demo images (push) Successful in 3m38s
"LND thinks I do not have a channel" while the wallet showed plenty of
liquidity (framework-pt, 2026-09-01): the send gate sums outbound over
FULLY-OPEN channels only, which is correct — a just-opened channel
sits in LND's pending list until it has ~3 confirmations, and an
open channel can have all its balance on the far side — but the modal
then claimed the node had NO channel at all, in every one of those
states, and pointed the user at opening another one.

The gate already fetched the full channel list; it now records WHY
liquidity is zero and the modal says the truth per state:
- pending channels -> "your new channel is waiting for on-chain
  confirmations, it unlocks automatically, nothing is needed from you"
  (and no "Open a channel" button — that would send the user to fix
  a problem they don't have, possibly opening a second channel)
- open channels, zero on the needed side -> "balance is on the far
  side — you can receive but there's nothing to send right now"
- payment refused with a routing/liquidity error -> says so, instead
  of claiming no channels
- only a genuinely channel-less node keeps the open-one guidance

Eleven unit tests pin the state machine, including the regression
case (pending-only -> 'pending', not 'none') and fail-open on RPC
errors.
2026-09-01 11:40:25 -04:00
archipelago 82001403b4 chore: publish release v1.8.9-alpha 2026-09-01 11:05:55 -04:00
archipelago 81ede159ac chore: prepare release v1.8.9-alpha 2026-09-01 11:02:00 -04:00
archipelago 8e988be853 chore(release): v1.8.9-alpha prep — What's New block + version bumps
Demo images / Build & push demo images (push) Successful in 3m48s
The release gate requires the freshly-built bundle to embed the new
version, and the version reaches the bundle through the What's New
modal in AccountInfoSection — there was no v1.8.9-alpha block yet, so
create-release.sh correctly refused to ship a bundle that looked stale.
This adds the block (the user-facing summary of today's LND/HTTPS/
launcher/NPM/Portainer fixes) and carries the version bumps the
aborted run had already written (Cargo.toml, Cargo.lock, package.json,
package-lock), so the re-run starts from a clean tree.

Verified: npm run build now produces assets containing 1.8.9-alpha
(Settings chunk), i.e. the exact check the script runs passes.
2026-09-01 10:54:47 -04:00
archipelago 210f7f1b12 chore(catalog): re-sign the catalog — NPM letsencrypt mount + NET_BIND_SERVICE
Regenerated from the fixed apps/nginx-proxy-manager/manifest.yml (the
only semantic change vs the previous signed catalog) and signed with
the release-root key. Catalog-covered nodes pick this up on their next
hourly fetch and the NPM start/die loop ends: s6 gets its /etc/letsencrypt
mount back and the internal nginx can bind 80/443/81 again under
--cap-drop=ALL.
2026-09-01 10:37:09 -04:00
archipelago ed49cc974f docs: tracker updated — fixes landed, tests green, remaining steps are the two mnemonic ceremonies + node updates 2026-09-01 10:31:36 -04:00
archipelago 4849186ab9 docs: incident tracker for the 2026-09-01 https/launcher/LND breakage + v1.8.9 notes
Demo images / Build & push demo images (push) Successful in 3m57s
Root-cause table, fix inventory, regression-test inventory and the
deploy/live-verification checklist for today's fleet incident — written
as the working record while the fixes land, so the deploy + verify steps
can be checked off against real nodes rather than memory. CHANGELOG
carries the user-facing notes for the release these fixes ship in.
2026-09-01 10:29:10 -04:00
archipelago 3347b8b8b9 fix(ui): https app launches and the nostr bridge follow the frame's real origin
Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:

1. portAuth() looked the launch port up under the name the user clicks
   ('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
   declares those ports under the manifest id that owns them
   (archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
   portIsGateFronted answered false, and an HTTPS dashboard handed app
   frames http:// URLs — blocked as mixed content: mempool and IndeeHub
   'did not connect', bitcoin knots/core opened http:// in a new tab.
   Resolution now follows launch aliases, then a port-wide catalog scan
   that only answers when every declarer of that port agrees (a port
   any app publishes as plain HTTP is never upgraded to https).

2. The signed-catalog cache was only warmed by the Store/Discover
   views, so a user who went straight to My Apps launched apps with an
   empty cache. Warmed at dashboard mount now — fetchAppCatalog()
   already memoizes with a 1h TTL.

3. The NIP-07 bridge compared event.origin for strict equality with the
   recorded (http) app URL and replied to the recorded URL as the
   postMessage targetOrigin — both break the moment a frame is scheme-
   upgraded (cached HSTS did exactly that): every nostr request was
   silently dropped and replies to the stale origin threw. The bridge
   now matches host+port (scheme deliberately ignored) and always
   replies to event.origin — the frame's real origin.

Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
2026-09-01 10:29:05 -04:00
archipelago e382e679ae fix(apps): NPM needs /etc/letsencrypt mounted and NET_BIND_SERVICE
Converting Nginx Proxy Manager to a platform manifest (fc68c5b6) dropped
two things its image hard-requires, and the result was an endless
start/die loop — shorty-s watched it restart 3,176 times:

1. /etc/letsencrypt mount: NPM's s6 'prepare' service refuses to boot
   without it ('ERROR: /etc/letsencrypt is not mounted!'). Mounted from
   the same persistent app directory as before
   (/var/lib/archipelago/nginx-proxy-manager/letsencrypt), so existing
   certificates are preserved — no data moves, no migration.

2. NET_BIND_SERVICE: NPM's internal nginx listens on 80, 443 AND 81,
   and the orchestrator runs --cap-drop=ALL. The legacy podman-run path
   defaulted to the full capability set (and the legacy repair path in
   package/config.rs always listed it), which is why this only broke
   once the manifest became the source of truth.

The signed catalog embeds manifests with origin-wins semantics, so the
catalog carries the fix for every catalog-covered node — regenerate it
here (plus the generated store/launcher-port artifacts, which also pick
up drift from bf6ef964's retired apps). Catalog re-signing follows the
usual ceremony.
2026-09-01 10:29:05 -04:00
archipelago 77d0768a21 fix(nginx): stop pinning HSTS — actively clear it instead
The HTTPS server block sent Strict-Transport-Security:
max-age=31536000; includeSubDomains. Browsers that visited HTTPS once
cached the policy and then silently upgraded the still-open HTTP
dashboard's fetches and frames to https — a scheme change is
cross-origin, so every /rpc/v1 call died 'No Access-Control-Allow-
Origin header' while the node was perfectly healthy (framework-pt
2026-09-01: the 'Failed to fetch' storm, dashboard 'not responding',
every app frame mixed-content-blocked).

Plain HTTP is a supported access mode BY DESIGN on this platform: the
node's certificate is optional and self-signed (Settings → Node
certificate, /ca.crt flow), and setup-node-ca.sh deliberately keeps
port 80 serving for devices that haven't installed the CA. So:

- port 80 sends no HSTS at all (with the rationale inline)
- port 443 sends max-age=0, which ACTIVELY DELETES the policy already
  cached by affected browsers — leaving it absent would have kept every
  stranded browser broken for a year

tests/lifecycle/bats/nginx-hsts.bats pins all three properties at the
gate: no live policy on :80, max-age=0 (never 31536000) on :443, and
no long-lived pin anywhere in the deployed config.
2026-09-01 10:28:57 -04:00
archipelago f133d5555a feat(apps): surface Portainer's first-run setup token in the credentials interstitial
Portainer >=2.21 no longer lets whoever loads the page first claim the
admin account: on a fresh install it mints a one-time setup token and
prints it ONLY to the server logs. On an appliance that is a dead end —
'check the Portainer server logs' is exactly what a user cannot follow,
and after the 2.45.0 update it made a freshly restarted Portainer look
broken ('disappeared', then demands a token nobody can find).

package.credentials — the same RPC that powers the login-credentials
card on the app page — now extracts the setup_token line from
portainer's recent container logs and hands it over with the existing
copy-button treatment, titled and explained for a first-time user. The
token stops being printed once setup completes, and any container
recreate drops the log line, so the card disappears on its own and no
dead token lingers. Parsing is a pure, unit-tested scan against the
live-captured 2.45.0 log shape (64 hex chars after setup_token=).
2026-09-01 10:28:57 -04:00
archipelago cbd5314dd9 fix(lnd): pay through Router.SendPaymentV2 — LND 0.21 removed the old route
LND 0.21.2 removed the deprecated Lightning.SendPaymentSync REST route
(/v1/channels/transactions). The backend still called it, so every
Lightning send answered literal HTTP 404 and the wallet UI reported
'Payment failed: Not Found' fleet-wide right after the pin bump —
receive worked, which made it look intermittent.

Pay through the supported Router.SendPaymentV2 route (/v2/router/send)
instead, keeping the existing contract with the UI:
- single-record responses (no_inflight_updates) unwrapped from the
  grpc-gateway result envelope, transport errors from the nested error
- a slow multi-hop payment still resolves as pending + payment hash
  (only LND may declare failure), never a false 'Payment failed'
- LND's failure_reason codes translated to the same plain-language
  advice, invoice-expiry still says 'ask for a fresh invoice'

Guard it at the gate: tests/lifecycle/bats/lnd-api-compat.bats POSTs a
deliberately-invalid invoice to /v2/router/send on the RUNNING LND and
fails if the route answers 404 — the image/backend skew that shipped
silently last time because no test ever spoke the payment endpoint.
Also bumps the stale lnd image expectation in remote-lifecycle.sh.
2026-09-01 10:28:49 -04:00
archipelago 9fb2e1ed9e chore(catalog): sign the Cuprate logging fix 2026-09-01 08:47:29 -04:00
archipelago 7125dea05d Merge PR #152: fix Cuprate logging defaults 2026-09-01 08:39:26 -04:00
ssmithxandClaude Sonnet 5 bcdf2c75be fix(cuprate): file log level should be info, not cuprated's debug default
The previous commit on this branch copied cuprated's raw
--generate-config defaults (stdout=info, file=debug, max_log_files=7)
verbatim. Turns out that's the wrong reference: compared against
ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml — the actual
dev config this app was built and tested against — file logging is
meant to run at "info" with 14 rotated files, not the binary's raw
"debug"/7. Confirmed live on amishparadise: podman logs (stdout) was
already clean at info, but the on-disk file log
(.local/share/cuprate/logs/<date>) was flooding with per-peer DEBUG
gossip (~400KB in 2 minutes) because it inherited the binary default
instead of the intended one.

Left the resource-tuning knobs in the reference config (8GB
target_max_memory, tokio/rayon thread counts, P2P connection counts,
explicit reader_threads) out of this file — those were sized for
ssmithx's dev box and don't apply here; this manifest's
target_max_memory already stays deliberately under the container's
4Gi memory_limit.

Regenerated releases/app-catalog.json (still unsigned).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:27:33 +00:00
ssmithxandClaude Sonnet 5 e77f60085d fix(cuprate): make Cuprated.toml logging levels explicit
apps/cuprate/manifest.yml only ever wrote network/target_max_memory/
rpc.restricted.enable into Cuprated.toml, so the [tracing.stdout] and
[tracing.file] tables were silently absent — cuprated still applied
its built-in info/debug/7 defaults, but nothing on disk showed it.
Verified live on amishparadise 2026-09-01: the deployed 5-line file
had no [tracing] section at all, and the level was only discoverable
by running `cuprated --generate-config` and diffing.

Add both tables to the manifest's files[].content with the same
values cuprated already defaults to, so every new install ships a
Cuprated.toml an operator can actually read and tune. overwrite:false
means already-deployed nodes (amishparadise included) keep their
existing file untouched — this only changes what fresh installs get.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it's authoritative for
the fleet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:19:20 +00:00
archipelago 6c31eb9d4a chore(catalog): sign the LND 0.21.2 sweep 2026-09-01 07:45:39 -04:00
archipelago 63e6c64c63 fix(ci): drop the remaining stray Claude-worktree gitlinks 2026-09-01 05:27:15 -04:00
archipelago 4d8bb1fd44 fix(ci): drop a stray gitlink that broke every demo build
A Claude worktree under aiui/ was committed as a submodule gitlink
(160000) with no .gitmodules entry, so actions/checkout's recursive
submodule pass exited 128 and every 'Build & push demo images' run since
v1.8.6 failed. Removing the index entry — the worktree is local state
and was never meant to be tracked.
2026-09-01 05:27:00 -04:00
archipelago 2b4b60013c feat(lnd): build LND 0.21.2-beta in-house and sweep the pin
Demo images / Build & push demo images (push) Failing after 40s
Upstream publishes no docker images; our v0.18.4 image was built in-house.
This pass: official v0.21.2-beta release binaries (sha256-verified against
the signed release manifest), static, on alpine with the same entrypoint
shape as our existing image, pushed to our registry and smoke-run.
LND 0.21 auto-migrates the channel DB on first start (keeping a backup) —
the Update button is user-initiated, never auto-applied.
2026-09-01 05:14:50 -04:00
archipelago f0ef410948 chore(catalog): sign the swept pins 2026-09-01 04:52:28 -04:00
archipelago 19467e9b7c chore(apps): sweep pin bumps — gitea 1.27.3, vaultwarden 1.37.2, filebrowser 2.63.23, home-assistant 2026.8.3, adguardhome 0.107.79, portainer 2.45.0, pine-whisper 3.6.0
Demo images / Build & push demo images (push) Failing after 39s
First upstream sweep since v1.8.5: the safe patch/minor pins, mirrored
into our registry first (source.archipelago-foundation.org/lfg2025/*).
Held for their own careful passes: the majors (grafana 11, nextcloud,
uptime-kuma 2, bitcoin-core 29, the DBs) and consensus-sensitive apps
(fedimint, electrumx 2.0). LND 0.21.2 needs an in-house image build —
upstream publishes none.
2026-09-01 04:50:49 -04:00
archipelago 628ed252b4 chore: publish release v1.8.8-alpha 2026-09-01 04:18:16 -04:00
archipelago bc94445ca0 chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:57:23 -04:00
archipelago 04cf0f663a chore: drop the superseded v1.8.8 prep for rebuild 2026-09-01 03:49:16 -04:00
archipelago 576c642da4 fix(apps): ollama resource type + adguardhome port; gate on collisions
Demo images / Build & push demo images (push) Failing after 38s
Ollama's embedded manifest failed the typed parse (memory_limit wants a
string) so the catalog overlay was skipped for it; AdGuard Home's
conventional :3000 collided with Grafana's. The release gate now runs
the host-port collision test (repo_app_manifests_have_no_host_port_collisions)
so this class can never ship untested again.
2026-09-01 03:31:02 -04:00
archipelago 12866db84a chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:16:10 -04:00
archipelago a184254706 style: rustfmt the ssh-mesh module 2026-09-01 02:42:39 -04:00
archipelago 192e045426 feat(ui): SSH-over-mesh card, store-listing filter, icon treatment
Demo images / Build & push demo images (push) Failing after 41s
Settings gains the SSH-over-mesh card (danger-zone confirmation for the
any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog
merge filters components via the shared serviceNames module; Discover
grids get the standard icon container; install no longer yanks the user
to My Apps; v1.8.8 release notes.
2026-09-01 02:41:55 -04:00
archipelago 9ac46a69f8 feat(fips): SSH-over-mesh toggle + manifest-driven package metadata
fips/ssh_mesh.rs owns the 90-ssh.nft drop-in lifecycle: off by default,
any-peer scope behind the UI's danger confirmation or an explicit mesh
address list, reconciled on every daemon config install. The scanner now
takes installed apps' icons from their real manifest metadata (Cuprate's
Services tile) and classifies manifest-declared UI apps as launchable
even when the address probe misses (Alby Hub).
2026-09-01 02:41:55 -04:00
archipelago bf6ef9644c chore(apps): retire morphos-server, did-wallet, lightning-stack, cryptpad
Store-listing components are filtered via the shared serviceNames canon;
these four never earn a tile: MorphOS server is old, the Web5 DID wallet
and CryptPad are untested, Lightning Stack is an untracked upstream
bundle (LND covers it).
2026-09-01 02:41:55 -04:00
archipelago c32910809e chore: publish release v1.8.7-alpha 2026-09-01 01:42:12 -04:00
archipelago d2174128c5 chore: sign the v1.8.7 app catalog + release manifest 2026-09-01 01:37:24 -04:00
archipelago 2ad0171e5f fix(ui): drop the now-unused scheme helper
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 19:08:17 -04:00
archipelago 46cb0bfd37 fix(ui): gate-fronted https launches + signed-catalog App Store
Demo images / Build & push demo images (push) Failing after 36s
directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl()
now upgrade to https only for ports the app gate fronts — decided from
the signed catalog's embedded manifest ports (auth gated/open), so
plain-HTTP publishes (legacy installs, auth:none API ports like
Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog()
merges the daemon-verified signed catalog into the App Store listing
(signed entries appear immediately; community copy supplies featured
and curated metadata), and Marketplace.vue uses the same dynamic fetcher
as Discover so the grid sees signed-new apps too.
2026-08-31 18:41:00 -04:00
archipelago b8593c9090 docs(release): v1.8.7 notes — https app launches + platform round
Demo images / Build & push demo images (push) Failing after 34s
2026-08-31 18:40:51 -04:00
archipelago fc68c5b680 feat(apps): complete the manifest platform — convert the last five stragglers
Demo images / Build & push demo images (push) Failing after 36s
Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home now
carry full manifests: the app gate fronts their web ports (TLS on the
same port, node login where appropriate), installs run through the
orchestrator, and pins live in the signed catalog. Tailscale mirrors its
legacy shape exactly (userspace networking, web console on 8240, plain
HTTP for the gate to front). Ollama stays loopback-only — the
assistant's local model backend, not a web app.

Retires the four already-removed apps for good (FIPS, Nostr VPN,
Routstr, Penpot pins dropped from image-versions.sh, the generator map,
and image_versions.rs), fixes Cuprate's duplicated metadata block that
strict YAML parsers reject, and updates the port-inventory review gates
for the new open (3 own-login consoles) and exempt (2 DNS) ports.
2026-08-31 18:40:39 -04:00
archipelago 3ed75c328d style: rustfmt the signed-catalog serving 2026-08-31 17:09:16 -04:00
archipelago 687196ad3b chore: prepare release v1.8.7-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 17:08:37 -04:00
archipelago e2bd6330a1 test(app-catalog): pin the signed-catalog body gate 2026-08-31 17:08:37 -04:00
archipelago 7c0a492c43 fix(ui): launch apps on the page's scheme over HTTPS
New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
2026-08-31 17:08:26 -04:00
archipelago 3089624969 Merge remote-tracking branch 'gitea-vps2/main' 2026-08-31 16:15:17 -04:00
archipelago 5b658cec67 feat(app-catalog): serve the signed catalog from the node first 2026-08-31 16:15:13 -04:00
archipelago 21b8d4b1ee catalog: add Cuprate (0.1.0-preview) 2026-08-31 16:10:38 -04:00
lfg2025 6f05f5583f Merge pull request 'docs: session record — companion 0.5.28 shipped + deployment playbook' (#151) from companion/session-2026-08-31 into main 2026-08-31 20:03:03 +00:00
Dorian 02ac4396d1 docs: session record — companion 0.5.28 shipped + the deployment playbook
Full state at session end (all public surfaces verified byte-identical
at 0.5.28/vc48; only node web-bundle redeploys outstanding), the feature
map, and the operational playbook next sessions need: Tor SOCKS proxy
for Gitea API/curl (the 'unreachable API' was a missing proxy flag),
token scopes, protected-main ship flow via -ship branch + PR + API merge,
stale local main lineage, the foundation server's two surfaces, demo CI
auto-redeploy, build/test commands, and the open items.
2026-08-31 21:02:59 +01:00
archipelago 5ffdcc9936 docs(release): explain the v1.8.7 correction
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:46:18 -04:00
archipelago 9cf07e1eac fix(release): enforce the v1.8 What's New floor
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:44:48 -04:00
archipelago e7854702c0 chore: publish release v1.8.6-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:40:23 -04:00
archipelago d4018a6e73 chore: prepare release v1.8.6-alpha 2026-08-31 15:34:51 -04:00
archipelago b57cba63d1 Merge remote-tracking branch 'gitea-vps2/main'
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:12:59 -04:00
archipelago 7bc9f69b1f fix(settings): start What's New history at v1.8.0 2026-08-31 15:12:18 -04:00
lfg2025 913743923c Merge pull request 'docs: deploy handoff — companion 0.5.28 to the live surfaces' (#150) from companion/0.5.28-deploy-handoff into main 2026-08-31 19:01:58 +00:00
Dorian 241e8cfca4 docs: handoff — deploy companion 0.5.28 (vc48) to the live surfaces
For the archi-dev-box agent: companion 0.5.28 is on main (PR #149) and
Gitea raw serves it (verified byte-identical, v1+v2+v3). Remaining: the
foundation server's static /packages mirror (the real-node QR download
URL — currently 0.5.27), node web-bundle redeploys (same as the
2026-07-23 flow), and confirming the demo stack flipped after CI's
webhook redeploy. Exact commands, expected shasum, and final verify
block included.
2026-08-31 20:01:44 +01:00
archipelago 017505c431 fix(release): include every curated changelog item 2026-08-31 14:52:47 -04:00
archipelago 7a39d8fbd1 fix(settings): sort What's New history newest-first
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 14:50:55 -04:00
archipelago e3275353b9 fix(release): publish assets before exposing manifest 2026-08-31 14:45:29 -04:00
lfg2025 9f1a289d1a Merge pull request 'Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch' (#149) from companion/0.5.28-ship into main
Demo images / Build & push demo images (push) Failing after 41s
2026-08-31 18:38:43 +00:00
Dorian dd07da53f9 chore(android): update companion apk download 2026-08-31 19:34:57 +01:00
Dorian 7d09418a09 Companion 0.5.28 — backup & restore (#128), NIP-46 remote signer (#139), companion-gated install pitch (#61 residual)
The companion-agent queue from the 2026-08-30 handoff, complete:

- Backup & Restore: hub sub-page, SAF export/import sealed in the
  node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
  with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
  nostrconnect:// QR pairing (scanner + OS deep link), per-request
  approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
  wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
  official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
  isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
  display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
  pure public-key function — verified against the fips crate).

Issues #61 (comment), #128, #139 closed on the tracker.
2026-08-31 19:34:05 +01:00
archipelago eef35d65b7 chore: release v1.8.5-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 14:27:00 -04:00
archipelago 3b3500a7dd test(image): gate installer crash-capture payload 2026-08-31 12:38:59 -04:00
archipelago 2f0f7fd388 fix(host): repair malformed legacy kdump defaults 2026-08-31 11:16:34 -04:00
archipelago b300a720db fix(host): query package allowlist without literal quotes 2026-08-31 10:44:15 -04:00
archipelago 5b6d278c46 fix(host): preserve shell variables in privileged fixups 2026-08-31 10:24:06 -04:00
Dorian b927461f8e feat(companion): fipssh — ssh to a mesh node by npub (Termux helper)
Verified against the fips crate source: the mesh ULA is a pure function
of the PUBLIC key — fd || sha256(x-only pubkey)[0..15] — so the npub is
the durable address and needs no resolver. Android/tools/fipssh wraps
ssh for Termux: 'fipssh user@npub1…' derives the ULA (pure-python
bech32 + sha256, checksum-validated, typo protection) and execs ssh
over the companion's split tunnel; --resolve prints the ULA alone.

The derivation is pinned by a new Rust test
(npub_derives_the_same_mesh_ula_as_the_fips_identity, 3 seeds against
fips::Identity) and the helper's output was verified byte-identical
against a live fips identity pair. SSH-over-mesh handover updated with
an addendum: node docs/UI can advertise npub-based addressing, no
node-side DNS needed for this case.
2026-08-31 15:00:42 +01:00
archipelago 699669a5f7 fix(host): retain captured kdump vmcores 2026-08-31 09:57:09 -04:00
Dorian 8cf45377a2 docs: handoff — SSH over the FIPS mesh (node-side toggle) for the node agent
Today's field test: Termux over the companion's split tunnel reaches the
node's fips0 ULA and gets RST — the mesh path works end to end, port 22
is refused by the node (fips0 default-deny, no 22 in the fips.d drop-ins;
sshd IPv6 listening unverified). The interim manual unblock (a
source-restricted 90-ssh.nft drop-in) is documented, but the real ask is
a first-class 'SSH over mesh' settings toggle in the node UI, with the
drop-in lifecycle owned by the daemon, a source-scope decision (paired
phones vs any mesh peer), sshd preflights, and an acceptance checklist.
Companion side is done (device-wide split tunnel + the node ULA now
displayed/copyable in the hub Nodes page) — the node agent is downstream.
2026-08-31 14:54:28 +01:00
Dorian 4efac99e97 feat(companion): show + copy the node's mesh ULA in the hub Nodes list
FIPS nodes carry their fips0 ULA in the saved entry, but it was never
displayed — the only way to learn it was the node itself. Each FIPS
node row in the Nodes page now shows its mesh address as a monospace
subtitle with a tap-to-copy affordance, which is exactly the address
other apps on the phone (Termux ssh over the split tunnel, for
example) need. Non-FIPS entries are unchanged.
2026-08-31 14:48:53 +01:00
Dorian 981296e8b0 fix(companion): backup + signer live inside the hub modal, not standalone screens
Field feedback on 0.5.28: the standalone Backup/Signer screens were hard
to read over the synthwave background, back left the app instead of the
menu, and they broke the hub's one-container interaction model. Both are
now hub sub-pages exactly like Nodes/FIPS:

- BackupSection / SignerSection (ui/components) render inside the NESMenu
  panel with the menu's own dark glass surface, scrim, and palette — the
  readability and theming problem disappears with the standalone surface.
- The header back arrow returns to the hub card page (same as Nodes).
- The panel height cap drops from 92% to 70% of the screen — ~15%
  breathing margin top and bottom; content scrolls inside.
- The pairing QR scanner is hosted by NESMenu OUTSIDE the panel
  (QrGlassModal is a full-screen Box, not a Dialog — inside the panel's
  scroll it would clip), and decoded nostrconnect:// URIs funnel into the
  signer section through the same latch as the deep link.
- The nostrconnect:// deep link now routes to the session and pops the
  hub open on the signer sub-page (SignerLaunch singleton) instead of a
  dedicated route; standalone screens and routes removed.
- BunkerManager.refreshState is now a proper suspend fun (was
  runBlocking on the caller's dispatcher).

Docs updated to the new locations. Rebuilt for on-device testing
(v0.5.28-debug/vc48, same signing cert).
2026-08-31 14:23:04 +01:00
archipelago 54431fc856 fix(host): enforce the full kdump crash reservation 2026-08-31 09:16:18 -04:00
Dorian 22f8129b52 feat(companion): backup & restore + NIP-46 remote signer — 0.5.28 (#128, #139)
Companion 0.5.28 (versionCode 48), the companion-agent queue items:

#128 Backup & Restore — the phone side of losing your phone or wiping it
to cross a border. Hub card → SAF export/import of an encrypted .json:
everything the app holds (servers+passwords, FIPS identity/peers, signer
key) sealed in the node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305,
native backup.rs — same blob layout as the node's, node-shaped envelopes
decrypt too). Restore is merge-only: servers upsert npub-first, identity
and signer key adopt only when absent, peers union by npub. No cloud, no
telemetry — the file goes wherever the user saves it.

#139 Remote Signer — the phone IS the NIP-46 bunker. Generate/import a
nostr key, scan a nostrconnect:// QR (in-app scanner or deep link), and
approve/deny each sign_event request from a legible card (kind label,
content, tags, time) — nothing signs without a human. Wire-faithful to
rust-nostr's reference bunker (connect-carrying-secret handshake, NIP-44
v2 transport with NIP-04 receive fallback, kind-24133 responses);
get_public_key/describe/ping handled, everything else 'not authorized'.
Session state in BunkerManager, UI in SignerScreen, hub card wired.

Plus NativeCore (JNI object for the new native surface), FipsPreferences
peers-merge for restore, nostrconnect:// intent filter, and the release
docs (companion-backup-restore.md, companion-nip46-remote-signer.md).

Also Android/tools/nip46-test-client.py: a pure-Python NIP-46 client that
plays the node's login role (QR, handshake, get_public_key, sign_event)
and verifies the phone's signature with an independent BIP-340 — the
end-to-end test for the feature until node-side lands. Its crypto matches
the official NIP-44 + BIP-340 vectors byte-for-byte, the same vectors the
Rust core passes, so the two interop by construction.

Built + smoke: assembleDebug v0.5.28/vc48, same signing cert as the
served 0.5.27 (d622e07e…644d) so it updates in place.
2026-08-31 13:53:52 +01:00
Dorian 57e31eb192 feat(companion): backup envelope + NIP-46 signer crypto in the native core
Extends archy-fips-core with the two companion-release features' crypto
(#128, #139), same JNI-over-JSON contract as the mesh surface:

backup.rs — the ADR-005 encrypted-backup envelope, byte-compatible with
the node's backup code (Argon2id default params + ChaCha20-Poly1305,
blob = base64(salt||nonce||ct)); decrypt ignores extra envelope fields
so node backups read here too. Round-trip, tamper, wrong-passphrase and
cross-shape tests included.

nostr.rs — the phone-side remote-signer crypto: nsec/npub bech32 keys,
BIP340 schnorr event signing (NIP-01 id serialization), NIP-44 v2
payloads (HKDF-SHA256 + ChaCha20 + HMAC-SHA256, both padding prefixes),
NIP-04 fallback, nostrconnect:// parsing with repeated relay params.
Verified against the official NIP-44 vectors (conversation keys, message
keys, padded lengths, byte-exact encrypt vectors), the BIP-340 reference
sign vectors, and round-trip/tamper/failure tests. secp256k1 0.29 note:
Keypair::public_key() is the 33-byte compressed key — x-only pubkeys
must go through .x_only_public_key().0 (one real bug the vectors caught).

JNI glue adds com.archipelago.app.NativeCore: backupEncrypt/Decrypt,
nostrGenerateSecret/SecretFromAny/ParseConnectUri/SignEvent and the
NIP-44/NIP-04 cipher pairs. Android arm64 build verified via cargo-ndk
(7.2 MB .so, +0.4 MB for both modules). Host: cargo test 23/23, clippy clean.
2026-08-31 13:29:36 +01:00
Dorian 12c853da45 docs(companion): verify the zxing-cpp integration sketch online
The QR-decoder option doc was written on an offline machine with the
Maven coordinates and wrapper API flagged as from-memory. Verified
against Maven Central + the wrapper source: artifact is
io.github.zxing-cpp:android:3.1.1 (current release), Format.QR_CODE is
nested inside BarcodeReader (not a top-level BarcodeFormat), options are
a constructor-argument data class, and read(ImageProxy) handles the
Y-plane/cropRect/rotation itself. Sketch updated accordingly; the option
itself stays NOT-actioned pending the move-to-the-code decision trigger.
2026-08-31 13:06:45 +01:00
archipelago 3409db569e docs(release): complete the v1.7.44→current release-notes audit
Demo images / Build & push demo images (push) Failing after 37s
The RELEASE_NOTES_BACKLOG gate for cutting the next release, closed out:

- Eight sections backfilled to the curated standard, from the Settings
  What's New blocks, the old-lineage release commits, and the hotfix
  diffs: v1.7.44 (was four raw commit-hash lines), v1.7.47/48/64/65
  (thin), and v1.7.50/51/107 (real tagged releases whose sections were
  missing entirely — v1.7.107 restored verbatim from the curated copy
  at 35e9c624 that later went missing).
- Mechanical inventory across all 92 sections in range: every section
  now has ≥3 curated bullets, zero raw-hash entries.
- What's New modal regenerated for the three restored versions
  (sync-whats-new --check passes, 92 versions present).
- Manifest-notes-only confirmed by construction: the manifest reads its
  changelog from CHANGELOG.md and check-release-manifest.sh rejects raw
  or thin entries before publishing.

Evidence trail for the backfills is recorded in
docs/RELEASE_NOTES_BACKLOG.md.
2026-08-31 08:05:42 -04:00
Dorian d259f3cbb9 fix(web): companion-gate the store banner + manual intro trigger (#61 residual)
Only the auto-popup was companion-gated — inside the companion WebView
users still saw the 'install the companion' banner in the App Store and
could pop the intro overlay through it. Gates all three paths on
isCompanionApp(): CompanionBanner self-hides, openCompanionIntro() is a
no-op, and the manual-open watcher in CompanionIntroOverlay refuses to
open (the overlay's raw window check also moves to the canonical helper
so there is exactly one detection). No APK change.
2026-08-31 12:56:32 +01:00
archipelago cb71c25ea0 chore(catalog): carry the Cuprate store entry into the frontend public catalog
Demo images / Build & push demo images (push) Failing after 39s
generate-app-catalog.py only updates entries that already exist in each
catalog file, so the hand-curated cuprate entry (added to
app-catalog/catalog.json with 7b88ba59) never propagated to
neode-ui/public/catalog.json — the sync's field-bumps did, the new entry
did not. Both catalogs now carry identical 31-entry lists (verified
content-equal), so the browser-side store copy and the curated one agree.
2026-08-31 07:48:31 -04:00
archipelago c5eeb31055 fix(ui): wifi setup on a fresh install — reveal toggle + a no-network callout (#145)
Demo images / Build & push demo images (push) Failing after 39s
Two reports from a fresh install without a cable:

(a) No way to see the WiFi password being typed. Every password field in
    the app was a bare type=password input. PasswordRevealInput is the
    reusable fix — masked by default, one-tap eye toggle, v-model and
    enter pass-through — first applied to the WiFi prompt in ServerModals
    so a long key typed from across the room can be verified.

(b) WiFi settings are undiscoverable with no wired internet. New
    OnboardingNetworkCallout floats over every onboarding step when the
    node has NO physical link at all (no ethernet up, no WiFi associated
    — polled from network.list-interfaces, self-dismissing the moment a
    link exists) and deep-links 'Connect to WiFi' to
    /dashboard/server?open=wifi, which Server.vue consumes by popping the
    WiFi picker on arrival. Deliberately scoped the other way too:
    Archipelago is offline-first, so 'no internet' never nags — only 'no
    link at all', only during onboarding (the wrapper hosts /login too;
    the callout is restricted to /onboarding/* routes), and a failed probe
    stays silent. The query is consumed via history.replaceState so a
    KeepAlive tab-return never re-pops the modal, and Server.vue keeps
    reading it from the real URL rather than vue-router — its
    KeepAlive-mounted tests have no router context to give.

Verification: full frontend suite 1023/1023; type-check clean; production
build clean with both new strings confirmed in the emitted bundles
(OnboardingWrapper + Server chunks).
2026-08-31 07:47:47 -04:00
archipelago 966db4810a docs: companion-agent handoff — work queue for #61-residual, #128, #139
Hands the companion-owned work to the companion agent with precise
pointers (Android/ source, served APK at 0.5.27/vc47 + the deploy
pipeline from the 2026-07-23 handoff, the ArchipelagoNative bridge and
isCompanionApp gating pattern) and the queue: the ungated
CompanionBanner/intro-trigger residual of #61 (Discover.vue:156,
useCompanionIntro's openCompanionIntro), GrapheneOS backup/restore (#128,
reusing the node's ADR-005 backup envelope), and the NIP-46 remote-signer
phone side (#139, with the signer-login research doc as background).

Tracker labels applied earlier: #128 and #139 carry 'companion-agent'.
2026-08-31 07:32:41 -04:00
archipelago 51a5473e22 docs(release): v1.8.5-alpha changelog section + What's New sync
Demo images / Build & push demo images (push) Failing after 42s
Curated release notes for the pending v1.8.5-alpha: Cuprate (with the
two review catches), kdump/rasdaemon + the host-fixup OTA channel, the
uninstall-abort fix, federation inline-picture routing, honest disk
usage, the three lying-screens fixes (#143/#127/#129), durable mesh
notifications + router recovery (#57/#103), and upstream-release tracking
with the first-sweep safe bumps.

What's New modal synced via scripts/sync-whats-new.py (--check passes;
89 versions, all present). Per docs/RELEASE_NOTES_BACKLOG.md the
v1.7.44-alpha -> current section audit remains the open item before the
tag.
2026-08-31 07:23:53 -04:00
archipelago 1872fc20ee feat(image): bake kdump + rasdaemon into fresh installs (#144)
The ISO's Dockerfile.rootfs gains kdump-tools/kexec-tools/rasdaemon with
USE_KDUMP=1, dumps to /var/crash and a compressed core collector, the
hang/panic sysctl drop-in, and rasdaemon + kdump-tools enabled — and the
installed target's GRUB cmdline gains crashkernel=256M next to the
existing quiet/splash line.

Source of truth note: the edit lands in
image-recipe/_archived/build-auto-installer-iso.sh — the builder that
generates the (git-ignored) image-recipe/build/auto-installer/ workspace,
which a cache-hit can reuse. The workspace copy was updated to match so
even a cached build ships the same state. Host fixups (previous commit)
converge already-deployed nodes to exactly this end state, so fresh and
old installs agree.

bash -n clean on the builder.
2026-08-31 07:23:53 -04:00
archipelago cbd463e980 feat(host): crash/hardware-error capture, delivered by a new host-fixup OTA channel (#144)
kdump + rasdaemon on every node, per docs/kdump-rasdaemon-design.md with
the approved decisions: hang capture ON (a wedged kiosk dumps and reboots
itself instead of sitting dead), crashkernel=256M, backfill ships with
this release, phase-2 UI surfacing deferred.

Host fixups (docs/system-level-ota-design.md) are the general answer to
'deliver system-level updates OTA': curated OS packages, sysctl drop-ins,
service enablement and the GRUB crashkernel line, carried by the signed
binary and applied idempotently at startup — non-fatal by construction
(offline/locked-dpkg nodes converge on a later boot), skipped on dev
boxes and non-Debian hosts. This formalizes the polkit/audio repair
precedents into a channel with a stated policy: pinned packages and
parameter intent only, never dist-upgrade automation; the ISO bakes the
identical end state into fresh installs (next commit).

The one runtime limitation is honest: crashkernel memory can only be
reserved at boot, so the fixup writes GRUB, runs update-grub, and logs
that it takes effect on the next reboot.

tests/lifecycle/os-audit.sh gains section D — a graded baseline check:
FAIL if capture never landed, WARN if written but awaiting reboot, PASS
when reserved, policy live and rasdaemon recording. Section D runs
independently of RPC health: a wedged backend must not mask that the
node also stopped capturing evidence.

Verification: host_fixups unit tests 4/4; cargo fmt clean; full suite
runs in the release gate (create-release) and the archi-dev-box
lifecycle gate before the tag.
2026-08-31 07:23:44 -04:00
archipelago 9df580bf2b docs: peering trust terminology — names for the four concepts (#134)
Gives stable names to what issue #134 showed gets conflated: Trusted peer
(invite-verified, operator decision), Discovered peer (learned from a
Trusted peer's advertisement, hard-capped at Observer — TRUST IS NOT
TRANSITIVE), Routing hint (what a Discovered peer actually contributes:
reachability, not trust), and Peer advertisement (the mechanism itself,
a feature not a leak).

Records the two rules that make the model sound (trust requires a
traceable operator decision; discovery is transitive, trust is not), why
advertisement exists (one invite makes a node reachable to the trusted
set without granting anything), and the deferred open questions: the
'don't advertise my peers' privacy toggle and UI tier vocabulary.
2026-08-31 07:23:44 -04:00
archipelago aee7ecaac1 docs: index the kdump/rasdaemon design 2026-08-31 06:11:15 -04:00
archipelago e51ceaa250 docs: draft kdump + rasdaemon troubleshooting design (#144)
Design for capturing post-mortem and hardware-error evidence on fleet
nodes: kdump (crashkernel=256M, dump to /var/crash on the unencrypted
root — never the LUKS data partition, so the crash kernel never handles
key material; makedumpfile-compressed, keep-2 retention) and rasdaemon
(EDAC/ECC events into sqlite on the same root).

Deliberately phased: phase 1 = capture on the image + bootstrap backfill
for existing nodes (kernel cmdline can't travel by OTA; takes effect on
next reboot); phase 2 = a read-only system.diagnostics surface in the
UI, only after a fleet node has produced a real dump.

Four decisions flagged in the doc: hang-capture on/off (recommended ON
— a wedged kiosk is useless anyway, and this turns every freeze into
evidence + self-reboot), crashkernel size, backfill timing, and phase-2
scope. Implementation touchpoints listed (Dockerfile.rootfs,
auto-install.sh:1810 cmdline, kdump-tools config, bootstrap, lifecycle
gate assertions).
2026-08-31 06:10:55 -04:00
archipelago 7c9559aa57 chore(catalog): sign the catalog — Cuprate ships, safe pin bumps land
Signed by the release root (ceremony verify passed locally before push).
Contents of this catalog over the previous one:

  NEW   cuprate           0.1.0-preview-18-g618ff14 — alternative Monero
                        node (Rust); image verified present in the mirror
                        registry; manifest embedded; store entry curated
                        (money / optional)
  BUMP  strfry            1.1.1 -> 1.1.2
  BUMP  btcpay-server     2.4.2 -> 2.4.3
  BUMP  netbird (nginx)   1.31.3-alpine -> 1.31.4-alpine
  BUMP  pine   (nginx)    1.31.3-alpine -> 1.31.4-alpine

All bump targets verified pullable from their public registries before
editing. The three mirror-backed bumps (vaultwarden 1.37.2-alpine,
archy-nbxplorer 2.6.11, home-assistant 2026.8.3) remain parked on
app-bumps-mirror-pending until a live registry-push token exists for the
lfg2025 namespace.

Drift gate clean: check-app-catalog-drift.py --release --strict
(31 store entries, 0 drift, 0 missing). 69 catalog entries total.

Nodes pick this up on their next hourly catalog refresh (or at startup)
— signature verified against the release-root key before application.
2026-08-31 05:56:00 -04:00
archipelago 7b88ba59b2 chore(apps): bump the pins that need no mirroring; curate Cuprate's store entry
Demo images / Build & push demo images (push) Failing after 40s
Pin bumps (all verified pullable from their public registries before
editing, so none can become an image-not-found on a node):

  strfry           1.1.1 -> 1.1.2              (dockurr/strfry, direct pull)
  btcpay-server    2.4.2 -> 2.4.3             (docker.io/btcpayserver, direct pull)
  netbird (nginx)  1.31.3-alpine -> 1.31.4-alpine
  pine   (nginx)   1.31.3-alpine -> 1.31.4-alpine

image-versions.sh moved in lockstep for BTCPAY_IMAGE — it is the baseline
the update badge compares against. Held back deliberately, per the risk
policy from the Aug-17 pass: gitea (four minors of DB migrations),
portainer (six minors), filebrowser (2.27 -> 2.63), fedimint/gateway
(0.8 -> 0.12, real migrations), lnd (money-critical), netbird-server/
netbird-dashboard (0.x, must move in lockstep), and everything with a
major jump or a data migration.

Cuprate also gets its curated store entry (category money, tier optional,
icon, repo) — same shape as the Alby Hub / phoenixd entries — synced
through generate-app-catalog.py into both store catalogs and the
app-session config. The fips launch-port list is unchanged (Cuprate has
no UI port; the generated file round-trips to the committed bytes after
cargo fmt).

Three further bumps are prepared and parked on the
app-bumps-mirror-pending branch, blocked only on a registry-push token:
vaultwarden 1.37.2-alpine, archy-nbxplorer 2.6.11, home-assistant
2026.8.3 — all mirror-backed, and the push credential on record for the
lfg2025 namespace is dead.

Drift gate: check-app-catalog-drift.py --release --strict clean
(31 store entries, 0 drift, 0 missing). appSessionConfig tests 7/7.
2026-08-30 16:22:26 -04:00
archipelago b12d1d3826 feat(apps): track the last untracked apps' upstreams
Five apps had no app.upstream block, so nothing could ever tell us
when their pins fell behind upstream:

  barkd           gitlab ark-bitcoin/bark   (GitLab-only project)
  immich-postgres ghcr  immich-app/postgres (image exists only on ghcr.io)
  indeedhub-minio github minio/minio
  pine-whisper    dockerhub rhasspy/wyoming-whisper
  lightning-stack manual — no public listing exists for
                   lightninglabs/lightning-stack anywhere (docker.io,
                   ghcr.io, github.com all checked), so it is tracked by hand

This adds two fetchers to scripts/check-upstream-releases.py to reach the
first two: latest_gitlab (GitLab releases API; strips the project-name
tag prefix, e.g. bark-0.6.2 -> 0.6.2) and latest_ghcr (anonymous pull
token + tags/list, the same handshake a docker pull performs).

Live-verified after the change:
  barkd            0.3.0 -> 0.6.2   (bump gated on ark_client.rs REST compat)
  immich-postgres  14-vectorchord0.4.3-pgvectors0.2.0 -> 17-vectorchord0.4.3-pgvector0.8.0
  indeedhub-minio  RELEASE.2024-11-07T00-52-20Z -> latest (date-opaque: UNCOMPARABLE, shown for hand comparison)
  pine-whisper     3.4.1 -> 3.6.0   (tuned-args revision needs re-basing, not just a pin move)

Offline coverage check: 59 apps, 0 untracked.
2026-08-30 16:22:11 -04:00
archipelago 698e915df2 Merge PR #141: package Cuprate, an alternative Monero node
Demo images / Build & push demo images (push) Failing after 37s
2026-08-30 14:18:42 -04:00
ssmithxandarchipelago a179df66d8 docs: add app update strategy, SSH access, and app wishlist to TODO
Flags the app update policy already noted as unresolved in
app-developer-guide.md, adds a section for SSH access strategy, and
starts an app wishlist (Cashu wallet, phoenixd) for packaging.
2026-08-30 14:01:20 -04:00
ssmithxandarchipelago 771ff0d28b docs: add TODO.md backlog and link from docs index
Captures unscoped forward-looking items (peering/federation model,
distributed git & OTA, nostr integration, platform/OS, app testing,
observability, and the dev/build process) so they're tracked outside
of ROADMAP.md's curated public summary.
2026-08-30 14:01:20 -04:00
92111385b7 fix(mesh): don't offer radio-only resource transfer to radio-unreachable peers
The federation fallback in the plain content-inline path wasn't enough —
mesh.transport-advice recommended the "resource-mesh" tier purely from our
own device being Reticulum-capable, without checking that THIS peer
actually has a radio route. For a federation-only contact (no radio twin)
that steered the frontend into send-content-inline's Reticulum
resource-transfer path, which has no dest_prefix to send to and fails with
"Peer is federation-only (no radio twin)" — reproduced after deploying the
first fix on a live node.

Adds MeshService::has_radio_route(contact_id), and gates both the
"resource-mesh" tier in mesh.transport-advice and the resource-transfer
branch in mesh.send-content-inline on it. Federation-only peers now fall
through to the has_tor branches, which route the frontend to
mesh.send-content (already correctly federation-aware) instead.

Landed from PR #133 (re-committed to drop private host details from the
original message; content identical).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:18:08 -04:00
a9e52fa310 fix(mesh): route send-content-inline over federation for radio-less peers
mesh.send-content-inline always called send_typed_wire (the LoRa/radio
path), which fails with "Peer is federation-only (no radio twin)" for
any contact reachable only via Tor federation — reproduced sending a
picture from the companion app to a federation-only peer. mesh.send-content
already resolves the peer's federation onion and falls back to
send_typed_wire_via_federation; mirror that same lookup here.

Landed from PR #133 (re-committed to drop private host details from the
original message; content identical).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:18:08 -04:00
archipelago b4714f1773 fix(store): defer multi-version app version choice (#129)
Demo images / Build & push demo images (push) Failing after 39s
2026-08-30 10:23:58 -04:00
archipelago d79ca54019 fix(wallet): disclose backup passphrase only when needed (#127) 2026-08-30 10:23:58 -04:00
archipelago 758332d63d fix(openwrt): make stale router config recoverable (#103) 2026-08-30 10:23:58 -04:00
archipelago ee5123af68 test(ui): satisfy strict build indexing
Demo images / Build & push demo images (push) Failing after 41s
2026-08-30 10:18:02 -04:00
archipelago a624d11b6a fix(mesh): make radio message notifications durable (#57) 2026-08-30 10:16:33 -04:00
archipelagoandClaude Opus 5 2c984fbd49 fix(ui): the IBD-finished toast no longer tells a node without LND to fund its wallet
Demo images / Build & push demo images (push) Failing after 52s
When Bitcoin's IBD completed mid-Lightning-goal, the watcher toasted
"you can now fund your wallet" — but the on-chain wallet lives in LND,
not Bitcoin Core. The watcher only checked that the goal had pending
manual steps, never that the install-LND step had completed, so a user
whose LND wasn't installed yet was pointed at a flow that could not
work: the fund modal's address comes from lnd.newaddress and does not
exist until LND is installed (issue #143).

The toast now checks LND's install state at fire time. With LND
installed the message is unchanged; without it, the toast says the
actual next step — install Lightning (LND) — and the Finish setup
button lands on the goal wizard, whose active step is the pending
install-LND one (the wizard itself was already correctly sequenced).

The watcher had no tests; added four pinning its contract: the two
message branches, silence with no in-progress goal, and silence when
the chain was already synced at page load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:24:01 -04:00
archipelago c188d9de78 fix(lifecycle): abort unsafe declarative uninstall 2026-08-23 07:59:40 -04:00
archipelago 37a82fd2f9 fix(cuprate): avoid Penpot RPC port collision 2026-08-23 01:43:09 -04:00
archipelagoandClaude Opus 5 a9a30406df fix(disk): count reserved blocks as used, not free
Disk usage was computed as used/size, where size is the raw device size.
ext4 reserves 5% of the filesystem for root — 92.4 GiB of this node's
1.8 TiB — which size includes but nothing can allocate. Two consequences,
both live on archi-dev-box today:

The dashboard advertised 251 GiB free when only 159 GiB could actually be
written, and reported 86.2% usage against df's 90.8%.

Worse, disk_monitor triggers automatic cleanup (podman image prune) at
90%. The disk has been genuinely above that threshold while this returned
86.2%, so the cleanup never once fired — which is exactly how ~72 GB of
dangling images accumulated unnoticed, and why deleting apps appeared to
free nothing.

Both call sites now ask df for avail and use used/(used+avail): the same
figure df itself prints, and the space an operator can actually spend.
Callers deriving free as total - used now get avail.

Note this shifts disk_total_bytes in the analytics series down by the
reserve; historical samples are not comparable across this change.

Tests updated for the three-column output, plus a regression test built
from this box's real numbers asserting the corrected math crosses the 90%
threshold the old math missed. 15/15 disk_monitor tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:01:18 -04:00
archipelagoandClaude Opus 5 f1b5d2d267 fix(cuprate): stop publishing the unauthenticated unrestricted RPC
The manifest bound cuprated's unrestricted RPC (full node control) to
0.0.0.0 inside the container with
i_know_what_im_doing_allow_public_unrestricted_rpc = true, relying on
ports[].bind: 127.0.0.1 to keep it private. That only restricts the HOST
side. Verified live on archi-dev-box 2026-08-22: a peer container got a
valid unauthenticated get_info off container port 18081 — and still did
after cuprate was moved to its own network, because podman bridges route
to each other unless created with --opt isolate=true, which the
orchestrator's auto-create does not pass. Every app on the node could
therefore drive full node control with no credential.

The PR justified this as the pattern bitcoin-knots already uses, but
knots writes rpcuser/rpcpassword from generated secrets, so a 0.0.0.0
bind there still is not control without credentials. cuprated has no RPC
authentication at all, so the two are not equivalent.

Unrestricted RPC is now left at cuprated's own default — container
loopback only, published nowhere, reachable by nothing — which is what
upstream intends by refusing a non-local bind without an explicit
override. Restricted RPC (the safe-for-public subset wallets use) and p2p
are unchanged, and health_check moves to 18089 since 18184 is gone.

Re-verified after the change: peer container gets connection refused on
18081 (exit 7), restricted RPC and the health endpoint still answer, the
node still syncs, validator APPROVED, 76/76 container tests pass
including the unauthenticated-port canary (still 28 — an auth: local
port was removed, not an auth: none one).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 03:10:53 -04:00
ssmithxandClaude Sonnet 5 d6b48ce095 feat(apps): package Cuprate, an alternative Monero node
Full-node daemon: P2P + Monero's own restricted RPC (the safe-for-public
subset wallets use as a "remote node") are auth:none like bitcoin/electrumx's
equivalents; unrestricted RPC (full node control) stays gated auth:local.
readonly_root works cleanly since the upstream image is FROM scratch with
ownership fixed at build time — no runtime chown/setuid needed, unlike
bitcoin-knots/core.

Verified locally end-to-end before committing: built the upstream Dockerfile,
confirmed the generated Cuprated.toml against `cuprated --generate-config`/
`--dry-run`, and ran the real image with the manifest's exact ports/volumes —
including discovering that cuprated's own 127.0.0.1-default RPC bind is
unreachable through a published host port and needs to bind 0.0.0.0
internally with ports[].bind:127.0.0.1 doing the actual restriction, the
same pattern bitcoin-knots' RPC port already uses in this repo.

Bumps the unauthenticated_ports_are_all_accounted_for canary (26 -> 28) for
cuprate's two auth:none ports, per that test's own review-before-updating
contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 13:58:06 +00:00
archipelago 9c5164372e chore: release v1.8.4-alpha 2026-08-20 07:15:01 -04:00
archipelagoandClaude Opus 5 e38b148d8e fix(release): spell out how the mnemonic prompt actually submits
The signer reads stdin to EOF, so pressing Enter submits nothing and a
second paste simply appends to the first. Step [6b/8] said only "paste the
release master mnemonic when prompted", which gives no hint that Ctrl-D is
what ends the input — a 24-word phrase arrived today as "invalid word
count: 89", about four pastes concatenated by someone reasonably assuming
Enter had not worked.

sign-manifest.sh already explains this properly; create-release.sh now says
the same thing, including that pasting twice is itself a failure mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:27:42 -04:00
archipelagoandClaude Opus 5 e03a2fed89 fix(release): surface frontend build failures instead of hiding them
`npm run build 2>&1 | tail -3` threw away npm's exit status, so a failed
build was indistinguishable from a good one. The run continued and blamed
the next check instead — "the frontend build no-opped or its output is
stale" — which points at a stale dist rather than at the build error that
actually happened, and cost a diagnosis cycle today.

Success still prints the same quiet 3 lines; a failure now prints the real
error, keeps the full log, and aborts on the spot.

Verified both branches with a stubbed npm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 05:24:37 -04:00
archipelagoandClaude Opus 5 3d7de3e902 fix(release): a dateless changelog header silently skipped the release
Demo images / Build & push demo images (push) Failing after 38s
create-release aborted at [4/8] with "web/dist/neode-ui does not contain
v1.8.4-alpha — the frontend build no-opped or its output is stale". The
build had not no-opped: it was fresh, and simply had no 1.8.4 string to
embed.

sync-whats-new.py only matches '## vX.Y.Z (YYYY-MM-DD)'. The entry read
'## v1.8.4-alpha (draft — date set at cut)', so the version was invisible
to it: the gate's whats-new-sync stage reported "87 versions, all present"
while the release being cut had no What's New block. That modal is the
only place a version string appears in the frontend, so the bundle carried
none and the freshness check — correctly — refused it, while naming the
wrong cause. Step [5/8] only greps for '^## v1.8.4-alpha (' so it passed
the draft too.

Three changes: date the v1.8.4-alpha entry, insert the modal block it was
owed, and make the sync tool refuse any version header without a real date
instead of skipping it. Skipping is what let a wrong "all present" through.

Verified: the draft header now fails the check with an explicit message,
the dated one passes (88 versions, up from 87), and a rebuilt bundle
contains 1.8.4-alpha where it did not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:01:39 -04:00
archipelagoandClaude Opus 5 60c1db98bb fix(ui-tests): raise the vitest timeout so a busy box cannot fail the gate
Demo images / Build & push demo images (push) Failing after 40s
Four unrelated tests failed the release gate at once today — every one of
them "Test timed out in 5000ms", none an assertion. Wall times were 6.3s,
16.5s, 5.5s and 36.2s for tests that normally finish in milliseconds
(useModalKeyboard's takes 349ms on an idle box), and the whole suite took
405s against its usual ~70s. The cause was CPU starvation from a
concurrent cargo build, not anything in the code.

The 5s default says nothing about these tests and everything about the
machine: this box also runs a live node, so a gate run can always collide
with a build or container churn. 20s survives that while still bounding a
genuine hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:06:14 -04:00
archipelagoandClaude Opus 5 f5b112c508 fix(gate): stop reporting a compile timeout as a test failure
cargo-test-weekly failed twice today with exit 124 at unit 427/429 — the
non-incremental test-profile build running out of wall clock mid-compile,
before a single test executed. The summary said only "FAIL: cargo-test-
weekly", which reads as a broken test and sends you hunting for one that
does not exist.

Two changes: the ceiling goes 1500s -> 3600s (580s was already found too
short; 1500s now dies on the biggest link on a loaded, swapping box), and
stage() names exit 124 as a timeout rather than printing a bare code.

Verified both reporting branches: a timed-out stage and an ordinary
non-zero exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:50:52 -04:00
archipelagoandClaude Opus 5 5ccef0ac2f feat(release): attach the installer ISO to the Gitea release automatically
Publishing the ISO was a manual step printed as a reminder at the end of
build-iso-release.sh: upload the ISO, its .sha256 and the signed checksum
JSON by hand. Only the OTA binary and frontend tarball were automated.

publish-release-assets.sh now uploads all three when an ISO for the
version exists in image-recipe/results/, with the same supply-chain rules
the OTA manifest already gets: the checksum JSON must be signed by the
pinned release root, the signature must cryptographically verify, and the
image must still match its own .sha256 (a truncated or half-copied ISO is
exactly what a signed checksum exists to expose). After upload it
confirms every asset landed at its exact local size.

The stage runs AFTER main is pushed, deliberately. The ISO is not
referenced by releases/manifest.json, so no node's OTA path depends on
it — running it last means a slow or failed multi-GB upload can never
delay or strand an OTA release that has already been verified. When no
ISO exists yet (the usual case, since the ISO build needs the tag this
script pushes) it explains how to build and attach one, and exits clean.

Uploads take a max-time argument: 4h and a progress bar for the ISO,
where the previous fixed 15-minute silent ceiling would have killed a
multi-GB transfer partway through.

Verified with a stubbed harness: no-ISO skip, missing .sha256, unsigned
checksum, wrong signing key, corrupted image, happy path, and a truncated
upload caught by the size check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:33:54 -04:00
archipelagoandClaude Opus 5 e79ab37da7 chore(release): bump version to 1.8.4-alpha
Demo images / Build & push demo images (push) Failing after 40s
Left uncommitted by an aborted create-release run on 2026-08-19: the
version bump landed in the tree but the release never reached its tag or
manifest. Committing it so the tree is clean before the release is re-cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:04:04 -04:00
archipelagoandClaude Opus 5 d75963de10 fix(gate): show which UI test failed instead of swallowing it
The ui-unit-tests stage piped vitest through `tail -4`, which cut off the
failure block. A red gate reported "1 failed | 999 passed" and nothing
else — no file, no test name, no assertion — so the failure could not be
diagnosed after the run.

Success still prints the quiet 4-line summary; failure now dumps the full
log and keeps it on disk so a scrolled-off terminal isn't the end of it.

Verified both paths: green run unchanged, and a deliberately failing spec
now surfaces its file, test name, assertion and line number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:33:09 -04:00
archipelagoandClaude Opus 5 c788dff42d style: apply cargo fmt so the release gate can run
The release gate's first real stage is `cargo fmt --check`, and it had
44 diffs across 15 files — enough to abort `create-release.sh` at step 0
before it touched a version number. Some of that drift is mine from the
last two days, some predates it in files I never opened
(bootstrap.rs, ghost_reaper.rs, openwrt/router.rs), and one is the
regenerated fips/app_ports.rs.

No behaviour change — rustfmt only.

Gate now: 8 of 9 green. The remaining red is cargo-test-weekly exiting
124, which is the 25-minute `timeout` expiring during a cold
CARGO_INCREMENTAL=0 rebuild on a loaded node — the tests never started.
Not a test failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:38:41 -04:00
archipelagoandClaude Opus 5 bd299318c0 chore(catalog): re-sign the catalog with the new pins, and fix a stale firewall list
Demo images / Build & push demo images (push) Failing after 41s
Regenerates both catalogs from the manifests so the 15 pin bumps become
real. The catalog overrides on-disk manifests on every node, so until
now those bumps were edited but inert.

There are two catalogs and regenerating one is not enough:
generate-app-catalog.sh writes releases/app-catalog.json (the signed one
nodes fetch), while generate-app-catalog.py writes app-catalog/catalog.json
and neode-ui/public/catalog.json (the source pair, the second baked into
the frontend app store). check-app-catalog-drift.py --release --strict
reads the *source* catalog, so regenerating only the release one left it
failing and would have aborted the ISO gate at stage 1 — after the
signing and tagging were already done. Drift is now 0.

The regeneration also rewrote fips/app_ports.rs, which had not been
regenerated since the initial open-source import. Diffing the port values
rather than the reformat: 36 -> 37, a single addition, **8187 — Alby
Hub**. Its port has never been in the FIPS firewall allow-list, and by
the same token neither has any app onboarded since that import. Nothing
else changed.

Catalog signed by the pinned release root and verified with
`ceremony verify`; registry trust floor checked before signing, both
hosts trusted by the deployed fleet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:02:40 -04:00
archipelagoandClaude Opus 5 8bc161f40f fix(ecash): repair two mangled warning messages
Both import refusals reached the operator with runs of ~18 spaces mid
sentence — "Importing a different one                  means coins
minted…". The string literals had been written as single long lines with
the line-continuation whitespace baked in rather than escaped, so Rust
preserved it verbatim.

Only visible once the sanitizer stopped swallowing these messages, which
is its own small lesson: the text had been wrong since it was written and
nothing could show it.

Cosmetic, but not trivially so — this is the warning that stops someone
replacing the phrase their balance was minted under, and text that looks
broken is text people stop reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:31:52 -04:00
archipelagoandClaude Opus 5 c19c411b91 chore(apps): mirror and bump the upgrades that carry no data migration
With registry push access, the 24 mirror-backed apps stopped being
blocked. Ten images are now mirrored (single-platform amd64, matching
the existing convention) and their pins moved:

  alby-hub          v1.23.0       -> v1.24.0
  mempool-frontend  v3.0.1        -> v3.3.1     (mempool, archy-mempool-web)
  mempool-backend   v3.0.0        -> v3.3.1
  fedimintd         v0.10.0       -> v0.10.1
  gatewayd          v0.10.0       -> v0.10.1
  nostr-rs-relay    0.9.0         -> 0.10.0
  portainer         2.39.1        -> 2.39.6
  vaultwarden       1.30.0-alpine -> 1.37.1-alpine
  jellyfin          10.8.13       -> 10.11.11
  home-assistant    2026.7.3      -> 2026.8.2

Every one verified pullable from our mirror after copying, so none can
become an image-not-found on a node. image-versions.sh moved in lockstep
— it is the baseline the update badge compares against when the catalog
does not cover an app, and leaving it behind would have kept advertising
an update that had already been applied.

Chosen by risk, not by count: these are patch/minor bumps with no data
migration. The ones held back are held for a reason each — Postgres
15->18 and 16->18 refuse to start on an older cluster, Redis 7->8,
Valkey 7->9, Nextcloud 29->32 must go one major at a time, plus
uptime-kuma 1->2, grafana 10->13, electrumx 1->2, photoprism, and
core-lightning's three years of schema migrations. Those are each a
migration plan, not a pin edit. LND (v0.18.4 -> v0.21.2) is held
separately: it is only a minor bump by version but it migrates its
channel database irreversibly, and this box holds real funds.

Note the checker still reports several of these as behind, and that is
correct: it reads the *catalog* pin, which is what nodes actually act on.
These land when the catalog is regenerated and re-signed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:22:42 -04:00
archipelagoandClaude Opus 5 7d6e52537a chore(apps): bump the pins that can move without mirroring
Of the 33 apps behind upstream, these five pull straight from a public
registry, so their targets exist already and the bump is real work rather
than a promise:

  strfry          1.0.4        -> 1.1.1
  netbird (nginx) 1.27-alpine  -> 1.31.3-alpine
  pine    (nginx) 1.27-alpine  -> 1.31.3-alpine
  pine-piper      2.2.2        -> 2.4.2
  nostr-rs-relay  0.8.9        -> 0.10.0

All five targets verified present upstream with skopeo before editing, so
none of these can turn into an image-not-found on a node.

Deliberately NOT bumped here, though they are also direct-pull:
core-lightning (v23.08 -> v26.06, ~3 years of schema migrations), gitea
(four minors of DB migrations), and netbird-server/netbird-dashboard —
which have to move in lockstep and carry their own migrations. Those are
each a piece of work, not a line edit.

The other 24 are blocked on something else entirely: their images live in
our mirror and none of the upgrade targets have been mirrored yet, so a
pin bump alone would break every install. That needs registry push
credentials.

These take effect when the catalog is regenerated and re-signed — the
catalog overrides on-disk manifests, so editing here changes nothing on a
node until the signing ceremony.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:54:05 -04:00
archipelagoandClaude Opus 5 86923f05a5 fix(ecash): stop the sanitizer eating the import safety rails
Live-checking the import route on the node showed both of its refusals
arriving as "Operation failed. Check server logs for details."

That is not merely opaque here, it is unsafe. The two messages are the
feature's safety rails: "That is not a valid BIP-39 recovery phrase —
check for typos" is the only help someone gets when a pasted phrase has
a bad word, and "This wallet already has a backup phrase… reveal and
write down the current phrase first, then confirm to replace it" is the
warning that stops an operator orphaning the words their balance was
minted under. Masked, the first is unactionable and the second is
invisible — the confirmation checkbox would be the only clue that
anything was at stake.

Same for "no backup phrase yet, nothing to restore from" and the NUT-09
message naming a mint that cannot restore at all.

Caught only because the refusal paths were exercised against the live
node rather than trusted from the unit tests, which see the real message
and never meet the sanitizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:43:47 -04:00
archipelagoandClaude Opus 5 ee40880ce5 feat(ecash): import a backup phrase from another NUT-13 wallet
Demo images / Build & push demo images (push) Failing after 2m2s
Bring-your-own, the open question the migration plan left. Point this
wallet at a phrase you already hold — Minibits, Nutstash, cdk-cli — and
its coins become restorable here, which is the other half of "these
words are portable".

Replacing an established phrase is the one genuinely lossy thing this
module can do, so it is treated that way. The coins already held stay
spendable: they are proofs, not derivations, and nothing here touches
`ecash.json`. But they were minted under the *old* phrase, so a restore
will no longer find them. Hence an explicit confirm, a prompt to reveal
and write down the current phrase first, and — most importantly — the
replaced phrase is archived beside the wallet, never overwritten. It may
be the last copy of the words a balance was minted under, and quietly
destroying that is precisely what this module exists to prevent.

Re-importing the phrase already in use is a no-op rather than a
replacement, so it archives nothing.

Counters are deliberately left alone. They are per-keyset and
seed-relative, so under a new seed they merely start high, which costs
nothing because a restore scans from zero regardless. Resetting them
would be the dangerous choice on the day someone imports the phrase they
were already using.

`imported` is its own provenance rather than reusing `independent`: both
mean the node's recovery phrase does not cover the wallet, but only one
of them means the operator already knows where else the words live.

15 NUT-13 tests green, 1000 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:09:19 -04:00
archipelagoandClaude Opus 5 c1a79fdd69 fix(apps): never recommend a release candidate, and flag major jumps
Two things the first run of this script got wrong, both found by reading
its own output rather than by a test.

It recommended MariaDB `13.0.1-ubi10-rc` — a release candidate — because
ordering strips the suffix, so an RC outranks every stable tag
numerically. Pre-releases are now excluded, with one exception that
matters here: a project whose stable line *is* suffixed. LND ships
`-beta` and always has, so a blanket exclusion would report it as
permanently current. The rule is therefore "no pre-release unless the pin
we are on is itself one", which keeps LND honest and MariaDB stable.

And "33 behind" is not an actionable list, because the entries are not
the same kind of work. A patch bump is a pin change; a major bump is
where the data migrations live — Postgres refuses to start on an older
cluster, Nextcloud requires one major at a time. Each row now says which
it is, and the summary names the majors separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:55:18 -04:00
archipelagoandClaude Opus 5 59440ef1ac fix(wallet): a seen receipt stays seen across refreshes
Demo images / Build & push demo images (push) Failing after 2m6s
fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.

Acknowledgement now lives in localStorage, capped at 300 keys.

That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.

Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.

Frontend: 1000 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:30:57 -04:00
archipelagoandClaude Opus 5 603291008b fix(test): restore the ecash network before bouncing the service
The restore proof's cleanup set the network back *after* restarting
archipelago, so the call landed on a socket that wasn't listening yet
and failed silently. A fully green run left the node parked on testnet —
the one outcome a cleanup path must never produce, and worse for being
invisible.

Network first, while the RPC is still up; then the wallet file, then the
restart, then wait for the service back so a check running straight
afterwards doesn't meet a dead socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:15:31 -04:00
archipelagoandClaude Opus 5 212e349b19 refine(wallet): the balance readout scans as one column, not a barcode
Demo images / Build & push demo images (push) Failing after 2m7s
Seeing it on the node settled the shape. Two rows of per-cell delays
read as a dense flicker — closer to a progress bar than a display, and
short enough against the row's text to look like an underline.

Three rows laid out column-first fixes both: the three cells of a column
now share a delay, so the lit column travels across as a single scan
line, and at 11px the matrix sits with the text rather than under it.

Verified in a real browser against the live node with the balance RPCs
held open: five placeholders, five distinct rail colours (white, orange,
yellow, purple, blue), 42 cells each, all animating, each announcing
what it is waiting for — and no "0 sats" anywhere on screen while the
calls were in flight. They gave way to real figures on arrival, with
Lightning's genuine 0 correctly shown as a figure rather than left
shimmering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:05:33 -04:00
archipelagoandClaude Opus 5 fa6fe32ef9 feat(wallet): a balance that isn't loaded yet says so, in pixels
Demo images / Build & push demo images (push) Failing after 2m18s
An unloaded balance rendered as `0`. Zero is not a loading state — it is
a number, and it is the one number that frightens people. Someone
opening the dashboard while the RPCs were still in flight was told, in
the wallet's own typeface, that their money was gone.

There is no formatting fix for that. The fix is to stop claiming a
figure we do not have, so `null` now means "not known yet" and `0` means
"none", and the two are kept apart end to end: the refs start at null,
a rail becomes a number only when its call actually succeeds, and a
snapshot key that was never written stays unknown instead of becoming a
zero.

In place of the figure, a small dot-matrix scans in the rail's own
colour. It inherits currentColor, so on-chain shimmers orange, Lightning
yellow, Cashu purple, Fedimint blue and Ark teal with no colour table to
keep in sync — and it is sized to the figure it stands in for, so
nothing jumps when the real number lands. It carries role="status" and
names what it is waiting for; a shimmering box with no text is nothing
at all to a screen reader.

Two consequences worth stating. The total is withheld until every rail
that makes it up is known — summing nulls as zero would show a total
*lower* than the rails beneath it, which is worse than showing nothing
because it looks authoritative. And the Ark row stays hidden while its
balance is unknown, since "unknown" must not be read as "> 0" on the
many nodes with no Ark sidecar.

The LND app UI had the same bug in a different shape: its tiles start as
an em-dash, but renderBalances() runs on every poll including before the
first response, and `num(null && …)` is 0 — so the dashes were painted
over with "0 sats" almost immediately. Same treatment, in plain CSS.

Also fixes a stale assertion in AppHeroSection's suite, which has been
red since 9ccc325a changed "Restarting..." to a real ellipsis; and two
test proofs that used a plausible-looking hex string for `C`. The V3
codec never parses that field so it went unnoticed, but the V4 encoder
hands it to the reference implementation, which checks the point is
actually on secp256k1. Real curve points now.

Frontend: 996 tests green. Backend: 1436 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:54:12 -04:00
archipelagoandClaude Opus 5 fc98c1d8dd fix(wallet): an incoming payment no longer disappears before you look
Demo images / Build & push demo images (push) Failing after 2m10s
Chasing the "selecting incoming clears a pending token, and there's a
timeout if you don't click" report led here. Instant rails — Lightning,
Cashu, Fedimint, Ark — settle immediately, so there is no confirmation
to wait for and no natural moment for a receipt to leave the Incoming
badge. It was leaving on a five-minute wall clock instead.

So a payment could arrive, raise the badge, and evaporate before anyone
looked; and opening the panel a few minutes late showed nothing, because
the payment you came to check on had already aged out. Worse, once the
count hit zero the badge silently changed meaning — the same click that
opened the panel now navigated to the transactions view instead.

For ecash that is the worst case available. It leaves no public ledger
entry, so this panel was the only place the receipt was ever shown; once
it timed out there was nowhere left to look.

Instant-rail receipts now stay until they have actually been seen, which
is the same unread model the mesh inbox uses. Closing the panel is what
marks them seen, not opening it — marking on open would make a row
vanish under the cursor of someone still reading it. On-chain is
untouched: a confirmation count is a real signal and already does this
job.

Also keys the list on a derived id. Instant rails have no txid, so
`:key="tx.tx_hash"` was `""` for every one of them.

This is my reading of the reported symptoms rather than a confirmed
repro — the operator should check it matches what they saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:30:16 -04:00
archipelagoandClaude Opus 5 e30516316b fix(ecash): give every node a backup phrase, and prove restore works
Demo images / Build & push demo images (push) Failing after 2m11s
Running the route suite on this box surfaced that the backup was
unreachable here: `identity/master_seed.enc` is written during
onboarding, and any node onboarded before that step existed simply does
not have one. Reveal bailed with "this node has no encrypted seed
backup", and restore followed it down.

But the choice on such a node was never "derived phrase or independent
phrase" — it was "independent phrase or no backup at all", and a wallet
whose coins can be restored from words the operator holds beats one
whose coins die with a single file. So it now generates one, recorded as
`independent`, and every surface that shows it says plainly that
restoring the node will not bring the ecash back — only these words
will. `derivable_from_node_seed` lets the card say which kind you are
about to get *before* you write anything down.

Also: a mint that never implemented NUT-09 answered restore with a bare
404, which surfaced as "mint returned 404 with no further detail" —
true, and useless to someone trying to get their coins back. It now
names the limitation.

The route suite was reading `result.amount_sats` from mint-claim, which
answers with `minted_sats`. A working claim had been reporting as a
failure; that was one of the two reds carried over from yesterday.

The real gap, though, was that "recovered 0 sats" passes on a wallet
with nothing to find — exactly the shape of a backup that looks fine
until the day you need it. test-ecash-restore.sh does the test that
settles it: mint, **delete the wallet file**, restore, check the coins
came back. On this box: 87 sats before the wipe, 0 after, 61 recovered
from the phrase alone — every coin minted since the phrase existed, and
none of the 26 sats minted before it, which used random secrets and
never could come back. Testnet only, and it refuses to run otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:27:01 -04:00
archipelagoandClaude Opus 5 cbbd20e22e test(ecash): cover cashuB emission, the backup phrase, and restore
Four things the suite could not previously catch:

- The emitted token is cashuA. It is still valid, so nothing fails — the
  send succeeds and the receiver redeems it. The only symptom of cashuB
  encoding falling back is a warning in the journal nobody reads, which
  is exactly the kind of silent regression a route check exists for.
- The wallet has no backup phrase. Without one the coins live in exactly
  one file and nothing can bring them back.
- The phrase changes between reveals, which would orphan every coin
  minted under the previous one.
- Restore double-counts. It runs against a live wallet, so running it
  twice must leave the balance where it was.

Reveal is also asserted to refuse a wrong password: it is the one route
here that hands out key material, and a session alone must not be enough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:06:27 -04:00
archipelagoandClaude Opus 5 eb48eab946 feat(apps): find out when an app has fallen behind upstream
Nodes offer an update when the signed catalog pins something newer than
what's running, and that machinery is fine. The missing step was the one
before it: nothing told *us* when upstream shipped. A pin could sit at
fedimintd v0.10.0 for months while every node in the fleet correctly and
confidently reported "up to date".

The reason nothing could tell us is that a manifest records only our
mirror — `source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`
says nothing about the project it was mirrored from. So this adds an
optional `app.upstream` block naming the real source, and a script that
asks each one what it has released.

Running it answers the question that prompted this. Of 58 apps, 28 are
behind, including LND v0.18.4-beta against v0.21.2-beta, Bitcoin Core
28.4 against 31.1, and fedimintd/gatewayd v0.10.0 against v0.10.1.

Two choices worth stating. An app with no `upstream` block is reported
as UNTRACKED rather than skipped — a silent skip is how this stayed
invisible, and before this commit all 58 were silently skipped. And a
suggestion prefers our own tag variant: telling someone pinned to
`postgres:16.13-alpine` that the newest tag is `18.6-trixie` is true and
useless, because swapping the base image is a different decision from
bumping a version.

Five apps are deliberately left untracked (barkd, immich-postgres,
indeedhub-minio, lightning-stack, pine-whisper): I could not establish
their upstream with confidence, and a wrong `repo` produces a confident
wrong verdict, which is worse than an honest gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:04:33 -04:00
archipelagoandClaude Opus 5 59fffc809f feat(ecash): the wallet can now be restored from a phrase (NUT-13)
Demo images / Build & push demo images (push) Failing after 2m15s
Until now every Cashu proof this node held was backed by a secret drawn
from OsRng and written to exactly one file. Losing wallet/ecash.json
lost the coins outright — no phrase to write down, and nothing the mint
could do about it. Ecash is a bearer instrument, so "one file, no
backup" was the sharpest edge in the wallet.

NUT-13 derives each proof's secret and blinding factor from (seed,
keyset id, counter) instead. The wallet becomes a phrase, and the coins
can be re-derived and re-claimed — here or in any other NUT-13 wallet.

The phrase is its own 24 words, derived from the node master seed over a
fixed HKDF path. Both halves matter: it is still covered by the node's
recovery phrase, so there is nothing extra to write down; but it is
portable, so restoring ecash into Minibits or cdk-cli does not mean
handing over the key to the entire node.

It sits on disk unencrypted, deliberately. The master seed needs the
operator's password to open, which no background mint or swap can ask
for; and this file lives beside wallet/ecash.json, which already holds
spendable bearer secrets in plaintext. It regenerates exactly those
secrets, so it is the same sensitivity class as the file next to it.
0600, like identity/nostr_secret, which is derived and persisted the
same way.

Counters are reserved *before* the mint call and never rolled back. A
gap costs a restore scan a few extra probes; a reused counter costs a
coin, because two proofs with the same secret can only be spent once.

Restore is the half that cannot be done offline: a re-derived secret is
not money until the mint's signature over it exists. /v1/restore returns
those signatures; unblinding reconstitutes the proofs. It is additive
and idempotent — coins already held are skipped by secret, spent ones
are counted but not added — so it is safe to press on a working wallet,
which is when someone is most likely to reach for it.

Existing nodes activate on the first visit to Settings → Ecash backup
phrase: that password prompt is the only moment the master seed can
legitimately be opened. New nodes get it at onboarding. Until then the
behaviour is exactly as before — valid proofs, no backup — and the card
says so rather than implying a backup already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:34 -04:00
archipelagoandClaude Opus 5 579287ba48 feat(ecash): emit cashuB tokens, and share one payment success screen
Most wallets — Minibits, Nutstash, cdk-cli — default to reading cashuB
(V4) now, so that is what we send. cashuA stays as the fallback rather
than the default: it is still valid everywhere, so a token this wallet
cannot express in V4 (a multi-mint one) is worth sending in V3 rather
than failing the send outright. That path warns, because by the time
`send_token_at` serializes, the proofs are already marked spent.

The V4 encoder is the reference implementation's, not ours. The envelope
puts the keyset id and signature on the wire as raw CBOR bytes under
single-letter keys, and a token subtly wrong there is money the receiver
cannot redeem — so upstream owns the encoding, the way it already owns
keyset-id resolution. Our own hand-written decoder reads what upstream
writes in the new test, which is agreement between two independent
implementations rather than a round trip through one codec.

Two refusals are deliberate and tested: a multi-mint token has no V4
form, and a truncated v2 keyset id must never be baked into a token we
emit (the framework-pt case) — it is only resolvable against the mint's
keyset list.

Also folds SendBitcoinModal onto the shared PaymentSuccessPane it had a
private copy of, so on-chain, Lightning and ecash all show the same
screen and the copyable-identifier row is defined once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:15 -04:00
archipelagoandClaude Fable 5 f4a1c47429 feat(wallet): ecash gets the real payment success screen, with copyable proof
Demo images / Build & push demo images (push) Failing after 2m37s
Redeeming ecash reported success as one line of small green text, while an
on-chain or Lightning payment got the full moment — amount, verb, and the
identifiers you can copy. That asymmetry matters most for ecash: it leaves
no public ledger entry, so if the payment is ever questioned there is
nothing to look up afterwards. Whatever isn't copyable at that instant is
simply gone.

The success pane is extracted from SendBitcoinModal into a shared
PaymentSuccessPane so Cashu and Fedimint show the *same* screen rather
than a lookalike, and the copyable-row treatment is defined once. Each
caller passes the identifiers its protocol actually has; ecash receive now
shows the issuing mint (newly returned by wallet.ecash-receive) and the
redeemed token itself, clamped so a long token doesn't flood the pane.

Also: the test-ecash switch is a proper toggle (role="switch", keyboard
focusable) rather than a checkbox — it selects which purse the wallet is
looking at, so it should read as a mode you are in.

SendBitcoinModal still carries its own copy of the markup; consolidating it
onto the shared component is a follow-up, deliberately not done in the same
change as the money-path wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:08:15 -04:00
archipelagoandClaude Fable 5 ffec7d3114 fix(ecash): pay the mint's input fee, and stop a damaged wallet from being erased
Two independent fixes, both found while exercising the routes headlessly.

**Mint fees (NUT-02).** A mint may charge a per-input fee and rejects any
swap whose outputs don't equal inputs minus that fee — `11005 Transaction
inputs should equal outputs less fee`, which is what sending hit against
testnut.cashu.space. We ignored the fee entirely, so the wallet could not
spend at ANY fee-charging mint; Minibits charges zero, which is why
production never saw it. `MintKeyset`/`KeysetInfo` now carry
`input_fee_ppk`, `swap_fee_for` computes the NUT-02 sum (rounded up), and
`MintClient::swap` reduces its outputs to cover it — applied there rather
than at each call site so send, receive and cross-mint swaps are all
covered at once. Inputs from a keyset the mint doesn't list contribute no
fee: the mint is the authority, and guessing high would burn the sender's
coins.

**Damaged-wallet erasure.** `load_wallet` used `unwrap_or_default()`, so a
truncated `ecash.json` read as an EMPTY wallet — and because the next
operation saves the wallet back, that empty state was then written over the
only copy of the proofs. A corrupt file became permanent loss. Now a file
that exists but doesn't parse fails with a message naming the file and
stating the coins are still in it, and the bytes are left untouched for
recovery; an empty file is still treated as a fresh wallet, since a create
that never got its first write is not damage. The accepted-mints list gets
the same treatment, where corruption would have silently reset the operator
to trusting only the default mint.

Writes are now atomic (temp + fsync + rename) for both files. The previous
plain write truncated the real file first, which is exactly how a wallet
ends up unparseable after a crash or power cut.

Tests cover: a damaged file errors and survives on disk, an empty file is
fresh, saving leaves no temp behind and round-trips, and — guarding the
on-disk contract against exactly this update — a verbatim pre-update wallet
file still loads with its balance, proofs and history intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:01:45 -04:00
archipelagoandClaude Fable 5 26638aa621 fix(ecash): sign with the mint's SAT keyset, not whichever came first
`get_active_sat_keyset` picked the first keyset with a non-empty key map,
and `MintKeyset` had no `unit` field to filter on — so on a multi-unit mint
the wallet signed sat-denominated mint/swap requests against a usd or eur
keyset. The mint refuses that with `11013 Unit unsupported`, which is
exactly what claiming minted coins hit against testnut.cashu.space (it
serves usd, eur, msat and sat keysets). Minibits is sat-only, so this
latent bug never surfaced in production — the test-mint switch found it on
its first run.

MintKeyset now carries `unit` and `active`, both defaulted so a sat-only
mint that omits them still parses, and selection filters to sat and prefers
an active keyset.

Also: pin BIP-39 seed derivation to the specification's own test vectors.
The node's entire identity hangs off `Mnemonic::to_seed("")`, and the
`bip39` crate is no longer version-pinned (the exact pin had to be relaxed
so `cashu` could resolve). A bump that changed derivation would silently
re-key every node on the fleet and orphan every backup; both vectors —
empty passphrase and the NFKD-exercising passphrase arm — now fail the
suite instead. Verified byte-identical under the newly resolved 2.2.2.

And the route script polls the mint's quote state before claiming: the test
mint settles its own invoices, but not instantly, so claiming immediately
raced the settlement and reported a spurious "Quote not paid".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 06:07:27 -04:00
archipelagoandClaude Fable 5 be2cfb8293 feat(ecash): adopt the reference NUT-02 resolver + real/test network switch
Demo images / Build & push demo images (push) Failing after 2m26s
Executes steps 1-3 of docs/cashu-cdk-migration-plan.md, plus the test-coin
switch needed to exercise these routes without spending real sats.

Protocol layer: depend on `cashu` 0.17.5 (MIT, the crate CDK is built on,
default-features off, `wallet` only). Keyset ids now go through upstream's
`Id::from_short_keyset_id` / `ShortKeysetId` instead of the prefix match
hand-rolled in 2277fc46 — same repair, but implemented by the reference
code that defines the rule, so the next spec turn is a version bump rather
than another incident. `MintClient` feeds it the mint's `/v1/keysets` in
upstream's own `KeySetInfo` shape, parsing entries individually so one
keyset in an unmodelled unit can't block resolving the id we need.

Adding the crate required relaxing `bip39 = "=2.1.0"` to `"2.1"` (resolves
2.2.2): the exact pin held `unicode-normalization` at 0.1.22 and no
resolution existed otherwise. The pin carried no recorded rationale; seed
tests cover the bump.

Network switch: `wallet.ecash-network` / `wallet.ecash-set-network`, with a
Test mode toggle in Wallet Settings → Cashu. Cashu has no testnet, so this
points the wallet at the public `testnut` mint — but crucially each network
gets its OWN wallet and accepted-mints file, because test and real proofs
in one purse would be spendable interchangeably and the balance would be a
lie. Mainnet keeps the original filenames, so existing funds files are
untouched and switching is reversible: tests assert a real balance survives
a round trip through test mode.

Headless coverage: scripts/test-ecash-routes.sh drives every ecash RPC over
the real HTTP path (network get/set, balance, history, mint quote + claim,
send, receive, double-redeem refusal, garbage input, melt quote), restores
the node's original network on exit, and exits non-zero with the failure
count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 05:04:25 -04:00
archipelagoandClaude Fable 5 03cf74696d docs(ecash): pin the seed-backup UX to the existing reveal pattern
Ecash gets its own BIP-39 mnemonic derived from the node master seed:
still covered by the node's recovery phrase, but portable into any NUT-13
wallet without exposing the master seed — so 'back up my ecash' is not the
same action as 'expose the key to everything'.

Surfaced exactly like the Lightning seed: the shared SeedRevealPanel, on
the app detail page and in Settings → Backup, behind the same
verify_reveal_auth password re-entry. Standard BIP-39, so the SeedQR tab
works (no aezeed flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:22:29 -04:00
archipelagoandClaude Fable 5 6e23b121ea docs(ecash): plan the move to the reference Cashu implementation
Scoped from the framework-pt keyset incident: adopt the `cashu` crate
(MIT, the crate CDK is built on) for the token codec, keyset ids, crypto,
DLEQ and NUT-13, while keeping ecash.rs's on-disk contract, MintClient's
Tor seam and error table, and our multi-mint routing. Records why the
full cdk WalletDatabase shim is the wrong trade over live funds, and how
ecash backup can derive from the node's existing seed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:07:19 -04:00
archipelagoandClaude Fable 5 2277fc4684 fix(ecash): redeem tokens whose keyset id was truncated to the old length
A Minibits token could not be redeemed on framework-pt: the mint answered
POST /v1/swap with a bare 422, which the RPC sanitizer turned into
"Operation failed. Check server logs for details." The journal had the
real reason:

  inputs[0].id: NUT02: ID length invalid, expected 8 bytes (short/v1)
  or 33 bytes (v2)

The token carried keyset id 01fc0ec0e59cd6fa — exactly the first 8 bytes
of the mint's active 33-byte id 01fc0ec0e59cd6fa01b7a88f…a821. NUT-02 v2
ids are 33 bytes behind a 0x01 version byte; the sending wallet cut it to
the 8 bytes that were the whole id under v1. The mint reads the version,
expects 33 bytes, and rejects it — so the length complaint is right even
though 8 bytes is legal for a 0x00-prefixed v1 id.

The id only names which keyset signed a proof, and the short form is a
prefix of the full one, so it can be repaired: before swapping, any
8-byte 0x01-prefixed id is expanded against GET /v1/keysets (new
MintClient::get_keysets — it lists inactive keysets too, and coins from a
retired keyset stay spendable). Preferring the active keyset on a prefix
tie. Attempting this is safe: an id naming the wrong keyset fails
signature verification at the mint and no coins move. Anything already
valid, or with no unambiguous match, is passed through so the mint's own
error still reaches the operator.

Token decoding now also checks keyset ids locally, so an id that is not
hex or is neither NUT-02 length fails with a message naming the format
instead of a raw 422 from the mint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:06:30 -04:00
archipelagoandClaude Fable 5 9ccc325a4d fix(container): reap ghost containers so an app can't be locked out of itself
Demo images / Build & push demo images (push) Successful in 3m24s
A ghost is a container whose process tree is still running while podman
has no record of it: the exit-command's `cleanup --rm` deletes the record,
conmon and the payload survive. It keeps owning exactly what the app needs
— the published host port and the file locks in its data dir — so the
replacement container either fails to bind ("address already in use") or
starts and dies on the lock, and Restart=always loops it there forever.
Nothing in the stack could see it: every podman-level stop/rm/recreate
misses a container podman lost.

Seen twice now: 752 restarts on a fleet node (2026-08-10) and again on the
dev box today, where Gitea flapped until it fell out of My Apps. Both were
cleared by hand; container-doctor.sh has the same logic but is an
out-of-band script the daemon never calls.

- New container::ghost_reaper: finds conmon processes whose 64-hex
  container id is absent from `podman ps -a --no-trunc -q`, then kills the
  payload's children and conmon (TERM, 5s grace, then KILL — the Gitea
  ghost ignored TERM). Id-based, never name-based: killing by name would
  hit the live managed container. A failed `podman ps` reaps nothing
  rather than treating every container as a ghost.
- Hooked at repair_before_package_start (covers package.start,
  package.restart and the orchestrator start path) and in the boot
  reconciler's 30s tick, so ghosts are cleared before an app is asked to
  start and swept for every app continuously.

Restart feedback: the lifecycle RPCs return {"status":"restarting"} in
milliseconds and work in the background, so "Restarting..." flashed for a
few frames and the buttons went idle while the app was still down — the
click read as a no-op. The hero buttons now show a spinner and hold it off
the node's own state (starting/stopping/restarting/updating, plus running
+ health=starting), and the just-clicked action is held until the backend
confirms it picked the work up, with a 12s cap so an unresponsive node
still releases the controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:45:45 -04:00
archipelagoandClaude Fable 5 b113fafee4 fix(ui): app-gate warning names FIPS alongside LAN, Tailscale and Tor
Demo images / Build & push demo images (push) Successful in 3m28s
The mesh is a reach path like the others; omitting it understated what
turning an app's gate off exposes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:16:34 -04:00
archipelagoandClaude Fable 5 58cdea5e79 feat(appgate): apps with their own login can skip the node login
Demo images / Build & push demo images (push) Successful in 3m33s
Some apps carry a complete account system and are broken by an upstream
challenge: git clients speak basic-auth (not browser cookies), and a
BTCPay checkout link handed to a customer must open for that customer.
Both were behind the gate's login page — the "non-browser clients need an
access token" gap disclosed in five consecutive releases.

- New manifest port policy `auth: open`: the daemon still fronts the port
  exactly like `gated` (loopback pin, external binds, frame-header fixes,
  app-down retry page, Tor upstream) but serves it without the login
  challenge. Requires auth_rationale, same burden of proof as `none`.
  Gitea 3001 and BTCPay 23000 declare it.
- Runtime operator override per app (security.set-app-gate → app-configs/
  <id>.json "gateEnabled"), surfaced as Settings → app → Access control.
  Wins over the manifest in both directions and applies on the next
  request — no restart, and it works today on catalog-covered apps whose
  signed manifest still says `gated`.
- The gate resolves policy per-request from the live port map, so a
  toggle takes effect without waiting for the 60s rebind sweep. "Off"
  never releases the port: gated apps are loopback-pinned, so releasing
  would strand them, not open them.
- security.app-gate-status now reports gate_enabled + any override.
- New guard test pins the `auth: open` set (both entries reviewed); the
  `auth: none` count moves 25 → 26, absorbing pre-existing drift from the
  phoenixd onboarding (loopback JSON API with its own generated password).
- Docs: the manifest spec's ports row documented only host/container/
  protocol — bind, auth, auth_rationale and session_passthrough were
  undocumented. Added a full "Ports & the app gate" section plus a
  developer-guide entry telling app authors to enforce their own auth
  regardless, since the operator can flip the gate either way.

Verified live on archi-dev-box from an external address: gated → 401 gate
page; override off → Gitea 200 own page, BTCPay 302 to its own login,
git-over-HTTP info/refs 200; override on → 401 again; clear → default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:40:07 -04:00
archipelagoandClaude Fable 5 9b789a64ad docs(changelog): RSSI readings depend on radio firmware reporting them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 06:18:15 -04:00
archipelagoandClaude Fable 5 4a7f466ea6 feat(mesh): radio_state reports the radio's last-RX RSSI/SNR
r_stat_rssi/r_stat_snr straight from the RNode firmware's per-packet
stat reports — the direct way to tell "firmware reports signal stats"
from "it doesn't" when a peer's RSSI shows as unknown, and a natural
read-back for the LoRa panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 06:13:54 -04:00
archipelagoandClaude Fable 5 64205d23b7 docs(changelog): draft v1.8.4-alpha entries for the release-fix batch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 06:09:39 -04:00
archipelagoandClaude Fable 5 3a3077529b feat(kiosk): companion remote drives app iframes via trusted CDP input
Demo images / Build & push demo images (push) Successful in 3m11s
Companion tap/scroll/type now works INSIDE cross-origin app iframes and
kiosk tabs. The web relay synthesizes untrusted DOM events in the top
document, which can never cross an origin boundary — so apps served
through the appgate were dead to the remote. The kiosk Chromium now
exposes a loopback-only CDP port (default origin check intact, no
--remote-allow-origins) and a backend bridge (api/handler/cdp.rs)
dispatches validated companion input as Input.dispatchKeyEvent /
dispatchMouseEvent / mouseWheel — trusted events that hit-test through
any frame, move real focus, and insert text like a physical device.

- Session keeper self-heals across kiosk Chromium restarts; inert on
  nodes without a kiosk unit (falls back to the existing relay path).
- The kiosk relay subscriber self-tags (?kiosk=1) and the backend mutes
  its key/click/scroll messages while the bridge is live, so input never
  applies twice; cursor moves still flow for the on-screen cursor.
- While companion input is active the native OS pointer is hidden
  (cursor:none, auto-restores 30s after the last event) so the dead
  physical-mouse cursor doesn't sit next to the virtual one.
- docs/tv-input-iframe-apps.md scope note updated: gamepad keys stay on
  uinput; CDP is for companion pointer/typing only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 05:28:53 -04:00
archipelagoandClaude Fable 5 876ecc4bdf fix(ui): replace native confirm() dialogs with the global in-app modal
window.confirm blocks the JS event loop, which froze companion remote
input while open — the remote user could raise the mesh "Clear" prompt
(or reboot / backup-delete / uninstall confirms) and then never dismiss
it, because the synthetic events that would dismiss it queue behind the
dialog itself.

New promise-based appConfirm() (useAppConfirm.ts) + one AppConfirmModal
mounted globally in App.vue, built on BaseModal (Teleport-to-body,
full-viewport backdrop, glass card — the canonical modal contract). All
six native confirm() call sites migrated: mesh clear-all, mesh message
delete, dashboard reboot, backup delete, backup USB copy, app uninstall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 05:28:53 -04:00
archipelagoandClaude Fable 5 458444d700 fix(mesh): report real RSSI/SNR for Reticulum peers instead of a fake 0
Every Reticulum-heard peer surfaced as rssi=0 — indistinguishable from a
real 0 dBm reading and, worse, from "heard over the TCP bridge with no
radio involved at all", which made a TCP-fed mesh look like working RF
during the 2026-08-16 radio diagnosis.

- Sidecar: announce handler now uses the 4-arg RNS dispatch to get the
  announce packet hash and reports per-announce rssi/snr from Reticulum's
  packet-stat cache; LXMF deliveries report message.rssi/snr/q (LXMF
  already populates them on direct RNode hops). All None over TCP or
  multi-hop — the honest RF-vs-internet discriminator.
- Rust: ReticulumPeer caches last_rssi/last_snr from announce and recv
  events (a TCP-relayed announce never blanks a real RF reading), and
  get_contacts surfaces them so refresh_contacts propagates real values.
- Identity discovery no longer hardcodes rssi 0: unknown is now None
  end-to-end and logged as such.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:52:04 -04:00
archipelagoandClaude Fable 5 519fa68c72 fix(federation): end the perpetual peer-joined "Invalid signature" storm
Root cause observed live 2026-08-16: onboarding/seed-restore rewrite
identity/node_key on disk but server_info.pubkey is only seeded at boot,
so until the next restart every peer-joined advertised the stale boot key
while signing with the new seed-derived key — deterministically rejected
by every receiver, once per 90s heal tick, forever.

- seed.generate / seed.restore now refresh server_info.pubkey in the live
  snapshot immediately (mirrors the DID-rotation handler).
- The 90s heal loop advertises the SAME key it signs with (disk identity,
  like federation sync already did) instead of the boot snapshot.
- notify_join no longer logs "delivered" for an HTTP-200 JSON-RPC
  rejection; in-band errors are terminal (identical signed bytes can
  never succeed on retry).
- The heal loop backs off per peer (doubling toward a daily re-assert)
  instead of re-notifying every 90s forever — Observer-held peers never
  appear in Trusted-only exported hints, so they_list_us could never
  become true for them.
- Receiver now binds the DID to the advertised pubkey (the old check was
  self-referential) and logs malformed signatures distinctly from
  genuine mismatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:45:39 -04:00
archipelagoandClaude Fable 5 809f7649a4 fix(mesh): plug-and-play radio detection in all situations
Three root causes from the 2026-08-16 framework-pt incident where a
replugged radio detected but never connected:

- detect_serial_devices scanned a hardcoded ttyUSB0-2/ttyACM0-2 list, so
  a radio enumerating at index 3+ was permanently invisible. Now scans
  /dev for all ttyUSB*/ttyACM* nodes (deterministic order, /dev/mesh-radio
  alias still first and still wins the dedup).
- An operator rnode-rf-settings.json port override silently outranked the
  device_path the user just chose in the detection modal. mesh.configure
  now clears a stale override when a different device is configured
  (symlink-resolved compare keeps /dev/mesh-radio aliases intact).
- Espressif native-USB boards (303a, ESP32-S2/S3/C3 RNodes) had no udev
  rule, so they never got the stable /dev/mesh-radio alias and a persisted
  alias path dangled after a port move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:38:49 -04:00
archipelagoandClaude Fable 5 c5cd751bcf fix(nginx): a moved or slow-to-DHCP node no longer loses its whole web UI
setup-node-ca.sh writes one 'listen <addr>:443 ssl;' per LAN address at
the moment it runs (per-address on purpose — Tailscale holds :443 on the
tailnet address) and its idempotency guard never revisits them. nginx
REFUSES TO START while any listen address is missing, so this takes the
entire dashboard down, not just HTTPS:
  1. the node moves networks and the old address is gone; or
  2. nginx starts before DHCP assigns the address — and nginx.service
     ships no Restart=, making that single race permanent.
Both hit archi-dev-box today: nginx dead since boot on 'bind() to
192.168.63.240:443 failed (99: Cannot assign requested address)', the
dashboard simply unreachable, which is exactly the symptom a user with
no screen cannot diagnose.

run_nginx_listener_repair drops listeners for absent addresses, adds one
per present address (CGNAT excluded), installs behind  with
rollback, then starts nginx if it is down and gives it a
Restart=on-failure drop-in so the boot race stops being fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:49:40 -04:00
archipelagoandClaude Fable 5 203c2b6e50 fix(async): finish the blocking-call sweep — scan, 3 SSH handlers, DNS
A codebase sweep for siblings of e282c059 (blocking network I/O parked
on the tokio runtime) found the openwrt fix was incomplete:

- openwrt.scan: scan_subnet is async in name only — up to 255 SEQUENTIAL
  blocking TCP probes at 500ms each (~2 min on a /24 that silently
  drops) plus a blocking SSH verify per candidate. One click of 'scan
  for routers' held a worker for that whole time. Now spawn_blocking.
- provision-tollgate / scan-wifi / configure-wan still ran their SSH
  exchanges inline; bounded_tcp caps each socket op but a session is
  many sequential ops (provision runs opkg install over SSH), so worst
  case was minutes. All three now spawn_blocking.
- network::check_dns: blocking glibc to_socket_addrs with no app-level
  bound, on every Server-tab load via network.diagnostics. Against a
  stale resolver — the moved-network case — that is 5-40s per refresh.
  Now spawn_blocking plus a 5s cap, so the tile reports 'no DNS'
  instead of hanging.

Verified false positives left alone: every other bare TcpStream::connect
targets 127.0.0.1 (fails instantly), and every remote reqwest client
already sets a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:03:08 -04:00
archipelagoandClaude Fable 5 6e89acced7 fix(network): SSDP discovery no longer parks a tokio worker for 3s
check_upnp_available uses a blocking std UdpSocket and, on a network
with no UPnP gateway (the normal case right after a node moves), runs
out its full 3s read timeout. Inline on the runtime that blocked a
worker on every call, from four call sites. Same class as the OpenWrt
SSH stall (e282c059), smaller blast radius — move it to spawn_blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:22:44 -04:00
archipelagoandClaude Fable 5 e282c05911 fix(openwrt): unbounded blocking SSH connect no longer stalls the whole API
Router::connect/connect_password did a blocking std TcpStream::connect
with no timeout, inline on the tokio runtime. Against a router that
stayed behind when its node moved networks (framework-pt, 2026-08-15),
every dashboard poll of openwrt.get-status parked a worker thread for
the OS connect timeout (~2 min) — overlapping polls stalled unrelated
RPCs for 25s+ at a time, sessions timed out, and TOTP codes expired
before the backend verified them.

- bounded_tcp(): 5s connect timeout + 30s read/write timeouts on the
  session socket, shared by both connect paths.
- openwrt.get-status runs its SSH exchange on spawn_blocking, so even a
  slow router can only slow its own tile, never the API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:52:03 -04:00
archipelagoandClaude Fable 5 9c675e5c7d fix(login): backend-unreachable no longer masquerades as a fresh node
Demo images / Build & push demo images (push) Successful in 3m4s
auth.isSetup failing (backend warming up after boot, transient proxy
blip) dropped Login.vue into its catch and showed 'Set Up Your Node' on
a fully-onboarded node — seen on framework-pt right after its network
move, and the same fail-open class RootRedirect already fixed for the
intro flash. Errors now fail toward the ordinary login form and a
background probe re-asks until the backend answers; a genuinely fresh
node flips to the setup form on the first successful probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:36:37 -04:00
archipelagoandClaude Fable 5 6833920778 feat(bitcoin): autoprune default raised 550 → 50000 MB
Small-disk nodes (<1000 GB data volume) keep the same dynamic
prune-vs-archival logic but now retain ~50 GB of recent blocks instead
of the bare 550 MB minimum. Takes effect for catalog-covered installs
at the next catalog regeneration + signing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:23:57 -04:00
archipelagoandClaude Fable 5 ec2e6375ed feat(bootstrap): two OTA heals for network moves + registry renames
Both failure modes are from framework-pt relocating (2026-08-15):

- archy-ha-btc-rpc-proxy bound socat to the LAN IP baked in at unit
  generation; after a move the address no longer exists and the unit
  restart-looped forever (counter 2446). run_ha_rpc_proxy_bind_repair
  rewrites ExecStart to compute the bind address at each start, so
  Restart=always itself heals any future move.
- homeassistant's quadlet pointed at the domain image ref with --pull
  never while local storage held the same name:tag under the bare-IP
  registry ref (catalog signing rename) — 761 restarts on 'image not
  known'. run_pull_never_image_repair retags a matching local image;
  it deliberately never pulls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:23:57 -04:00
archipelagoandClaude Fable 5 1587853ce2 fix(banner): console banner shows the reachable LAN address, not the WG tunnel IP
The welcome banner picked its address with 'hostname -I | awk {print $1}',
so a node with WireGuard up advertised 10.44.0.1 — its own tunnel address,
present on EVERY node — as its web ui / ssh address. Off-tunnel that is
unreachable, and after a headless box moves to a new network it is exactly
the wrong thing to trust (framework-pt, 2026-08-15).

- Pick the default route's source address; fall back to the first address
  that is not WireGuard 10.44/16, CGNAT 100.64/10, or loopback.
- Also print http://<hostname>.local when avahi is up — the one address
  that survives any DHCP change, which is the real answer for headless
  boxes that move between networks.
- scripts/welcome-banner.sh is the new canonical copy, embedded in the
  binary (tor-helper pattern): bootstrap::run_welcome_banner_sync rewrites
  /etc/profile.d/archipelago.sh on ISO-installed nodes at startup, so the
  fix reaches the deployed fleet with the next OTA instead of only fresh
  ISOs. Machines without an installer-baked banner are left untouched.
- Same fix inlined in the live ISO builder's PROFILE heredoc
  (image-recipe/_archived/build-auto-installer-iso.sh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:37:13 -04:00
archipelagoandClaude Fable 5 56d6396142 feat(appgate): gate login gets the real dashboard badge + glass-button states
Demo images / Build & push demo images (push) Successful in 3m0s
- The badge is now the dashboard login's AnimatedLogo, square for square:
  inline SVG (20 white rects, 100ms stagger, 3s loop) inside the same
  gradient ring. The old <img> of favico-black-v2.svg baked a second ring
  into the ring and couldn't animate; the asset leaves the gate allowlist
  since nothing references it now.
- The submit button is .glass-button longhand: hover lift + lightening +
  rim glow, active press, disabled dim — the flat darken-only hover read
  as broken next to /login.
- Loading state: submitting flips the button to spinner + 'Signing in…'/
  'Verifying…' and disables it, via a single inline script admitted by
  CSP sha256 hash (not unsafe-inline; injected markup stays inert, and
  the page still works as a plain POST without JS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:24:27 -04:00
archipelagoandClaude Fable 5 e0d8b9de74 fix(ui): login badge back to the original — gloss stays screensaver/intro only
The Kammergut gloss v3 opt-in had been applied to the /login badge as
well; per operator the glossed disc belongs ONLY on the screensaver and
the onboarding intro (and the splash tap-logo that fronts them). The
login page returns to the plain gradient-ring badge it always had, same
as the dashboard sidebar fix before it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:24:19 -04:00
archipelagoandClaude Fable 5 d422218c6b chore(demo): rebuild bundled AIUI with the Routstr provider picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:41:17 -04:00
archipelagoandClaude Fable 5 a4be1b4b7d feat(aiui): Routstr tops the model picker with the full live catalog
New 'Routstr (sats)' category sits FIRST in the model dropdown
(operator request 2026-08-14), listing every model the node's
/aiui/api/routstr/models proxy returns (432 live today) — the picker
panel now scrolls (max-h 70vh) instead of overflowing. Selecting a
Routstr model routes the turn through the node's paid completions
proxy, and the explicit choice wins even when AIUI runs embedded in
Archy — a selection, not a fallback. Node refusals (no budget armed,
budget spent, wallet can't fund) surface verbatim in chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:41:17 -04:00
archipelagoandClaude Fable 5 e3bd340725 feat(aiui): Routstr as an explicit, session-gated AI provider path
The D-04 Routstr leg was fallback-only — never user-selectable, and its
Nostr discovery parses a docs-shaped event content ({endpoints, models,
pricing}) that live kind-38421 announcements don't actually carry
({name, about}), so it could never match a real provider. This adds the
explicit path AIUI's model picker needs: /aiui/api/routstr/models
passes through the live aggregator catalog (the instance routstr.com's
own frontend queries; the canonical api.routstr.com 404s), and
/aiui/api/routstr/chat/completions makes one paid, non-streaming,
OpenAI-shaped call — session-gated, egress-screened (S3), refused
without an armed operator budget (D-05), paid via auto_pay_token,
change and refused-request tokens redeemed back into the wallet so a
failed attempt nets zero (verified live: quoted=1 reclaimed=1 net=0).
nginx template gains the location in both server blocks (T-13-15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:41:07 -04:00
archipelagoandClaude Fable 5 ced95a60d1 feat(wallet): Lightning gets the arrival screen; copy buttons unified
Demo images / Build & push demo images (push) Successful in 3m27s
- lnd.createinvoice now returns r_hash_hex; new lnd.invoicestatus RPC
  looks the invoice up (SETTLED + amt_paid_sat). E2E-verified on this
  box: real invoice minted, status polls settled:false until paid.
- Receive modal: Lightning polls settlement every 3s and flips to the
  on-chain-style success view — straight to the green check + amount
  (no broadcast step; settlement is final). Raw bolt11 text removed:
  QR + CopyButton only. State fully reset per open/close.
- CopyButton is now the wallet's only copy affordance: the ark-address
  and ecash-token holdouts swapped in, their ad-hoc handlers deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:43:30 -04:00
archipelagoandClaude Fable 5 a0bd9e53f8 feat(settings): CA generation from the UI; Routstr panel beside the API key
Demo images / Build & push demo images (push) Successful in 3m36s
WebUI RULE (operator, 2026-08-14): never point users at a terminal. The
certificate section told users to run setup-node-ca.sh by hand — it now
has a Generate button backed by system.node-ca.generate, which runs the
idempotent script server-side (live-tested: generated and /ca.crt serves).
Routstr budget panel moves directly under the Claude API key card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:19:05 -04:00
archipelagoandClaude Fable 5 6137762786 feat(settings): Routstr AI budget panel — the integration's missing switch
Demo images / Build & push demo images (push) Successful in 3m29s
The Routstr backend (Cashu-paid inference fallback, shipped 1.7.127) was
fully wired but permanently dormant: its D-05 gate requires an
operator-set sats allowance and nothing in the UI ever called
assistant.budget-get/set — default 0 meant never selected. New Settings
panel (below AI Data Access): allowance/spent/remaining, set-allowance
with 0-disables semantics, enabled/off badge. Backend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:57:53 -04:00
archipelagoandClaude Fable 5 8d1fda29fa feat(federation): peer requests take the stage — clustered and faced
Demo images / Build & push demo images (push) Successful in 3m10s
Requests were spread evenly around the orbit, so they could sit BEHIND
the globe: the blinking call-to-action was invisible and the chart read
as mis-scaled until the user hand-rotated. Now requests cluster tightly
at one stage angle (spacing shrinks as count grows) and, when a NEW
request arrives, the camera steers to face the cluster front-and-center
(depth ∝ sin(angle−rotY); front = angle+π/2) — arrival only, so a user
who rotates away isn't fought. Static/reduced-motion paths snap+render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 07:36:38 -04:00
archipelago 135fb5650b chore: release v1.8.3-alpha
Demo images / Build & push demo images (push) Successful in 3m43s
2026-08-14 06:34:47 -04:00
archipelagoandClaude Fable 5 1de4a0943e docs(changelog): curate v1.8.3-alpha notes + What's New block
Demo images / Build & push demo images (push) Successful in 3m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 05:13:43 -04:00
archipelagoandClaude Fable 5 b5e33784e6 fix(ui): kiosk map animates again + paints on resize; gloss scoped; icons get intrinsic size
Demo images / Build & push demo images (push) Successful in 3m42s
- NetworkMap3D: kiosks keep static PLACEMENT (the rAF-fragile intro was
  the blank-screen cause) but re-attach the half-rate ticker — the calm
  orbit is back; and measure() now renders explicitly when no ticker runs,
  so resizes repaint instead of leaving a stale/blank/mis-scaled
  projection (also fixes reduced-motion users on any screen).
- Gloss v3 scoped to .logo-gloss opt-in (screensaver, intro, login,
  splash tap-logo) — it had leaked onto every logo-gradient-border user,
  including the dashboard header, via AnimatedLogo's default border.
- normalize-app-icon.py output now carries intrinsic 512x512 dimensions:
  a viewBox-only SVG collapses to nothing in auto-sized tiles (the
  'transparent icon in My Apps' report); both app icons regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 05:11:54 -04:00
archipelago 2b7e92e770 chore: release v1.8.2-alpha
Demo images / Build & push demo images (push) Successful in 3m43s
2026-08-14 04:28:12 -04:00
archipelagoandClaude Fable 5 7c34df36cd feat(ui): app icons on the house canvas; detail page gets the tile treatment
Demo images / Build & push demo images (push) Successful in 3m33s
- alby-hub + phoenixd icons re-set with the standard 12% inner margin
  (they shipped edge-to-edge; every other icon carries whitespace).
- scripts/normalize-app-icon.py: wraps any third-party SVG mark onto the
  house canvas — the system applies the tile plate (archy-app-icon)
  automatically but deliberately no runtime inset, so the margin must be
  baked; the guide now says exactly that.
- MarketplaceAppDetails: the icon now carries archy-app-icon like the
  store tiles — the treatment no longer stops at the detail page.
- v1.8.2 changelog: third curated bullet (the ceremony gate requires 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 04:22:28 -04:00
archipelagoandClaude Fable 5 4b4e1ab1a3 feat(ui): Kammergut gloss v3 — wet black paint on the logo badge, approved
Demo images / Build & push demo images (push) Successful in 3m43s
Pure-CSS build (the plan-b SVG text filter embosses artifacts on a disc
— v1 rejected for exactly that): borderless painted disc with warm
Kammergut-toned light, dense gradient stops + turbulence grain dither
(banding), and a radial top bloom instead of a linear streak (a linear
streak's tips seamed against the rim — operator screenshot). Iterated
headlessly + on a live preview server; operator approved 2026-08-14.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 04:11:42 -04:00
archipelagoandClaude Fable 5 d05fa6988d fix(federation): kiosk map goes fully static — kills the blank-load stall
Demo images / Build & push demo images (push) Successful in 3m31s
The GSAP entrance intro needs healthy rAF delivery to reach opacity 1;
on a paint-starved kiosk it stalls and the federation/peers screen reads
as BLANK until a lucky refresh. Kiosks now take the existing staticMode
branch (no intro, no ticker — everything lands in place instantly); the
2D default and 2D/3D toggle stay. Half-rate tick kept for any future
non-static kiosk path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 03:57:05 -04:00
archipelagoandClaude Fable 5 2399eeac66 feat(ui): auto-tab fallback — embed-refusing apps become tab apps
Demo images / Build & push demo images (push) Successful in 3m49s
An app whose frame never loads while its backend reports Running (the
embed-refusal signature: frame-busting JS, top-level-origin apps,
SameSite=Strict logins — everything the gate's header stripping cannot
fix) is remembered in localStorage; every later launch opens a tab
straight from the click (user gesture, so no popup blocker), and
opensInTab() gives it the tab-launch icon. A successful iframe load
clears the memory and entries expire after 7 days, so nodes that gain
embedding (gate improvements) get re-probed instead of being remembered
broken forever. Dev guide updated; v1.8.2 changelog + What's New curated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:23:04 -04:00
archipelago 63cb9dd22c Revert "feat(ui): Kammergut gloss test on the logo badge inner circle"
Demo images / Build & push demo images (push) Successful in 3m51s
This reverts commit 6672d978f7.
2026-08-13 15:02:36 -04:00
archipelago 246916c77b chore: release v1.8.1-alpha
Demo images / Build & push demo images (push) Successful in 3m48s
2026-08-13 14:30:16 -04:00
archipelagoandClaude Fable 5 00416c3c24 feat(app-catalog): curated store entries for Alby Hub + phoenixd
The hand-curated app-catalog/catalog.json is the release gate's drift
baseline; the generator syncs fields but never adds entries, so the two
new apps needed appending — fields taken verbatim from their manifests
(drift check green, 30 catalog / 58 manifest apps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:48:03 -04:00
archipelagoandClaude Fable 5 1dc6d3e0e3 feat(catalog): Alby Hub + phoenixd live; registry refs move to the domain
Signed catalog: 68 apps (alby-hub v1.23.0 + phoenixd 0.9.0 join), every
image ref rewritten from the retired bare-IP host to the Foundation
domain. Trust floor promoted in this same commit: all five active fleet
nodes confirmed on 1.8.0-alpha (which trusts the domain); archy-x250-beta
is root-pin-stranded pre-.122 and needs a re-image regardless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:44:11 -04:00
archipelagoandClaude Fable 5 816a06747a docs(changelog): curate v1.8.1-alpha notes + What's New block
Demo images / Build & push demo images (push) Successful in 3m35s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:26:46 -04:00
archipelagoandClaude Fable 5 9243babcdb feat(kiosk): graphics tiers + Settings knob; network map kiosk mode
Demo images / Build & push demo images (push) Successful in 3m51s
The animated federation map froze the framework-pt 4K TV: the launcher
held every machine to the HD 5500-era choppy-audio flags (single raster
thread, GpuRasterization banned) while the map wrote SVG attrs at 60fps.

- Launcher: two flag tiers. legacy = the proven conservative set; modern
  (Intel gen8+, 'NNth Gen' models, AMD Ryzen) = default raster threads +
  GPU rasterization. Classified from /proc/cpuinfo (11 model strings
  covered by tests in-session); KIOSK_GRAPHICS=performance|quality in
  kiosk-display.conf overrides; headless unchanged. Reaches deployed
  kiosks via the include_str! self-heal, same as the vsync fix.
- system.kiosk-display.get/set: carries a 'graphics' field alongside
  'preset'; setting one no longer clobbers the other.
- Settings → Display: Graphics picker (Auto / Compatibility / Quality).
- NetworkMap3D: kiosks default to the 2D projection (remembered toggle
  still works) and tick at half rate with carried-over deltas — same
  spin speed, half the paint cost.
- Changelog: curated Unreleased notes for all of the above + the gate
  frame-embedding fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:23:07 -04:00
archipelagoandClaude Fable 5 6672d978f7 feat(ui): Kammergut gloss test on the logo badge inner circle
Demo images / Build & push demo images (push) Successful in 3m46s
Black gloss paint from plan-b's Kammergut wordmark (verbatim #paintGloss
SVG filter + the .paint-3d sheen gradient) applied to
.logo-gradient-border::after — the circle behind the A on the
screensaver, intro, splash and login. Marked as a TEST in both files;
revert = git revert of this one commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 13:21:45 -04:00
archipelagoandClaude Fable 5 a80712963c feat(appgate): proxied apps become embeddable — gate neutralizes frame blocking
Apps that ship X-Frame-Options (Alby Hub: DENY) or a CSP frame-ancestors
directive rendered as a dead grey pane in the dashboard's embedded app
session; the historical fix was a bespoke per-app nginx strip proxy
(gitea). The gate now removes X-Frame-Options and strips ONLY the
frame-ancestors directive from proxied responses — the rest of the app's
CSP passes through untouched. The clickjacking threat those headers
address is handled the same way the gate's own pages handle it: every
proxied request is authenticated first, and the gate already declares
permissive frame-ancestors on its own responses. Unit-tested; verified
live on archi-dev-box (Alby Hub embeds, CSP intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 13:05:25 -04:00
archipelagoandClaude Fable 5 76e6f06995 fix(phoenixd): dot-free datadir + data_uid; docs: iframe rules
phoenixd: the orchestrator treats bind paths containing a dot as file
mounts and never creates their source dir, so the image's default
/phoenix/.phoenix target crash-looped the unit (statfs: no such file).
Datadir moved to /data via PHOENIX_DATADIR; data_uid 1000:1000 matches the
image's phoenix user — without it phoenixd dies on phoenix.conf
'Permission denied'. Both verified end-to-end on archi-dev-box: orch
install OK, seed.dat + db on the host, authenticated /getinfo answers.

alby-hub: launch flips to embedded — pairs with the gate change that
neutralizes upstream frame blocking.

Dev guide: iframe embedding rules (who blocks framing and why the gate
may strip it; when open_in_new_tab is legitimate; test in the embedded
session, never a tab).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:38:17 -04:00
archipelagoandClaude Fable 5 b7ba35477c docs(dev-guide): package.install needs dockerImage too; helper gotchas
Learned installing alby-hub/phoenixd for real: the id-only payload fails
with 'Missing dockerImage' (the store normally injects the image from the
catalog), the session helper silently reuses a stale cached session
without ARCHY_FORCE_LOGIN=1, needs jq, and its set -euo pipefail kills an
interactive shell chain without output — so the guide now wraps the flow
in a heredoc subshell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:15:55 -04:00
archipelagoandClaude Fable 5 e87e8017bf docs(dev-guide): real pre-catalog testing flow on a live node
The old RPC example skipped login entirely and implied disk manifests show
up in the App Store. Documents: store lists signed-catalog + Nostr apps
only; the runtime-payload staging path (naive /opt/archipelago/apps copies
are deleted on every backend start); the rpc.bash session helper; and the
full lifecycle loop to run before submitting to the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:09:58 -04:00
archipelagoandClaude Fable 5 6168f6a7d0 fix(alby-hub): host port 8087→8187 — 8087 is netbird's
Caught by the orchestrator's collision check on archi-dev-box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 11:57:23 -04:00
archipelagoandClaude Fable 5 fdd26ba1e3 feat(apps): Alby Hub 1.23.0 + phoenixd 0.9.0 manifests with official icons
Demo images / Build & push demo images (push) Successful in 4m20s
Both images mirrored to the Foundation registry. Alby Hub: gated web UI
on 8087, LDK data under /var/lib/archipelago/alby-hub. phoenixd: headless
loopback API on 9740 (own password auth), seed dir preserved under
/var/lib/archipelago/phoenixd. Not yet in the signed catalog — disk
manifests only, pending install verification on archi-dev-box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 11:55:17 -04:00
archipelago a7a6528b08 chore: release v1.8.0-alpha
Demo images / Build & push demo images (push) Successful in 4m40s
2026-08-12 08:59:14 -04:00
archipelagoandClaude Fable 5 e751b7c6f9 feat(ui): What's New block for v1.8.0-alpha
Demo images / Build & push demo images (push) Successful in 3m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:36:39 -04:00
archipelagoandClaude Fable 5 a500a75235 style: rustfmt update.rs — unblock release gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:35:54 -04:00
archipelagoandClaude Fable 5 71a57c3ec1 docs(changelog): curate v1.8.0-alpha notes — alpha goes open source
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:32:32 -04:00
Archipelago b67e1527a2 Archipelago — open-source initial import 2026-08-12 10:55:50 +00:00
513 changed files with 41191 additions and 13340 deletions
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+22 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 45
versionName = "0.5.25"
versionCode = 52
versionName = "0.5.32"
vectorDrawables {
useSupportLibrary = true
@@ -41,6 +41,17 @@ android {
enableV1Signing = true
enableV2Signing = true
}
// Local-only UAT builds install beside both the production companion
// and its shared-key debug package. The ignored uat.keystore is made
// on the validation box; it must never be used for a public artifact.
create("uat") {
storeFile = file("uat.keystore")
storePassword = "android"
keyAlias = "androiduatkey"
keyPassword = "android"
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
@@ -51,6 +62,13 @@ android {
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
create("uat") {
initWith(getByName("debug"))
applicationIdSuffix = ".uat"
versionNameSuffix = "-uat"
signingConfig = signingConfigs.getByName("uat")
matchingFallbacks += listOf("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -118,8 +136,8 @@ tasks.register<Exec>("buildRustArm64") {
tasks.matching {
it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
"mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
)
}.configureEach { dependsOn("buildRustArm64") }
+9
View File
@@ -54,6 +54,15 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
<!-- Remote-signer pairing deep link (NIP-46, companion 0.5.28):
nostrconnect://<client-pubkey>?relay=...&secret=... — the
node's login QR, hand-off from any QR scanner app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
</activity>
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
@@ -1,5 +1,40 @@
package com.archipelago.app
import android.app.Application
import android.os.Looper
import android.webkit.WebView
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsNative
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ArchipelagoApp : Application()
class ArchipelagoApp : Application() {
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
// Warmups that otherwise land inside the first frame:
// - FipsNative.available dlopens the 7 MB Rust core; referenced from
// composition (NESMenu, mesh auto-start), it blocked the UI thread.
// - The first DataStore read gates the nav graph's start destination;
// parsing it here means the launch gate resolves in the first
// emission instead of waiting on cold disk IO.
warmupScope.launch {
FipsNative.available
runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() }
}
// First WebView construction pays Chromium provider load (~150-400 ms
// cold). Absorb it while the main thread is idle before the kiosk
// needs it, instead of serially after the connection probe.
Looper.getMainLooper().queue.addIdleHandler {
runCatching { WebView(this).destroy() }
false // one-shot
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.screens.releaseKioskWebView
import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,7 +20,13 @@ class MainActivity : ComponentActivity() {
private val pendingPairUri = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
// Hold the branded system splash until the nav graph has its launch
// state — without this the splash dropped at the first composed frame,
// which was EMPTY (the DataStore read hadn't landed): splash → black
// flash → UI on every launch.
var navReady = false
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { !navReady }
enableEdgeToEdge()
super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString
@@ -29,6 +36,7 @@ class MainActivity : ComponentActivity() {
AppNavHost(
pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null },
onReady = { navReady = true },
)
}
}
@@ -38,4 +46,14 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
pendingPairUri.value = intent.dataString
}
override fun onDestroy() {
super.onDestroy()
// Swiped out of recents (or otherwise finished) — let go of the
// retained kiosk WebView so the next launch starts clean. Without
// this the FIPS service keeps the process (and the static WebView)
// alive, and "close the app" no longer restarted it. isFinishing
// keeps config changes (rotation) on the fast reattach path.
if (isFinishing) releaseKioskWebView()
}
}
@@ -0,0 +1,69 @@
package com.archipelago.app
import org.json.JSONObject
/**
* JNI binding to the companion's non-mesh native surface (same
* libarchy_fips_core.so as FipsNative — backup + nostr signer crypto, built
* from Android/rust/archy-fips-core).
*
* Same contract as FipsNative: JSON over strings, failures come back as
* {"error": "…"} rather than exceptions, and [available] is false on ABIs
* the .so isn't built for so every caller can degrade gracefully.
*/
object NativeCore {
val available: Boolean = try {
System.loadLibrary("archy_fips_core")
true
} catch (_: Throwable) {
false
}
// ── Backup (#128): the node's ADR-005 envelope ──────────────────────────
/** Encrypt a JSON payload into an ADR-005 envelope (ChaCha20-Poly1305). */
external fun backupEncrypt(payload: String, passphrase: String): String
/** Decrypt an ADR-005 envelope back to its payload JSON. */
external fun backupDecrypt(envelope: String, passphrase: String): String
// ── NIP-46 remote signer (#139) ─────────────────────────────────────────
/** Generate a fresh nostr key: {"secret","pubkey","npub","nsec"}. */
external fun nostrGenerateSecret(): String
/** Import a key from hex or nsec…: {"secret","pubkey","npub","nsec"}. */
external fun nostrSecretFromAny(secret: String): String
/** Parse nostrconnect://…: {"clientPubkey","relays":[…],"secret","perms","name","url","image"}. */
external fun nostrParseConnectUri(uri: String): String
/**
* Sign `{kind, content, tags, created_at}` with the signer key: returns
* the full signed event JSON. Approval happens BEFORE this call — the
* native side never signs unasked.
*/
external fun nostrSignEvent(secretHex: String, eventJson: String): String
/** NIP-44 v2 encrypt/decrypt; result JSON: {"result": payload} or {"error": …}. */
external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String
external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String
/** NIP-04 fallback (deprecated but still spoken by real clients). */
external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String
external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String
/** True when a native reply is an error envelope. */
fun isErr(json: String): Boolean = try {
JSONObject(json).has("error")
} catch (_: Exception) {
true
}
/** Error text from a native reply, or a generic message if malformed. */
fun errMsg(json: String): String = try {
JSONObject(json).optString("error", "native call failed")
} catch (_: Exception) {
"native call failed"
}
}
@@ -0,0 +1,229 @@
package com.archipelago.app.data
import android.content.Context
import com.archipelago.app.NativeCore
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.nostr.NostrSignerPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Companion backup & restore (#128) — the phone side of "losing your phone,
* or wiping it to cross a border".
*
* The payload (servers + FIPS identity/peers + signer key + flags) is
* serialized to JSON and sealed into the node's ADR-005 envelope (Argon2id +
* ChaCha20-Poly1305) by the native core — the SAME envelope the node uses,
* not a second format. The passphrase never leaves the encrypt call.
*
* Transport is deliberately boring: a plain .json file the user saves via
* the system file picker (SAF) — on GrapheneOS there is no cloud backup and
* there should be none here either; the file goes wherever the user puts it
* (USB drive, computer, a folder synced their way).
*/
class BackupManager(private val context: Context) {
private val servers = ServerPreferences(context)
private val fips = FipsPreferences(context)
private val signer = NostrSignerPreferences(context)
/** Everything the backup captures, for the restore preview UI. */
data class PayloadSummary(
val serverCount: Int,
val hasFipsIdentity: Boolean,
val hasSignerKey: Boolean,
val appVersion: String,
)
/** What a restore actually did, for the result UI. */
data class RestoreResult(
val serversRestored: Int,
val activeSet: Boolean,
val fipsIdentityRestored: Boolean,
val signerKeyRestored: Boolean,
)
private fun appVersion(): String = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
} catch (_: Exception) {
""
}
/**
* Assemble the encrypted backup envelope. Runs on IO: DataStore reads
* plus the Argon2id KDF (tens of ms) + AEAD.
*/
suspend fun createBackup(passphrase: String): String = withContext(Dispatchers.IO) {
require(passphrase.isNotEmpty()) { "passphrase required" }
val active = servers.activeServer.first()
val saved = servers.savedServers.first()
val fipsId = fips.identity()
val peers = fips.peersJson()
val partyPeers = fips.partyPeers()
val partyName = fips.partyName()
val partyListen = fips.partyListen()
val signerSecret = signer.secret()
val payload = JSONObject().apply {
put("app", "archipelago-companion")
put("payloadVersion", 1)
put("appVersion", appVersion())
put("createdAt", System.currentTimeMillis() / 1000)
put("servers", JSONArray(saved.map { it.serialize() }))
put("active", active?.serialize() ?: JSONObject.NULL)
if (fipsId != null) {
put("fips", JSONObject().apply {
put("secret", fipsId.secret)
put("npub", fipsId.npub)
put("address", fipsId.address)
put("peers", JSONArray(peers))
put("partyPeers", JSONArray().apply { partyPeers.forEach { put(JSONObject().apply {
put("npub", it.npub); put("ula", it.ula); put("name", it.name)
put("ip", it.ip); put("port", it.port)
}) } })
put("partyName", partyName)
put("partyListen", partyListen)
})
}
if (signerSecret != null) {
put("signer", JSONObject().apply { put("secret", signerSecret) })
}
put("flags", JSONObject().apply {
put("introSeen", servers.introSeen.first())
})
}
val envelope = NativeCore.backupEncrypt(payload.toString(), passphrase)
if (NativeCore.isErr(envelope)) throw BackupException(NativeCore.errMsg(envelope))
envelope
}
/**
* Peek at a decrypted backup (passphrase already checked) to preview what
* a restore would do. Does NOT touch any stored state.
*/
suspend fun readBackup(envelope: String, passphrase: String): Pair<PayloadSummary, JSONObject> =
withContext(Dispatchers.IO) {
val payload = NativeCore.backupDecrypt(envelope, passphrase)
if (NativeCore.isErr(payload)) throw BackupException(NativeCore.errMsg(payload))
val obj = JSONObject(payload)
if (obj.optString("app") != "archipelago-companion") {
throw BackupException("Not a companion backup (this may be a node backup — restore it on the node)")
}
val summary = PayloadSummary(
serverCount = obj.optJSONArray("servers")?.length() ?: 0,
hasFipsIdentity = obj.has("fips"),
hasSignerKey = obj.has("signer"),
appVersion = obj.optString("appVersion", ""),
)
summary to obj
}
/**
* Apply a decrypted backup to this install. Merge semantics — a restore
* never silently destroys what's already here:
*
* - Servers upsert (npub-first, [ServerPreferences.upsertServer]) — same
* identity merges, never duplicates.
* - The backup's active server is set active only when none is.
* - FIPS identity/peers restore only when this phone has none (a phone
* that already paired has a live identity the node peers with; swapping
* it from a backup would strand the current pairing). Peers merge by
* npub otherwise.
* - Signer key restores only when none exists locally.
*/
suspend fun restoreBackup(payload: JSONObject): RestoreResult = withContext(Dispatchers.IO) {
val serverArray = payload.optJSONArray("servers") ?: JSONArray()
var restored = 0
for (i in 0 until serverArray.length()) {
val raw = serverArray.optString(i)
val entry = ServerEntry.deserialize(raw) ?: continue
servers.upsertServer(entry)
restored++
}
var activeSet = false
val activeStr = if (payload.isNull("active")) null else payload.optString("active", "")
val activeEntry = activeStr?.takeIf { it.isNotBlank() }?.let { ServerEntry.deserialize(it) }
if (activeEntry != null && servers.activeServer.first() == null) {
servers.setActiveServer(activeEntry)
activeSet = true
}
// FIPS identity: only adopt when this phone has none.
var fipsRestored = false
val fipsObj = payload.optJSONObject("fips")
if (fipsObj != null && fips.identity() == null) {
val secret = fipsObj.optString("secret")
if (secret.isNotBlank()) {
fips.saveIdentity(
com.archipelago.app.fips.FipsNative.Identity(
secret = secret,
npub = fipsObj.optString("npub"),
address = fipsObj.optString("address"),
)
)
fipsRestored = true
}
// Peers: union by npub with whatever is already here (an empty
// store takes the backup's list wholesale).
val backupPeers = fipsObj.optJSONArray("peers")?.let { arr ->
(0 until arr.length()).joinToString(",", "[", "]") { arr.optString(it) }
} ?: "[]"
fips.mergePeersJson(backupPeers)
val partyArr = fipsObj.optJSONArray("partyPeers")
if (partyArr != null) {
for (i in 0 until partyArr.length()) {
val p = partyArr.optJSONObject(i) ?: continue
val npub = p.optString("npub")
val ula = p.optString("ula")
if (npub.isNotBlank() && ula.isNotBlank()) {
fips.upsertPartyPeer(
com.archipelago.app.fips.PartyPeer(
npub = npub, ula = ula,
name = p.optString("name").ifBlank { "Phone" },
ip = p.optString("ip"), port = p.optInt("port"),
)
)
}
}
}
if (fipsObj.optString("partyName").isNotBlank()) {
fips.setPartyName(fipsObj.optString("partyName"))
}
fips.setPartyListen(fipsObj.optBoolean("partyListen", false))
}
// Signer key: only adopt when none exists locally.
var signerRestored = false
val signerObj = payload.optJSONObject("signer")
if (signerObj != null && signer.secret() == null) {
val secret = signerObj.optString("secret")
if (secret.isNotBlank()) {
signer.saveSecret(secret)
signerRestored = true
}
}
// Flags: a user who completed the intro on the old phone shouldn't
// see it again on the new one.
val flags = payload.optJSONObject("flags")
if (flags?.optBoolean("introSeen", false) == true) {
servers.markIntroSeen()
}
RestoreResult(
serversRestored = restored,
activeSet = activeSet,
fipsIdentityRestored = fipsRestored,
signerKeyRestored = signerRestored,
)
}
class BackupException(message: String) : Exception(message)
}
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
@@ -29,6 +30,18 @@ data class ServerEntry(
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
/**
* Is this node reachable over the Archipelago FIPS mesh?
*
* A node that advertised either identity (npub) or a mesh address (ULA)
* came from a FIPS-capable pairing QR. Anything else — a hand-entered LAN
* box, someone else's server behind their own VPN — is a plain HTTP
* target, and the companion must NOT raise its own tunnel for it: Android
* allows exactly one VPN at a time, so doing so would silently take the
* tunnel away from whatever the user actually uses to reach that node.
*/
fun isFipsNode(): Boolean = npub.isNotBlank() || meshIp.isNotBlank()
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
private fun urlHost(host: String): String =
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
@@ -89,9 +102,9 @@ class ServerPreferences(private val context: Context) {
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return@map null
ServerEntry(
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
val address = prefs[activeAddressKey] ?: return null
return ServerEntry(
address = address,
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
@@ -102,19 +115,52 @@ class ServerPreferences(private val context: Context) {
)
}
// distinctUntilChanged on every flow: DataStore emits on EVERY write to the
// file regardless of key, and each spurious emission recomposed whatever
// screen collected it (the kiosk recomposed on gesture-hint writes).
val activeServer: Flow<ServerEntry?> = context.dataStore.data
.map { prefs -> activeServerFrom(prefs) }
.distinctUntilChanged()
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
val raw = prefs[savedServersKey] ?: emptySet()
raw.mapNotNull { ServerEntry.deserialize(it) }
}
// Sorted so set-iteration order can't produce a structurally different
// list for the same servers (which defeats distinctUntilChanged).
raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() }
}.distinctUntilChanged()
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false
}
}.distinctUntilChanged()
/** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[gestureHintSeenKey] ?: false
}
}.distinctUntilChanged()
/** Everything the nav graph needs to pick a start destination, derived
* from ONE DataStore emission. Collecting introSeen and activeServer as
* two separate flows let them land in different frames — the intro flag
* could resolve first and flash the Connect screen at a paired user
* before the active server arrived. */
data class LaunchState(
val introSeen: Boolean,
val activeServer: ServerEntry?,
/** Every saved node — the launch gate needs the COUNT to decide
* whether to ask which one to connect to. */
val savedServers: List<ServerEntry>,
)
val launchState: Flow<LaunchState> = context.dataStore.data.map { prefs ->
LaunchState(
introSeen = prefs[introSeenKey] ?: false,
activeServer = activeServerFrom(prefs),
savedServers = (prefs[savedServersKey] ?: emptySet())
.mapNotNull { ServerEntry.deserialize(it) }
.sortedBy { it.displayName() },
)
}.distinctUntilChanged()
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
@@ -37,6 +37,7 @@ class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
private var handoffKickJob: Job? = null
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
// tunnel's underlying network stays pinned to the interface that was
@@ -204,14 +205,20 @@ class ArchyVpnService : VpnService() {
/**
* Track the phone's default network and hand the mesh over to it as the
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
* phone roams (Wi-Fi ⇄ 5G). Two actions per change:
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
* network instead of dying on the one it launched with.
* 2. re-home the mesh — kick the session warmer so discovery + sessions
* rebuild on the new path immediately; the node's own fast-reconnect
* (1s) redials peers over the new route.
* rebuild on the new path; the node's own fast-reconnect (1s) redials
* peers over the new route.
* onAvailable also fires for the FIRST network, which is how the initial
* underlying network gets set.
*
* requestNetwork, NOT registerDefaultNetworkCallback: this app is routed
* through its own TUN, so its "default network" IS the VPN — a default
* callback fires once with our own tunnel and never again on Wi-Fi ⇄ 5G.
* A NetworkRequest's default capabilities include NOT_VPN, so requestNetwork
* tracks the best real transport underneath instead.
*/
private fun registerNetworkHandoff() {
if (networkCallback != null) return
@@ -236,10 +243,6 @@ class ArchyVpnService : VpnService() {
}
}
networkCallback = cb
// requestNetwork tracks the BEST network of the request; when the
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
// one. (registerDefaultNetworkCallback would also work; requestNetwork
// lets us extend to BLE-capable transports later.)
runCatching { cm.requestNetwork(request, cb) }
}
@@ -251,13 +254,22 @@ class ArchyVpnService : VpnService() {
runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (changed && FipsNative.isRunning()) {
Log.i(TAG, "network handoff → re-homing mesh on new default network")
// Fresh warmer pass drives immediate rediscovery/session rebuild
// on the new path instead of waiting out dead-link timeouts.
startSessionWarmer()
// Coalesced, not immediate: marginal Wi-Fi flaps the default
// Wi-Fi ⇄ cell in bursts, and an aggressive warmer pass per flip
// meant near-constant session churn — the "reconnects a lot"
// report. The re-pin above still happens on every change; only
// the rediscovery kick waits for the network to hold still.
handoffKickJob?.cancel()
handoffKickJob = scope.launch {
delay(2_000)
if (FipsNative.isRunning()) startSessionWarmer()
}
}
}
private fun unregisterNetworkHandoff() {
handoffKickJob?.cancel()
handoffKickJob = null
val cm = connectivityManager
val cb = networkCallback
if (cm != null && cb != null) {
@@ -3,8 +3,10 @@ package com.archipelago.app.fips
import android.content.Context
import android.content.Intent
import android.net.VpnService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
/**
* Glue between pairing and the mesh: persists the node peer from a scanned
@@ -36,20 +38,27 @@ object FipsManager {
* No-op on devices without the native lib (non-arm64).
*/
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
if (info == null || !FipsNative.available) return
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
peersDirty = true
// Restart the mesh with the new peer RIGHT NOW when consent already
// exists — relying on the consentNeeded collector left a running
// mesh on the OLD peer list whenever the collector wasn't active
// (fresh pairings looked dead until a full app restart).
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
if (info == null) return
// Every caller reaches this from a Compose scope — i.e. the MAIN
// thread — the instant a pairing QR decodes. Everything below is
// main-hostile: touching FipsNative dlopens the 7 MB mesh core,
// ensureIdentity runs native ed25519 keygen, and VpnService.prepare
// is a binder round-trip. Left on the UI thread it froze the frame
// right after the camera got the code, which reads as "the scanner
// is slow" when the scan itself already succeeded.
val consent = withContext(Dispatchers.IO) {
if (!FipsNative.available) return@withContext null
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
peersDirty = true
// Restart the mesh with the new peer RIGHT NOW when consent already
// exists — relying on the consentNeeded collector left a running
// mesh on the OLD peer list whenever the collector wasn't active
// (fresh pairings looked dead until a full app restart).
VpnService.prepare(context) == null
} ?: return
if (consent) startService(context) else _consentNeeded.value = true
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
@@ -67,11 +76,17 @@ object FipsManager {
* through AppNavHost instead.
*/
suspend fun autoStartIfReady(context: Context) {
if (!FipsNative.available) return
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
startService(context)
// Self-dispatching for the same reason as registerNode: callers reach
// this from Compose scopes, and dlopen + binder must not ride the UI
// thread (the connect path calls it while the scanner is still up).
val ready = withContext(Dispatchers.IO) {
if (!FipsNative.available) return@withContext false
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return@withContext false
// consent missing — don't prompt here
VpnService.prepare(context) == null
}
if (ready) startService(context)
}
fun startService(context: Context) {
@@ -89,6 +89,38 @@ class FipsPreferences(private val context: Context) {
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
/**
* Union the stored node peers with a backup's peer list, matched by
* npub — the backup's copy wins for the same npub (its addresses are what
* the restored identity pairs against). Used by companion restore (#128)
* after [saveIdentity] adopted the backup's mesh identity.
*/
suspend fun mergePeersJson(incomingJson: String) {
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
val incoming = try {
JSONArray(incomingJson)
} catch (_: Exception) {
JSONArray()
}
val incomingNpubs = mutableSetOf<String>()
val merged = JSONArray()
for (i in 0 until incoming.length()) {
val peer = incoming.optJSONObject(i) ?: continue
val npub = peer.optString("npub")
if (npub.isNotBlank()) {
incomingNpubs.add(npub)
merged.put(peer)
}
}
for (i in 0 until current.length()) {
val peer = current.optJSONObject(i) ?: continue
if (peer.optString("npub") !in incomingNpubs) merged.put(peer)
}
prefs[peersKey] = merged.toString()
}
}
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
suspend fun partyListen(): Boolean =
@@ -0,0 +1,445 @@
package com.archipelago.app.nostr
import android.content.Context
import com.archipelago.app.NativeCore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* NIP-46 remote-signer session (#139) — the phone side, wire-faithful to
* rust-nostr's reference bunker (`signer/nostr-connect/src/signer.rs`),
* which the node's login flow will interoperate with:
*
* 1. Client (the node's login page) shows a `nostrconnect://` QR.
* 2. We scan it, connect to its relay, subscribe to kind-24133 events
* p-tagged to our signer key, and send a `connect` request carrying the
* secret (the client validates it and answers "ack").
* 3. Requests arrive as NIP-44-encrypted kind-24133 events; we respond over
* the same channel. `sign_event` is the one method that never runs
* without a human tapping Approve on this phone.
*
* The session lives while the app is around (the login handshake takes
* seconds); there is no background service in v1 and no remembered-session
* auto-reconnect (research doc flow C — deferred deliberately).
*/
object BunkerManager {
sealed class SignerState {
/** Native core unavailable (e.g. x86 emulator) — signing impossible. */
object Unavailable : SignerState()
/** Key exists, no session. */
object Idle : SignerState()
/** No signer key generated/imported yet. */
object NoKey : SignerState()
data class Connecting(val relay: String) : SignerState()
/** Connect request sent; waiting for the client to ack. */
data class AwaitingClient(val relay: String, val clientName: String) : SignerState()
/** Handshake complete — this is the state where requests are answered. */
data class Ready(val relay: String, val clientName: String) : SignerState()
data class Failed(val reason: String) : SignerState()
}
/** One signature request awaiting a human decision. */
data class PendingRequest(
val id: String,
val method: String,
val clientPubkey: String,
val clientName: String,
val kind: Long?,
val content: String?,
/** Formatted tag lines for the approval card. */
val tags: List<String>,
val createdAt: Long?,
/** The full unsigned event JSON handed to the native signer on approve. */
val unsignedEventJson: String,
)
private val _state = MutableStateFlow<SignerState>(SignerState.Idle)
val state: StateFlow<SignerState> = _state.asStateFlow()
private val _pending = MutableStateFlow<PendingRequest?>(null)
val pending: StateFlow<PendingRequest?> = _pending.asStateFlow()
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(25, TimeUnit.SECONDS) // relay keepalive
.build()
private data class Session(
val socket: WebSocket,
val relay: String,
/** The client's pubkey (hex) from the nostrconnect URI. */
val clientPubkey: String,
val clientName: String,
/** The pairing secret — echoed back during handshake, then kept for
* validating an incoming `connect` from the same client. */
val secret: String,
/** Our connect request id, to match the client's ack response. */
val connectRequestId: String,
/** Our signer secret (hex). */
val signerSecretHex: String,
/** Our signer pubkey (hex). */
val signerPubkeyHex: String,
/** Event ids already handled (relays may redeliver). */
val seen: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet(),
)
private val session = AtomicReference<Session?>(null)
/** Refresh Idle/NoKey state (suspend; call from a coroutine — DataStore reads hit disk). */
suspend fun refreshState(context: Context) {
if (!NativeCore.available) {
_state.value = SignerState.Unavailable
return
}
if (session.get() != null) return
val prefs = NostrSignerPreferences(context.applicationContext)
_state.value =
if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle
}
/**
* Pair from a scanned or deep-linked `nostrconnect://…` URI. Returns a
* user-presentable error on failure, or null on success (state moves to
* Connecting → AwaitingClient).
*/
suspend fun pair(context: Context, uri: String): String? {
if (!NativeCore.available) return "Signing is unavailable on this device"
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val parsed = JSONObject(NativeCore.nostrParseConnectUri(uri.trim()))
if (parsed.has("error")) return@withContext parsed.getString("error")
val prefs = NostrSignerPreferences(appContext)
val secret = prefs.secret()
?: return@withContext "No signer key yet — generate or import one first"
val info = JSONObject(NativeCore.nostrSecretFromAny(secret))
if (info.has("error")) return@withContext info.getString("error")
val clientPubkey = parsed.getString("clientPubkey")
val relays = mutableListOf<String>()
parsed.optJSONArray("relays")?.let { arr -> for (i in 0 until arr.length()) relays.add(arr.optString(i)) }
val clientName = parsed.optString("name").ifBlank { "client" }
val pairSecret = parsed.getString("secret")
if (relays.isEmpty()) return@withContext "The pairing code carries no relay to reach the client on"
teardown()
var lastError = "no relay could be reached"
for (relay in relays) {
_state.value = SignerState.Connecting(relay)
val opened = openSession(
relay, clientPubkey, clientName, pairSecret, secret, info,
)
if (opened != null) {
session.set(opened)
prefs.savePairing(
NostrSignerPreferences.Pairing(clientPubkey, relay, clientName)
)
_state.value = SignerState.AwaitingClient(relay, clientName)
return@withContext null
}
lastError = "relay $relay did not answer"
}
_state.value = SignerState.Failed(lastError)
lastError
}
}
/** Re-establish the last saved pairing without a fresh QR. */
suspend fun resume(context: Context): String? {
if (!NativeCore.available) return "Signing is unavailable on this device"
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val prefs = NostrSignerPreferences(appContext)
val pairing = prefs.lastPairing()
?: return@withContext "Nothing to resume — no saved pairing"
val secret = prefs.secret()
?: return@withContext "No signer key"
val info = JSONObject(NativeCore.nostrSecretFromAny(secret))
if (info.has("error")) return@withContext info.getString("error")
teardown()
_state.value = SignerState.Connecting(pairing.relay)
val opened = openSession(
pairing.relay, pairing.clientPubkey, pairing.name,
secret = "", signerSecretHex = secret, info = info,
)
if (opened == null) {
_state.value = SignerState.Failed("relay ${pairing.relay} did not answer")
return@withContext "Could not reach ${pairing.relay}"
}
session.set(opened)
_state.value = SignerState.AwaitingClient(pairing.relay, pairing.name)
null
}
}
fun unpair() {
teardown()
_state.value = SignerState.Idle
}
private fun teardown() {
session.getAndSet(null)?.socket?.close(1000, "unpaired")
_pending.value = null
}
private fun randomId(): String {
val bytes = ByteArray(8)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun openSession(
relay: String,
clientPubkey: String,
clientName: String,
secret: String,
signerSecretHex: String,
info: JSONObject,
): Session? {
val signerPubkeyHex = info.getString("pubkey")
val connectRequestId = randomId()
// The listener needs the Session, the Session needs the WebSocket:
// bind through a holder set right after newWebSocket returns (OkHttp
// invokes onOpen on its own dispatcher after the network round-trip,
// i.e. always after the bind below).
val holder = AtomicReference<Session?>()
val request = Request.Builder().url(relay).build()
val socket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val s = holder.get() ?: return
// Subscribe to requests addressed to us (p-tag filter), from
// now — no history replay of stale login attempts.
webSocket.send(
"""["REQ","${s.connectRequestId}sub",{"kinds":[24133],"#p":["${s.signerPubkeyHex}"],"since":${epochSecs() - 120}}]"""
)
// Handshake: the signer sends `connect` carrying the secret
// (rust-nostr's NostrConnectRemoteSigner.send_connect_ack —
// the exact frame the node's client waits for).
val content = JSONObject().apply {
put("id", s.connectRequestId)
put("method", "connect")
put("params", JSONArray().put(s.signerPubkeyHex).put(s.secret))
}.toString()
if (!sendEncrypted(s, content)) {
_state.value = SignerState.Failed("Could not encrypt the connect message")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
val s = session.get() ?: return
handleRelayMessage(s, text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (session.get()?.socket === webSocket) {
_state.value = SignerState.Failed(t.message ?: "relay connection failed")
session.getAndSet(null)
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (session.get()?.socket === webSocket) {
_state.value = SignerState.Idle
session.getAndSet(null)
}
}
})
val s = Session(
socket = socket,
relay = relay,
clientPubkey = clientPubkey,
clientName = clientName,
secret = secret,
connectRequestId = connectRequestId,
signerSecretHex = signerSecretHex,
signerPubkeyHex = signerPubkeyHex,
)
holder.set(s)
return s
}
private fun epochSecs(): Long = System.currentTimeMillis() / 1000
/** Encrypt a JSON-RPC frame to the peer and publish it as kind 24133. */
private fun sendEncrypted(s: Session, json: String): Boolean {
val enc = NativeCore.nostrNip44Encrypt(s.signerSecretHex, s.clientPubkey, json)
if (NativeCore.isErr(enc)) return false
val payload = JSONObject(enc).getString("result")
val event = JSONObject().apply {
put("kind", 24133)
put("content", payload)
put("tags", JSONArray().put(JSONArray().put("p").put(s.clientPubkey)))
put("created_at", epochSecs())
}.toString()
val signed = NativeCore.nostrSignEvent(s.signerSecretHex, event)
if (NativeCore.isErr(signed)) return false
return s.socket.send("""["EVENT",$signed]""")
}
private fun handleRelayMessage(s: Session, text: String) {
val arr = try {
JSONArray(text)
} catch (_: Exception) {
return
}
if (arr.length() == 0) return
when (arr.optString(0)) {
"EVENT" -> {
val event = arr.optJSONObject(2) ?: return
if (event.optLong("kind") != 24133L) return
val id = event.optString("id")
if (id.isNotEmpty() && !s.seen.add(id)) return
val author = event.optString("pubkey")
if (author != s.clientPubkey) return // not our client
handleClientEvent(s, author, event.optString("content"))
}
// OK / CLOSED / NOTICE: nothing actionable for the bunker in v1.
}
}
private fun handleClientEvent(s: Session, author: String, content: String) {
// NIP-44 is the mandated transport; NIP-04 stays as receive fallback
// for clients that still speak the deprecated scheme.
val plain = run {
val nip44 = NativeCore.nostrNip44Decrypt(s.signerSecretHex, author, content)
if (!NativeCore.isErr(nip44)) JSONObject(nip44).getString("result") else {
val nip04 = NativeCore.nostrNip04Decrypt(s.signerSecretHex, author, content)
if (!NativeCore.isErr(nip04)) JSONObject(nip04).getString("result") else return
}
}
val msg = try {
JSONObject(plain)
} catch (_: Exception) {
return
}
val id = msg.optString("id")
val method = msg.optString("method", "")
if (method.isNotEmpty()) {
when (method) {
"connect" -> {
val params = msg.optJSONArray("params") ?: return
// Param 0 must be OUR pubkey (client is connecting to us,
// not some other bunker through this session).
val target = params.optString(0)
val givenSecret = params.optString(1)
val authorized = target == s.signerPubkeyHex &&
(s.secret.isBlank() || givenSecret == s.secret || givenSecret.isBlank())
if (authorized) {
respond(s, id, result = "ack")
_state.value = SignerState.Ready(s.relay, s.clientName)
} else {
respond(s, id, error = "unauthorized")
}
}
"get_public_key" -> respond(s, id, result = s.signerPubkeyHex)
"describe" -> respond(s, id, result = "connect get_public_key sign_event ping")
"ping" -> respond(s, id, result = "pong")
"sign_event" -> {
val params = msg.optJSONArray("params") ?: return
val eventJson = params.optString(0)
val ev = try {
JSONObject(eventJson)
} catch (_: Exception) {
respond(s, id, error = "malformed event")
return
}
// Never overwrite a pending request silently — a second
// tap on the node would otherwise cancel the visible one.
if (_pending.value == null) {
_pending.value = PendingRequest(
id = id,
method = method,
clientPubkey = author,
clientName = s.clientName,
kind = if (ev.has("kind") && !ev.isNull("kind")) ev.optLong("kind") else null,
content = if (ev.has("content") && !ev.isNull("content")) ev.optString("content") else null,
tags = formatTags(ev.optJSONArray("tags")),
createdAt = if (ev.has("created_at") && !ev.isNull("created_at")) ev.optLong("created_at") else null,
unsignedEventJson = eventJson,
)
} else {
respond(s, id, error = "busy")
}
}
else -> respond(s, id, error = "not authorized")
}
} else if (msg.has("result") || msg.has("error")) {
// A response to OUR connect request (the client's ack).
if (id == s.connectRequestId) {
if (msg.has("error")) {
_state.value = SignerState.Failed("Client rejected the connection: ${msg.optString("error")}")
} else if (msg.optString("result") == "ack") {
_state.value = SignerState.Ready(s.relay, s.clientName)
}
}
}
}
/** Approve the pending request: sign and send the result. */
suspend fun approve(): Boolean {
val s = session.get() ?: return false
val req = _pending.value ?: return false
val ok = withContext(Dispatchers.IO) {
val signed = NativeCore.nostrSignEvent(s.signerSecretHex, req.unsignedEventJson)
if (NativeCore.isErr(signed)) {
respond(s, req.id, error = "signing failed")
false
} else {
// Result is the signed event, JSON-stringified per the spec.
respond(s, req.id, result = signed)
}
}
_pending.value = null
return ok
}
/** Deny the pending request with an explicit error. */
fun deny() {
val s = session.get() ?: return
val req = _pending.value ?: return
respond(s, req.id, error = "denied")
_pending.value = null
}
/** Send a JSON-RPC response frame to the client. True when the WS send worked. */
private fun respond(s: Session, id: String, result: String? = null, error: String? = null): Boolean {
val frame = JSONObject().apply {
put("id", id)
if (error != null) put("error", error)
if (result != null) put("result", result)
}.toString()
return sendEncrypted(s, frame)
}
private fun formatTags(tags: JSONArray?): List<String> {
tags ?: return emptyList()
val out = mutableListOf<String>()
for (i in 0 until tags.length()) {
val tag = tags.optJSONArray(i) ?: continue
val parts = mutableListOf<String>()
for (j in 0 until tag.length()) parts.add(tag.optString(j))
out.add(parts.joinToString(" "))
}
return out
}
}
@@ -0,0 +1,95 @@
package com.archipelago.app.nostr
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.archipelago.app.NativeCore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
private val Context.signerDataStore: DataStore<Preferences> by preferencesDataStore(name = "nostr_signer")
/**
* Storage for the phone-side NIP-46 remote signer (#139): the signer secret
* key (hex) and the last pairing, so a re-opened app can resume a session
* without re-scanning the node's QR.
*
* Same plaintext-DataStore model as the FIPS secret (app-private storage,
* no extra OS keystore ceremony — the node login password lives the same way
* in ServerPreferences); the nsec grants the ability to sign as this identity,
* never node login.
*/
class NostrSignerPreferences(private val context: Context) {
private val secretKey = stringPreferencesKey("signer_secret")
private val clientPubkeyKey = stringPreferencesKey("pair_client_pubkey")
private val clientRelayKey = stringPreferencesKey("pair_client_relay")
private val clientNameKey = stringPreferencesKey("pair_client_name")
/** The signer secret (hex) or null when no key exists yet. */
suspend fun secret(): String? = context.signerDataStore.data.first()[secretKey]
val secretFlow: Flow<String?> = context.signerDataStore.data
.map { it[secretKey] }
.distinctUntilChanged()
suspend fun saveSecret(hex: String) {
context.signerDataStore.edit { it[secretKey] = hex.trim() }
}
/** Generate a fresh signer key (fails if the native core is missing). */
suspend fun generateSecret(): JSONObject = withContext(Dispatchers.IO) {
val json = NativeCore.nostrGenerateSecret()
val obj = JSONObject(json)
if (obj.has("error")) throw IllegalStateException(obj.getString("error"))
saveSecret(obj.getString("secret"))
obj
}
/** Import a secret from hex or nsec…; returns the parsed key info. */
suspend fun importSecret(raw: String): JSONObject = withContext(Dispatchers.IO) {
val json = NativeCore.nostrSecretFromAny(raw.trim())
val obj = JSONObject(json)
if (obj.has("error")) throw IllegalArgumentException(obj.getString("error"))
saveSecret(obj.getString("secret"))
obj
}
data class Pairing(val clientPubkey: String, val relay: String, val name: String)
suspend fun lastPairing(): Pairing? {
val prefs = context.signerDataStore.data.first()
val pubkey = prefs[clientPubkeyKey] ?: return null
val relay = prefs[clientRelayKey] ?: return null
if (pubkey.isBlank() || relay.isBlank()) return null
return Pairing(pubkey, relay, prefs[clientNameKey] ?: "")
}
suspend fun savePairing(pairing: Pairing) {
context.signerDataStore.edit {
it[clientPubkeyKey] = pairing.clientPubkey
it[clientRelayKey] = pairing.relay
it[clientNameKey] = pairing.name
}
}
suspend fun clearPairing() {
context.signerDataStore.edit {
it.remove(clientPubkeyKey)
it.remove(clientRelayKey)
it.remove(clientNameKey)
}
}
suspend fun wipeKey() {
context.signerDataStore.edit { it.remove(secretKey) }
}
}
@@ -0,0 +1,294 @@
package com.archipelago.app.ui.components
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.data.BackupManager
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Backup & Restore (#128) — the hub's BACKUP sub-page (same container as
* Nodes/FIPS), the phone side of losing your phone or wiping it to cross a
* border. See docs/companion-backup-restore.md for the envelope and merge
* semantics; this composable is the flow only.
*/
@Composable
internal fun BackupSection() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val manager = remember { BackupManager(context) }
var passphrase by remember { mutableStateOf("") }
var confirm by remember { mutableStateOf("") }
var status by remember { mutableStateOf<String?>(null) }
var statusError by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
// Decrypted backup awaiting the user's go-ahead (restore flow).
var restorePreview by remember { mutableStateOf<Pair<BackupManager.PayloadSummary, org.json.JSONObject>?>(null) }
fun say(msg: String, error: Boolean) {
status = msg
statusError = error
}
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
busy = true
try {
val envelope = manager.createBackup(passphrase)
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(envelope.toByteArray())
} ?: throw BackupManager.BackupException("could not open the destination file")
}
say("Saved — keep the file and the passphrase somewhere safe.", false)
} catch (e: Exception) {
say(e.message ?: "backup failed", true)
} finally {
busy = false
}
}
}
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
busy = true
try {
val envelope = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() }
?: throw BackupManager.BackupException("could not read the selected file")
}
val (summary, payload) = manager.readBackup(envelope, passphrase)
restorePreview = summary to payload
} catch (e: Exception) {
say(e.message ?: "restore failed", true)
} finally {
busy = false
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionCopy(
"An encrypted copy of everything this phone holds — nodes and their passwords, " +
"your mesh identity, the remote-signer key. Same envelope your node uses (ADR-005), " +
"one passphrase, no cloud."
)
// ── Create a backup ──────────────────────────────────────────────
SectionHeader(Icons.Default.Save, "Create a backup")
GlassField(
value = passphrase,
onValueChange = { passphrase = it },
placeholder = "Passphrase",
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
GlassField(
value = confirm,
onValueChange = { confirm = it },
placeholder = "Repeat passphrase",
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
SectionHint("The passphrase cannot be recovered — a backup nobody can open is a paperweight.")
WideAction(
text = if (busy) "Working…" else "Save backup file",
onClick = {
if (busy) return@WideAction
if (passphrase.length < 8) {
say("Use at least 8 characters — this passphrase guards every secret in the app.", true)
return@WideAction
}
if (passphrase != confirm) {
say("The two passphrases don't match.", true)
return@WideAction
}
val stamp = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date())
exportLauncher.launch("archy-companion-backup-$stamp.json")
},
)
Spacer(Modifier.height(2.dp))
// ── Restore a backup ─────────────────────────────────────────────
SectionHeader(Icons.Default.Restore, "Restore a backup")
SectionHint(
"Nothing is overwritten: nodes merge by identity, and the mesh identity and " +
"signer key only restore when this phone has none."
)
WideAction(
text = if (busy) "Working…" else "Choose backup file",
onClick = {
if (busy) return@WideAction
if (passphrase.isEmpty()) {
say("Enter the backup's passphrase first.", true)
return@WideAction
}
importLauncher.launch(arrayOf("application/json"))
},
)
restorePreview?.let { (summary, payload) ->
Spacer(Modifier.height(2.dp))
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.04f))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
"Backup verified${if (summary.appVersion.isNotBlank()) " (made by v${summary.appVersion})" else ""}",
color = SuccessGreen, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
)
SummaryRow("Nodes", summary.serverCount.toString())
if (summary.hasFipsIdentity) SummaryRow("Mesh identity", "included")
if (summary.hasSignerKey) SummaryRow("Remote-signer key", "included")
WideAction(
text = if (busy) "Restoring…" else "Restore onto this phone",
onClick = {
if (busy) return@WideAction
scope.launch {
busy = true
try {
val result = manager.restoreBackup(payload)
restorePreview = null
passphrase = ""
confirm = ""
say(
"Restored ${result.serversRestored} node(s)" +
(if (result.activeSet) ", set active" else "") +
(if (result.fipsIdentityRestored) ", mesh identity" else "") +
(if (result.signerKeyRestored) ", signer key" else "") +
". Restart the app to reconnect.",
false,
)
} catch (e: Exception) {
say(e.message ?: "restore failed", true)
} finally {
busy = false
}
}
},
)
}
}
status?.takeIf { it.isNotBlank() }?.let { msg ->
Text(
msg,
color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
internal fun SectionHeader(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp))
Text(title, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
internal fun SectionCopy(text: String) {
Text(text, color = TextMuted, fontSize = 12.sp, lineHeight = 16.sp)
}
@Composable
internal fun SectionHint(text: String) {
Text(text, color = TextMuted.copy(alpha = 0.8f), fontSize = 10.sp, lineHeight = 13.sp)
}
/** Wide orange-outline action button in the menu's visual language. */
@Composable
internal fun WideAction(
text: String,
onClick: () -> Unit,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
) {
Row(
Modifier
.fillMaxWidth()
.height(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
if (icon != null) {
Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(16.dp))
Spacer(Modifier.size(8.dp))
}
Text(text, color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
internal fun SummaryRow(label: String, value: String) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = TextMuted, fontSize = 12.sp)
Text(value, color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
}
@@ -1,37 +1,46 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.screens.PixelArtLogo
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/**
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
* dialing the node over the mesh (relaunch race, post-scan first connect),
* instead of an anonymous spinner. The point of the brand: what's loading
* is a connection to a cryptographic identity, not an IP.
* Full-screen loader shown while the app is dialing a node.
*
* Two faces, because they are two different promises:
* - [mesh] `true` — a FIPS node: the branded "F*CK IPs" screen, because what
* is loading really is a connection to a cryptographic identity, not an IP.
* - [mesh] `false` — a plain node reached over the network like anything
* else. No mesh branding at all: claiming the mesh is carrying a connection
* it isn't is worse than an anonymous spinner.
*/
@Composable
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
fun MeshLoadingScreen(
mesh: Boolean = true,
nodeName: String = "",
done: Boolean = false,
) {
Box(
Modifier
.fillMaxSize()
@@ -39,39 +48,39 @@ fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// The brand's circle-container logo (as on the connect screen /
// web login): pixel-art "a" centered in a black disc.
Box(
Modifier
.size(120.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color.Black)
.border(
1.dp,
Color.White.copy(alpha = 0.14f),
androidx.compose.foundation.shape.CircleShape,
),
contentAlignment = Alignment.Center,
) {
PixelArtLogo(Modifier.size(64.dp))
}
Spacer(Modifier.height(20.dp))
// The app's own badge — the same ringed mark as the launcher icon
// and the system splash, so launch → splash → this screen is one
// continuous identity.
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = null,
modifier = Modifier.size(112.dp),
)
Spacer(Modifier.height(24.dp))
Text(
text = "F*CK IPs MESH",
text = if (mesh) "F*CK IPS MESH" else "CONNECTING",
color = BitcoinOrange,
fontSize = 18.sp,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 4.sp,
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(10.dp))
Text(
text = message,
color = TextMuted,
text = when {
mesh -> "Dialing your node by its key — no IPs harmed"
nodeName.isNotBlank() -> "Reaching $nodeName"
else -> "Reaching your node"
},
color = if (done) TextPrimary else TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
Spacer(Modifier.height(28.dp))
SlidingLoader(
modifier = Modifier.width(220.dp),
done = done,
)
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
}
@@ -30,6 +30,9 @@ import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.SettingsBackupRestore
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -70,6 +73,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.screens.restartCompanionApp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
@@ -104,22 +108,62 @@ fun NESMenu(
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
// Remote-signer pairing request (nostrconnect://… deep link, or a scan):
// non-null opens the hub on the signer sub-page and pairs. Consumed once
// the signer section hands it back via [onSignerPairHandled].
signerPairRequest: String? = null,
onSignerPairHandled: () -> Unit = {},
) {
// Pairing state is latched here (not passed straight through) so the
// source can clear itself while the request stays alive until consumed.
var pendingSignerPair by remember { mutableStateOf<String?>(null) }
var signerScan by remember { mutableStateOf(false) }
LaunchedEffect(signerPairRequest) {
if (signerPairRequest != null) pendingSignerPair = signerPairRequest
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
// Contained hub overlay: a centred glass panel (not full-screen) that
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
// inside its own bounds when content is tall. Tapping the dimmed
// backdrop dismisses.
// holds the card page and its sub-pages (Nodes, FIPS, Backup, Signer)
// and scrolls inside its own bounds when content is tall. Tapping the
// dimmed backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
MenuPanel(
servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr,
onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty,
signerPairUri = pendingSignerPair,
onSignerScan = { signerScan = true },
onSignerPairHandled = {
pendingSignerPair = null
onSignerPairHandled()
},
)
}
}
}
// Pairing-QR scanner for the signer sub-page — a full-screen glass
// modal hosted OUTSIDE the hub panel so it isn't clipped to the panel's
// bounds (same layering the pairing scanner gets from WebViewScreen).
QrGlassModal(
visible = signerScan && visible,
title = "Scan pairing QR",
status = null,
idleHint = "Point at the nostrconnect QR the node or client shows",
permissionRationale = "Camera access is needed to scan the pairing code",
onDismiss = { signerScan = false },
onDecoded = { text ->
if (text.startsWith("nostrconnect://")) {
signerScan = false
pendingSignerPair = text
}
},
)
}
@Composable
@@ -136,6 +180,9 @@ private fun MenuPanel(
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
signerPairUri: String?,
onSignerScan: () -> Unit,
onSignerPairHandled: () -> Unit,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
@@ -174,9 +221,10 @@ private fun MenuPanel(
.widthIn(max = 420.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp)
// Cap height just short of the full screen; the panel wraps short
// content and only scrolls in the rare case it outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
// Cap height at 70% of the screen — a ~15% breathing margin top
// and bottom — the panel wraps short content and scrolls inside
// its own bounds when a sub-page outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
@@ -199,7 +247,13 @@ private fun MenuPanel(
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
Spacer(Modifier.width(12.dp))
Text(
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
when (page) {
HubPage.NODES -> "Nodes"
HubPage.FIPS -> "FIPS Mesh"
HubPage.BACKUP -> "Backup & Restore"
HubPage.SIGNER -> "Remote Signer"
HubPage.HUB -> "Menu"
},
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
@@ -221,14 +275,54 @@ private fun MenuPanel(
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES
}
if (FipsNative.available) {
// Mesh oversight only when this session is actually on the
// mesh. Offering "FIPS Mesh" while connected to a plain node
// (whose traffic is going nowhere near the tunnel) advertises
// a connection the user doesn't have.
if (FipsNative.available && activeServer?.isFipsNode() == true) {
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
}
if (onMeshParty != null) {
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
}
// Backup & Restore (#128): the phone side of losing your phone
// or wiping it to cross a border — encrypted export file, no cloud.
HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { page = HubPage.BACKUP }
// Remote Signer (#139): hold a nostr key on the phone and
// approve/deny remote signature requests (NIP-46).
HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { page = HubPage.SIGNER }
// Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here.
// Small version chip at the hub's foot — the one place a
// connected user can always check what build they're on.
val hubContext = LocalContext.current
// Restart: the dashboard WebView is retained across
// remote ⇄ dashboard (that's the point), which also means a
// wedged page can't be cleared by leaving the screen. This
// throws the page away and relaunches the app clean — the mesh
// service keeps running.
HubCard(Icons.Default.RestartAlt, "Restart", "Reload the app from scratch") {
onDismiss()
restartCompanionApp(hubContext)
}
val versionLabel = remember {
runCatching {
hubContext.packageManager
.getPackageInfo(hubContext.packageName, 0).versionName
}.getOrNull()?.let { "Companion v$it" } ?: ""
}
if (versionLabel.isNotEmpty()) {
Text(
versionLabel,
color = TextMuted.copy(alpha = 0.6f),
fontSize = 11.sp,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
}
HubPage.NODES -> {
@@ -236,6 +330,11 @@ private fun MenuPanel(
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
// FIPS nodes carry their mesh ULA — the address Termux
// (or any other app) can reach over the split-tunnel,
// from anywhere. Tap to copy; the node's npub stays
// visible in the FIPS Mesh page.
subtitle = server.meshIp.takeIf { it.isNotBlank() },
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
@@ -355,11 +454,23 @@ private fun MenuPanel(
HubPage.FIPS -> {
FipsSection(embedded = true)
}
HubPage.BACKUP -> {
BackupSection()
}
HubPage.SIGNER -> {
SignerSection(
pairUri = signerPairUri,
onScan = onSignerScan,
onPairHandled = onSignerPairHandled,
)
}
}
}
}
private enum class HubPage { HUB, NODES, FIPS }
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
@@ -546,26 +657,52 @@ private fun MenuItem(
onClick: () -> Unit,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
/** Optional second line (the node's mesh ULA); tapping it copies. */
subtitle: String? = null,
) {
val clipboard = LocalClipboardManager.current
Row(
Modifier
.fillMaxWidth()
.height(ROW_H)
// Rows with a second line grow to fit it.
.then(if (subtitle == null) Modifier.height(ROW_H) else Modifier.heightIn(min = ROW_H))
.clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(horizontal = 16.dp),
.padding(horizontal = 16.dp)
.then(if (subtitle == null) Modifier else Modifier.padding(vertical = 8.dp)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Column(Modifier.weight(1f)) {
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
if (subtitle != null) {
Row(
Modifier
.padding(top = 2.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { clipboard.setText(AnnotatedString(subtitle)) }
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
subtitle,
color = TextMuted,
fontSize = 10.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Text("⧉", color = TextMuted.copy(alpha = 0.7f), fontSize = 11.sp, modifier = Modifier.padding(start = 6.dp))
}
}
}
if (onEdit != null) {
Text(
"✎",
@@ -587,7 +724,7 @@ private fun MenuItem(
/** Glass text field with centered input text. */
@Composable
private fun GlassField(
internal fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
@@ -1,14 +1,24 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.os.Process
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.camera2.interop.Camera2Interop
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
@@ -16,22 +26,26 @@ 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.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
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.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -46,10 +60,15 @@ import androidx.compose.runtime.rememberUpdatedState
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
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.compose.ui.viewinterop.AndroidView
@@ -59,23 +78,26 @@ import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
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.PlanarYUVLuminanceSource
import com.google.zxing.common.GlobalHistogramBinarizer
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import kotlinx.coroutines.delay
import java.util.concurrent.Executors
/**
* Full-screen camera overlay that scans the node pairing QR
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
* Handles the camera permission itself; foreign/invalid codes show a hint
* and scanning continues.
* Scans the node pairing QR (docs/companion-pairing-qr.md) and reports the
* decoded server entry. Handles the camera permission itself; foreign/invalid
* codes show a hint in the status strip and scanning continues.
*
* Visually this is the SAME glass modal the web wallet uses (neode-ui's
* WalletScanModal) — scrim, glass card, square preview, orange viewfinder,
* status strip — so pairing from the app and scanning from the web UI look
* like one product rather than two different scanners.
*/
@Composable
fun QrScannerOverlay(
@@ -83,28 +105,14 @@ fun QrScannerOverlay(
onDismiss: () -> Unit,
onServerScanned: (PairResult.Success) -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val haptics = LocalHapticFeedback.current
var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
handled = false
hintRes = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
@@ -116,125 +124,331 @@ fun QrScannerOverlay(
}
}
QrGlassModal(
visible = visible,
title = stringResource(R.string.scan_node_qr),
status = hintRes?.let { stringResource(it) to true },
idleHint = stringResource(R.string.scan_qr_hint),
permissionRationale = stringResource(R.string.camera_permission_needed),
onDismiss = onDismiss,
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
// Confirm the hit in the hand — the eye is still on the
// code, not on the screen.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onServerScanned(result)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
}
/**
* The shared native scanner shell — one visual contract for every camera the
* app opens (pairing, wallet), mirroring neode-ui's WalletScanModal so the
* native and web scanners are indistinguishable:
* - black/60 scrim, dismiss on tap-outside
* - glass card (rounded 24, white/10 hairline) capped at 420dp
* - square preview with the 62% orange viewfinder and a darkened surround
* - a status strip that carries hints and errors
* - an optional footer (the wallet's "Upload image")
*/
@Composable
internal fun QrGlassModal(
visible: Boolean,
title: String,
// message + isError; null falls back to [idleHint].
status: Pair<String, Boolean>?,
idleHint: String,
permissionRationale: String,
onDismiss: () -> Unit,
onDecoded: (String) -> Unit,
footer: @Composable (() -> Unit)? = null,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var torchOn by remember { mutableStateOf(false) }
var hasTorch by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
} else {
torchOn = false
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
onServerScanned(result)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
// Aim frame
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
} else {
Column(
Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = TextPrimary,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
// Top bar: title + close
Row(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_node_qr),
color = TextPrimary,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
}
}
// Bottom hints
Column(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
.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),
) {
hintRes?.let { res ->
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(res),
color = BitcoinOrange,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Spacer(Modifier.height(8.dp))
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
}
if (hasPermission) {
Spacer(Modifier.height(8.dp))
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) {
CameraQrPreview(
onDecoded = onDecoded,
torchOn = torchOn,
onTorchAvailable = { hasTorch = it },
)
// Viewfinder — 62% of the preview, matching the web
// modal's .scan-viewfinder, and matching the ROI the
// decoder actually reads (QR_ROI_FRACTION).
Box(
Modifier
.fillMaxSize(QR_ROI_FRACTION)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
if (hasTorch) {
IconButton(
onClick = { torchOn = !torchOn },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.45f)),
) {
Icon(
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
stringResource(
if (torchOn) R.string.torch_off else R.string.torch_on,
),
tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f),
)
}
}
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = permissionRationale,
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))
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 = stringResource(R.string.scan_qr_hint),
color = TextMuted,
style = MaterialTheme.typography.bodyMedium,
text = status?.first?.takeIf { it.isNotBlank() } ?: idleHint,
style = MaterialTheme.typography.bodySmall,
color = if (status?.second == true) {
Color(0xFFF87171)
} else {
Color.White.copy(alpha = 0.6f)
},
textAlign = TextAlign.Center,
)
}
if (footer != null) {
Spacer(Modifier.height(16.dp))
footer()
}
}
}
}
}
/** Shared by the pairing scanner and the wallet scan modal. */
/**
* Warm the CameraX provider and the ZXing decode path before the user ever
* asks for a scan, so opening the scanner doesn't pay provider init + class
* loading on the critical path. Does NOT open the camera: no permission is
* needed, no LED lights up, nothing is recorded — [ProcessCameraProvider]
* init is process-wide and cached, and the synthetic decode below just walks
* a blank 32x32 frame to class-load the binarizer/detector.
*
* Called once per process from the kiosk WebView (first page load) and by the
* page via `ArchipelagoQr.prewarm()`.
*/
internal fun prewarmQrScanner(context: Context) {
if (!qrPrewarmed.compareAndSet(false, true)) return
val app = context.applicationContext
runCatching { ProcessCameraProvider.getInstance(app) }
// Off the UI thread: the first decode attempt loads a dozen ZXing classes.
Executors.newSingleThreadExecutor().let { exec ->
exec.execute {
runCatching {
val blank = ByteArray(32 * 32)
val reader = MultiFormatReader().apply {
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
}
val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false)
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
}
}
exec.shutdown()
}
}
private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Fraction of the preview's shorter edge that both the on-screen viewfinder
* and the decoder's region of interest use. Keeping them identical is the
* point: the user aims at the box, and the box is exactly what gets decoded.
*/
internal const val QR_ROI_FRACTION = 0.62f
/**
* Shared by the pairing scanner and the wallet scan modal.
*
* [torchOn] drives the flash; [onTorchAvailable] reports whether this camera
* has one at all (the caller only draws its toggle when it does).
*
* ## Why this looks the way it does
*
* The previous version hunted: a scheduled tick alternated the optical zoom
* between 1x and 1.5x and re-fired `startFocusAndMetering(...disableAutoCancel())`
* every 2 seconds. Both are camera-hostile:
*
* - Every zoom step restarts AE/AF convergence, so the sensor spends the
* seconds right after it delivering soft frames — precisely the frames the
* decoder needs to be sharp. The visible symptom is the "zooms in and out
* and takes ages" report.
* - `disableAutoCancel()` leaves AF **locked** at whatever it converged on
* instead of handing the lens back to continuous AF, so a re-aim never
* refocused on its own; the next timer tick then kicked off another full
* sweep from a locked position — a lens that hunts forever.
*
* A stock camera app does neither. It leaves CameraX's continuous AF alone,
* refocuses on tap, and never touches zoom. This does the same, with one
* concession to the "hand-held QR is a static scene" case: if nothing has
* decoded for a few seconds, ONE auto-cancelling focus nudge is issued (and
* then not again for a while), which re-arms continuous AF instead of
* fighting it.
*/
@Composable
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
internal fun CameraQrPreview(
onDecoded: (String) -> Unit,
torchOn: Boolean = false,
onTorchAvailable: (Boolean) -> Unit = {},
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val previewView = remember {
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).
// ignores rounded-corner clipping (the glass modal).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
// Set by the analyzer on every decode; the focus nudge below reads it to
// tell "nothing in view" from "reading fine, leave the camera alone".
val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
// A tap-to-focus wins over the periodic centre AF for a few seconds.
val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
DisposableEffect(Unit) {
val analysisExecutor = Executors.newSingleThreadExecutor()
// Analysis runs at display priority: the decode thread competes with
// the FIPS mesh service's native workers in this same process, and a
// background-priority analyzer is exactly how a sharp, well-framed
// code still takes seconds to land.
val analysisExecutor = Executors.newSingleThreadExecutor { r ->
Thread {
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
r.run()
}.apply { name = "qr-analyzer" }
}
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
@@ -243,15 +457,18 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
providerFuture.addListener({
val p = providerFuture.get()
provider = p
val preview = Preview.Builder().build().also {
val previewBuilder = Preview.Builder()
tuneForBarcodes(previewBuilder, context)
val preview = previewBuilder.build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
// lens, which won't focus close) left dense invoices undecodable
// while sparse address QRs still read — the "scanner doesn't pick up
// invoices" report. 1920x1080 roughly doubles module resolution so a
// QR held at the camera's actual focus distance still resolves.
// sharp focus. 1280x720 left dense invoices undecodable while sparse
// address QRs still read — the "scanner doesn't pick up invoices"
// report. 1920x1080 roughly doubles module resolution. The analyzer
// never binarizes the full 2 MP: it reads the centre ROI at this
// resolution (for dense codes) and the whole frame at half of it
// (for coverage), so the big frame costs little.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1920, 1080))
@@ -260,25 +477,47 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
.also {
it.setAnalyzer(
analysisExecutor,
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
QrCodeAnalyzer { text ->
lastDecodeAt.set(System.currentTimeMillis())
mainExecutor.execute { currentOnDecoded(text) }
},
)
}
try {
p.unbindAll()
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
// Force a centre autofocus on a repeating tick. A hand-held QR is
// a static scene, so continuous-AF often never retriggers and the
// lens sits at its resting (far) focus — fatal for dense codes.
// A normalized centre point works before the view is measured.
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
.createPoint(0.5f, 0.5f)
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
point,
androidx.camera.core.FocusMeteringAction.FLAG_AF,
).disableAutoCancel().build()
camera = cam
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit())
// Start the clock at bind time so the nudge below waits for the
// user to actually aim before it does anything.
lastDecodeAt.set(System.currentTimeMillis())
// Centre point, normalized — valid before the view is measured.
val point = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
// A one-shot AF action puts the lens in AUTO — i.e. LOCKED —
// until it auto-cancels. The default 5s lock is far too long
// here: it spans exactly the window where the user is swinging
// the phone towards the code, and a locked lens cannot follow
// them. Hand control back after 1s so CONTINUOUS_PICTURE (set
// explicitly in tuneForBarcodes) does the real work, which is
// what actually tracks a moving aim.
val focusAction = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
.setAutoCancelDuration(1, java.util.concurrent.TimeUnit.SECONDS)
.build()
var lastNudgeAt = 0L
focusScheduler.scheduleWithFixedDelay({
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
val now = System.currentTimeMillis()
// The nudge only exists for the one case continuous AF
// genuinely misses: the phone held perfectly still on a
// code while the lens sits at its resting focus, with no
// scene change to trigger a sweep.
if (now - lastDecodeAt.get() > 2_000 &&
now - lastNudgeAt > 3_000 &&
now - lastTapFocusAt.get() > 3_000
) {
lastNudgeAt = now
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}
}, 1, 1, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
@@ -286,66 +525,251 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
onDispose {
focusScheduler.shutdownNow()
runCatching { camera?.cameraControl?.enableTorch(false) }
camera = null
provider?.unbindAll()
analysisExecutor.shutdown()
}
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
// Screen-displayed QRs come with moiré, glare, and soft focus at
// close range — the exhaustive search is worth the milliseconds.
DecodeHintType.TRY_HARDER to true,
)
)
// Torch follows the caller's state (and switches off when the view goes).
LaunchedEffect(camera, torchOn) {
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
}
private var lastAttempt = 0L
AndroidView(
factory = { previewView },
modifier = Modifier
.fillMaxSize()
// Tap-to-focus: the ROI assumes the code is centred; a tap lets the
// user point at one that isn't, or re-trigger AF the instant
// they've framed it.
.pointerInput(camera) {
detectTapGestures { offset ->
val cam = camera ?: return@detectTapGestures
val factory = previewView.meteringPointFactory
val action = FocusMeteringAction.Builder(
factory.createPoint(offset.x, offset.y),
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE,
).build()
lastTapFocusAt.set(System.currentTimeMillis())
runCatching { cam.cameraControl.startFocusAndMetering(action) }
}
},
)
}
/**
* Configure the capture session the way a dedicated barcode scanner does,
* rather than the way a photo app does.
*
* The single most valuable knob is **CONTROL_AE_TARGET_FPS_RANGE**. Left
* alone, auto-exposure indoors happily drops the sensor to 10–15 fps and
* takes 60–100 ms exposures — every hand-held frame is then motion-blurred,
* and a blurred QR is not a slow decode, it is *no* decode. The user waves
* the phone about waiting for a lock that cannot happen. Pinning the lower
* bound of the AE range as high as the device allows caps exposure time
* (~33 ms at 30 fps), so frames come out sharp; AE compensates with gain
* instead, and ZXing tolerates noise far better than it tolerates blur.
* (Dark rooms get grainier as a result — that is what the torch button is
* for, and grainy-but-sharp still decodes where smooth-but-smeared never
* does.)
*
* CONTINUOUS_PICTURE is set explicitly so that when a tap-to-focus action
* expires, CameraX restores continuous AF rather than whatever the device
* defaults to; FAST noise/edge processing shaves ISP latency per frame.
*
* All of it is best-effort — an OEM that rejects a key just keeps its default.
*/
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
private fun tuneForBarcodes(builder: Preview.Builder, context: Context) {
runCatching {
val ext = Camera2Interop.Extender(builder)
ext.setCaptureRequestOption(
CaptureRequest.CONTROL_AF_MODE,
CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
)
ext.setCaptureRequestOption(
CaptureRequest.NOISE_REDUCTION_MODE,
CameraMetadata.NOISE_REDUCTION_MODE_FAST,
)
ext.setCaptureRequestOption(
CaptureRequest.EDGE_MODE,
CameraMetadata.EDGE_MODE_FAST,
)
highestSteadyFpsRange(context)?.let {
ext.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
}
}
}
/**
* The back camera's AE range with the highest floor, ignoring anything that
* runs past 30 fps (those are the high-speed/slow-motion modes, which cost
* light for frames we do not need).
*/
private fun highestSteadyFpsRange(context: Context): android.util.Range<Int>? = runCatching {
val manager = context.getSystemService(CameraManager::class.java) ?: return@runCatching null
val backId = manager.cameraIdList.firstOrNull { id ->
manager.getCameraCharacteristics(id)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: return@runCatching null
manager.getCameraCharacteristics(backId)
.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
?.filter { it.upper <= 30 }
?.maxWithOrNull(compareBy({ it.lower }, { it.upper }))
}.getOrNull()
/**
* ZXing decoder over the camera's Y (luminance) plane.
*
* ## The rule this class exists to obey
*
* **Every frame costs the same, and every frame sees the whole scene.**
*
* That sounds obvious; the previous version violated both halves and produced
* a scanner with a very specific failure: it locked on instantly if the code
* was already in view when the camera opened, but crawled if you opened it
* and then moved to the code. The cause was an escalation ladder — each frame
* that failed to decode unlocked progressively more expensive searches, up to
* a TRY_HARDER pass over the full 2 MP frame plus an inverted retry, easily
* 150–300 ms of work.
*
* So the moment the user began hunting for the code, the analyzer dropped from
* ~30 attempts per second to ~4, each one on a motion-blurred frame. By the
* time they framed the code and held still, the pipeline was busy grinding
* through an exhaustive search of an old, blurry frame. Escalating on failure
* is exactly backwards: failure means the user is still aiming, which is when
* the scanner must be at its *fastest*, not its most thorough.
*
* ## What runs now, on every single frame
*
* 1. **Centre ROI at full resolution** ([QR_ROI_FRACTION], ~0.45 MP). Full
* sensor detail, so dense Lightning invoices keep their pixels-per-module.
* 2. **The whole frame at half resolution** (~0.5 MP). This is what fixes the
* "move to the code" case: coverage is no longer limited to the viewfinder
* box on the fast path, so a code that is merely *near* the middle decodes
* immediately instead of waiting for a slow tier to come around. A code
* big enough to be off-centre is big enough to survive the 2x downscale.
* 3. **One alternating second binarizer** — GlobalHistogram over the ROI on
* even frames, over the half-frame on odd ones. Hybrid is tuned for
* shadowed paper; most codes this app scans are on a *screen* (the node's
* pairing popup, another phone's wallet) where a global threshold is both
* cheaper and more reliable. Alternating keeps the per-frame budget flat.
*
* Two rare extras, both bounded so they can never dent the loop above: an
* inverted ROI pass every 8th frame (light-on-dark codes), and one TRY_HARDER
* pass over the half-frame at most once a second (skewed/damaged codes).
*
* Steady-state that is ~35 ms per frame — around 27 attempts per second, and
* it does not degrade the longer the user hunts.
*
* Buffers are allocated once and reused: the original path allocated a fresh
* ~2 MB array per frame, 60 MB/s of garbage at 30 fps, with GC pauses landing
* mid-decode.
*/
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
// QRCodeReader directly rather than MultiFormatReader: with a single
// format in play the dispatch and per-call state reset are pure overhead.
private val reader = QRCodeReader()
private val plainHints = mapOf<DecodeHintType, Any>(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
)
private val hardHints = mapOf<DecodeHintType, Any>(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
)
private var roiBuffer = ByteArray(0)
private var halfBuffer = ByteArray(0)
private var frame = 0L
private var lastHardAt = 0L
private fun read(
source: PlanarYUVLuminanceSource,
global: Boolean = false,
hard: Boolean = false,
inverted: Boolean = false,
): String? {
val src = if (inverted) source.invert() else source
val bitmap = BinaryBitmap(
if (global) GlobalHistogramBinarizer(src) else HybridBinarizer(src),
)
return runCatching {
reader.decode(bitmap, if (hard) hardHints else plainHints).text
}.getOrNull().also { reader.reset() }
}
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 {
val plane = image.planes[0]
val buffer = plane.buffer
// Copy into a rowStride-wide array; the last row of the plane buffer
// may be short of the full stride, so the tail stays zero-padded.
val data = ByteArray(plane.rowStride * image.height)
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
val source = PlanarYUVLuminanceSource(
data, plane.rowStride, image.height,
0, 0, image.width, image.height,
false,
)
val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
// Dark-themed pages can render light-on-dark QRs — retry inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
val stride = plane.rowStride
// YUV_420_888 permits an interleaved Y plane. Rare, but a device
// that does it would otherwise hand the decoder pure noise.
val pixelStride = plane.pixelStride
val width = image.width
val height = image.height
frame++
buffer.rewind()
val available = buffer.remaining()
// ── 1. Centre ROI, full resolution ──────────────────────────────
val side = (minOf(width, height) * QR_ROI_FRACTION).toInt().coerceAtLeast(1)
val left = (width - side) / 2
val top = (height - side) / 2
if (roiBuffer.size != side * side) roiBuffer = ByteArray(side * side)
for (row in 0 until side) {
val srcPos = (top + row) * stride + left * pixelStride
if (srcPos + side * pixelStride > available) break
if (pixelStride == 1) {
buffer.position(srcPos)
buffer.get(roiBuffer, row * side, side)
} else {
val dst = row * side
for (col in 0 until side) {
roiBuffer[dst + col] = buffer.get(srcPos + col * pixelStride)
}
}
}
val roi = PlanarYUVLuminanceSource(roiBuffer, side, side, 0, 0, side, side, false)
read(roi)?.let { onDecoded(it); return }
// ── 2. Whole frame, half resolution ─────────────────────────────
val hw = width / 2
val hh = height / 2
if (halfBuffer.size != hw * hh) halfBuffer = ByteArray(hw * hh)
var truncated = false
for (row in 0 until hh) {
val srcRow = row * 2 * stride
val dst = row * hw
for (col in 0 until hw) {
val srcPos = srcRow + col * 2 * pixelStride
if (srcPos >= available) { truncated = true; break }
halfBuffer[dst + col] = buffer.get(srcPos)
}
if (truncated) break
}
val half = PlanarYUVLuminanceSource(halfBuffer, hw, hh, 0, 0, hw, hh, false)
read(half)?.let { onDecoded(it); return }
// ── 3. Alternating second binarizer ─────────────────────────────
val second = if (frame % 2 == 0L) roi else half
read(second, global = true)?.let { onDecoded(it); return }
// ── Bounded extras ──────────────────────────────────────────────
if (frame % 8 == 0L) {
read(roi, inverted = true)?.let { onDecoded(it); return }
}
val now = System.currentTimeMillis()
if (now - lastHardAt >= 1_000) {
lastHardAt = now
read(half, hard = true)?.let { onDecoded(it); return }
}
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
} catch (_: Exception) {
// Malformed frame; skip it.
} finally {
reader.reset()
image.close()
}
}
@@ -0,0 +1,14 @@
package com.archipelago.app.ui.components
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Cross-layer handoff for remote-signer pairing (#139): NavGraph's
* `nostrconnect://` deep link drops the URI here and routes to the session;
* WebViewScreen collects it, opens the hub menu, and NESMenu opens the
* signer sub-page with the request. Cleared once the signer section has
* consumed it (via NESMenu's onSignerPairHandled).
*/
object SignerLaunch {
val pendingUri = MutableStateFlow<String?>(null)
}
@@ -0,0 +1,381 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.NativeCore
import com.archipelago.app.nostr.BunkerManager
import com.archipelago.app.nostr.NostrSignerPreferences
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Remote Signer (#139) — the hub's SIGNER sub-page (same container as
* Nodes/FIPS). The phone holds a nostr key; a NIP-46 client (the node's
* login QR, any nostrconnect:// app) pairs via [pairUri] or the scanner
* (hosted by NESMenu outside this panel), and every `sign_event` request
* lands as a legible approve/deny card. See
* docs/companion-nip46-remote-signer.md.
*/
@Composable
internal fun SignerSection(
pairUri: String?,
onScan: () -> Unit,
onPairHandled: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
val prefs = remember { NostrSignerPreferences(context) }
var keyInfo by remember { mutableStateOf<JSONObject?>(null) }
var keyError by remember { mutableStateOf<String?>(null) }
var importText by remember { mutableStateOf("") }
var showNsec by remember { mutableStateOf(false) }
var notice by remember { mutableStateOf<String?>(null) }
var noticeError by remember { mutableStateOf(false) }
val bunkerState by BunkerManager.state.collectAsState()
val pending by BunkerManager.pending.collectAsState()
fun say(msg: String, error: Boolean) {
notice = msg
noticeError = error
}
suspend fun loadKey() {
val secret = prefs.secret()
keyInfo = secret?.let {
val json = NativeCore.nostrSecretFromAny(it)
if (NativeCore.isErr(json)) null else JSONObject(json)
}
}
LaunchedEffect(Unit) {
BunkerManager.refreshState(context)
loadKey()
}
// Consume a pairing request (deep link or scanner) exactly once.
LaunchedEffect(pairUri) {
val uri = pairUri?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
if (keyInfo == null) loadKey()
val err = BunkerManager.pair(context, uri)
if (err != null) say(err, true) else say("Pairing started…", false)
onPairHandled()
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionCopy(
"Hold a nostr key on this phone and sign for it remotely — pair with your " +
"node's login QR (or any NIP-46 client), then approve each signature " +
"request as it arrives. Nothing signs without you."
)
if (bunkerState is BunkerManager.SignerState.Unavailable) {
Text(
"Signing is unavailable on this device (native core missing).",
color = Color(0xFFFF6B6B), fontSize = 12.sp,
)
}
val info = keyInfo
if (info == null) {
// ── No key yet: generate or import ──────────────────────────
keyError?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 11.sp) }
WideAction(text = "Generate signer key", onClick = {
scope.launch {
try {
keyInfo = prefs.generateSecret()
keyError = null
BunkerManager.refreshState(context)
} catch (e: Exception) {
keyError = e.message ?: "could not generate a key"
}
}
})
GlassField(
value = importText,
onValueChange = { importText = it },
placeholder = "or import nsec…",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onGo = {
if (importText.isNotBlank()) {
scope.launch {
try {
keyInfo = prefs.importSecret(importText)
importText = ""
keyError = null
BunkerManager.refreshState(context)
} catch (e: Exception) {
keyError = e.message ?: "not a valid nsec"
}
}
}
}),
)
WideAction(text = "Import", onClick = {
if (importText.isBlank()) return@WideAction
scope.launch {
try {
keyInfo = prefs.importSecret(importText)
importText = ""
keyError = null
BunkerManager.refreshState(context)
} catch (e: Exception) {
keyError = e.message ?: "not a valid nsec"
}
}
})
} else {
// ── Identity ─────────────────────────────────────────────────
SectionHeader(Icons.Default.Key, "Signer identity")
MonoValue("npub", info.optString("npub")) {
clipboard.setText(AnnotatedString(info.optString("npub")))
}
if (showNsec) {
MonoValue("nsec", info.optString("nsec"), secret = true) {
clipboard.setText(AnnotatedString(info.optString("nsec")))
}
SectionHint("Anyone with the nsec can sign as you — clear the clipboard after copying.")
} else {
Text(
"Show nsec",
color = TextMuted, fontSize = 11.sp,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { showNsec = true }
.padding(vertical = 2.dp, horizontal = 6.dp),
)
}
// ── Session ──────────────────────────────────────────────────
Spacer(Modifier.height(2.dp))
val label = when (val s = bunkerState) {
BunkerManager.SignerState.Unavailable -> "Unavailable on this device"
BunkerManager.SignerState.NoKey -> "No signer key yet"
BunkerManager.SignerState.Idle -> "Idle — pair to start"
is BunkerManager.SignerState.Connecting -> "Connecting to ${s.relay}…"
is BunkerManager.SignerState.AwaitingClient -> "Paired with \"${s.clientName}\" — waiting for the handshake to finish"
is BunkerManager.SignerState.Ready -> "Ready for \"${s.clientName}\""
is BunkerManager.SignerState.Failed -> s.reason
}
Text("Session", color = TextMuted, fontSize = 11.sp)
Text(
label,
color = if (bunkerState is BunkerManager.SignerState.Failed) Color(0xFFFF6B6B)
else if (bunkerState is BunkerManager.SignerState.Ready) SuccessGreen
else TextPrimary,
fontSize = 13.sp,
lineHeight = 17.sp,
)
WideAction(
text = "Scan pairing QR",
onClick = {
if (bunkerState is BunkerManager.SignerState.NoKey) {
say("Generate or import a signer key first.", true)
return@WideAction
}
onScan()
},
icon = Icons.Default.QrCodeScanner,
)
if (bunkerState is BunkerManager.SignerState.Ready ||
bunkerState is BunkerManager.SignerState.AwaitingClient ||
bunkerState is BunkerManager.SignerState.Connecting
) {
Text(
"End session",
color = TextMuted, fontSize = 11.sp,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { BunkerManager.unpair() }
.padding(vertical = 2.dp, horizontal = 6.dp),
)
}
// ── Pending signature request — the whole point ──────────────
pending?.let { req ->
Spacer(Modifier.height(2.dp))
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.04f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.35f), RoundedCornerShape(14.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Signature request", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold)
SummaryRow("Client", req.clientName.ifBlank { req.clientPubkey.take(12) + "…" })
SummaryRow("Kind", kindLabel(req.kind))
req.createdAt?.let {
SummaryRow("Time", SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(it * 1000)))
}
req.content?.takeIf { it.isNotBlank() }?.let { content ->
Text(
content,
color = TextPrimary, fontSize = 10.sp, lineHeight = 14.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(8.dp)
.heightIn(max = 160.dp),
)
}
if (req.tags.isNotEmpty()) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
req.tags.take(6).forEach {
Text(
it,
color = TextMuted, fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (req.tags.size > 6) {
Text("+${req.tags.size - 6} more", color = TextMuted, fontSize = 9.sp)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier
.weight(1f)
.height(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFE5484D).copy(alpha = 0.16f))
.border(1.dp, Color(0xFFE5484D).copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.clickable { BunkerManager.deny() },
contentAlignment = Alignment.Center,
) { Text("Deny", color = Color(0xFFFF8A8D), fontSize = 13.sp, fontWeight = FontWeight.Bold) }
Box(
Modifier
.weight(1f)
.height(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.2f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
.clickable {
scope.launch {
val ok = BunkerManager.approve()
say(if (ok) "Signed and sent." else "Could not send the signature.", !ok)
}
},
contentAlignment = Alignment.Center,
) { Text("Approve", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold) }
}
}
}
}
notice?.takeIf { it.isNotBlank() }?.let { msg ->
Text(
msg,
color = if (noticeError) Color(0xFFFF6B6B) else SuccessGreen,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
/** Kind number → legible label, so the approve/deny card reads like a sentence. */
private fun kindLabel(kind: Long?): String = when (kind) {
0L -> "Metadata (kind 0)"
1L -> "Text note (kind 1)"
3L -> "Contact list (kind 3)"
4L -> "Direct message (kind 4)"
7L -> "Reaction (kind 7)"
14L -> "Chat message (kind 14)"
22242L -> "Client authentication (kind 22242)"
30078L -> "App-stored data (kind 30078)"
null -> "Unknown kind"
else -> "Kind $kind"
}
/** Monospace value chip with a copy affordance (tap the row). */
@Composable
private fun MonoValue(label: String, value: String, secret: Boolean = false, onCopy: () -> Unit) {
Column(Modifier.fillMaxWidth()) {
Text(label, color = TextMuted, fontSize = 10.sp)
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color.Black.copy(alpha = 0.45f))
.clickable { onCopy() }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
value,
color = if (secret) Color(0xFFFFB86B) else TextPrimary,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}
@@ -0,0 +1,119 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.BitcoinOrange
/** green-400 — the same "done" colour the web install overlay lands on. */
private val DoneGreen = Color(0xFF4ADE80)
/**
* The Archipelago loading bar: a stripe that runs side to side inside a dim
* track and lands as a solid green bar when the work completes.
*
* This is a direct port of the platform's install-progress overlay
* (neode-ui SystemUpdate.vue `.install-overlay-bar-anim`): a third-width
* orange stripe on a white/10 track, 1.8s ease-in-out, going full green on
* success. Using the same loader natively is what makes the companion feel
* like the same product as the node UI rather than a stock Android app.
*
* @param done finished successfully — the bar fills solid green.
* @param stalled waiting on the user / something external — the bar parks
* half-full in a dimmed orange instead of animating, so it
* reads as "this needs you", not "still working".
*/
@Composable
fun SlidingLoader(
modifier: Modifier = Modifier,
done: Boolean = false,
stalled: Boolean = false,
height: Dp = 8.dp,
) {
val doneProgress by animateFloatAsState(
targetValue = if (done) 1f else 0f,
animationSpec = tween(320),
label = "loaderDone",
)
BoxWithConstraints(
modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(percent = 50))
.background(Color.White.copy(alpha = 0.10f)),
) {
val trackWidth = maxWidth
val stripeWidth = trackWidth / 3
val stripePx = with(LocalDensity.current) { stripeWidth.toPx() }
if (doneProgress < 1f) {
if (stalled) {
Box(
Modifier
.fillMaxWidth(0.5f)
.fillMaxHeight()
.clip(RoundedCornerShape(percent = 50))
.background(BitcoinOrange.copy(alpha = 0.6f)),
)
} else {
// Keyframes copied from the web overlay: -100% → 120% → 300%
// of the STRIPE's own width, which is what gives the bar its
// fast sweep out and lazy re-entry.
val transition = rememberInfiniteTransition(label = "loaderSlide")
val offset by transition.animateFloat(
initialValue = -1f,
targetValue = 3f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1800
(-1f) at 0
1.2f at 900
3f at 1800
},
repeatMode = RepeatMode.Restart,
),
label = "loaderOffset",
)
Box(
Modifier
.fillMaxWidth(1f / 3f)
.fillMaxHeight()
.graphicsLayer { translationX = offset * stripePx }
.clip(RoundedCornerShape(percent = 50))
.background(BitcoinOrange),
)
}
}
if (doneProgress > 0f) {
Box(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.graphicsLayer { alpha = doneProgress }
.background(DoneGreen),
)
}
}
}
@@ -1,58 +1,26 @@
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
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
@@ -62,10 +30,10 @@ 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.
* Native replacement for the web wallet's scan pane — the shared [QrGlassModal]
* shell (same visual design as neode-ui's WalletScanModal) with the camera and
* decoding running 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
@@ -80,15 +48,7 @@ fun WalletQrScannerModal(
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 }
val haptics = LocalHapticFeedback.current
// Local error from a failed image upload; a fresh web status replaces it.
var uploadError by remember { mutableStateOf<String?>(null) }
@@ -107,155 +67,50 @@ fun WalletQrScannerModal(
}
}
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(visible) { if (visible) uploadError = null }
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),
)
}
// Throttle repeat frames: a static QR decodes many times a second 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) }
LaunchedEffect(visible) {
if (visible) {
lastText = ""
lastSentAt = 0L
}
}
QrGlassModal(
visible = visible,
title = stringResource(R.string.scan_to_send),
status = uploadError?.let { it to true } ?: status,
idleHint = stringResource(R.string.scan_wallet_hint),
permissionRationale = stringResource(R.string.camera_permission_needed),
onDismiss = onDismiss,
onDecoded = { text ->
val now = System.currentTimeMillis()
if (text != lastText || now - lastSentAt > 250) {
// Buzz on the FIRST hit only: an animated QR streams a new
// frame every few ms, and one buzz each would be a drill in
// the hand.
if (lastText.isEmpty()) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
lastText = text
lastSentAt = now
onDecoded(text)
}
},
footer = {
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. */
@@ -22,16 +22,21 @@ import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.SignerLaunch
import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.NodePickerScreen
import com.archipelago.app.ui.screens.PartyScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object Routes {
const val INTRO = "intro"
const val NODE_PICKER = "node_picker"
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
@@ -39,18 +44,38 @@ object Routes {
const val FLARE = "flare"
}
/**
* Process-scoped "have we already asked which node?" flag.
*
* The picker is a COLD-START question: opening the app fresh (or after the
* mesh service and its process were killed) is exactly when the user may want
* a different node than last time. An Activity recreation inside a live
* process — rotation, theme change — must not re-ask, and neither must a
* simple return from the background, so the flag lives with the process
* rather than in saved state.
*/
private object LaunchGate {
@Volatile
var nodeChoiceMade: Boolean = false
}
@Composable
fun AppNavHost(
pairUri: String? = null,
onPairUriConsumed: () -> Unit = {},
onReady: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val introSeen by prefs.introSeen.collectAsState(initial = null)
val activeServer by prefs.activeServer.collectAsState(initial = null)
// One combined emission — introSeen and activeServer resolving in separate
// frames used to flash the Connect screen at paired users on launch.
val launchState by prefs.launchState.collectAsState(initial = null)
val introSeen = launchState?.introSeen
val activeServer = launchState?.activeServer
val savedServers = launchState?.savedServers ?: emptyList()
// Pairing entry from a deep link that carried no password — prefills the
// connect form so the user lands on the password prompt for that server.
@@ -79,45 +104,78 @@ fun AppNavHost(
}
}
// Paired + previously consented → the mesh comes back silently on launch.
LaunchedEffect(Unit) {
FipsManager.autoStartIfReady(context)
if (introSeen == null) return
// Ask which node when the user keeps more than one and this is a cold
// start. Anything else (single node, mid-process Activity recreation,
// a pairing deep link) goes straight through as before.
val needsNodeChoice = introSeen == true &&
!LaunchGate.nodeChoiceMade &&
savedServers.size > 1
// Paired + previously consented → the mesh comes back silently on launch,
// but ONLY once the session's node is known to be a FIPS node. Bringing
// the tunnel up before that took Android's single VPN slot away from
// whatever the user uses to reach a non-mesh node. Off the main
// dispatcher: this path dlopens the 7 MB fips core and does a binder
// round-trip (VpnService.prepare).
LaunchedEffect(needsNodeChoice, activeServer?.npub, activeServer?.meshIp) {
if (needsNodeChoice) return@LaunchedEffect
if (activeServer?.isFipsNode() != true) return@LaunchedEffect
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
}
if (introSeen == null) return
// Launch state resolved — MainActivity holds the system splash until now,
// so the first visible frame is the real UI, never a black gap.
LaunchedEffect(Unit) { onReady() }
// Declared after the introSeen gate so it can't fire before the NavHost
// below has set the nav graph; pairUri stays pending until consumed here.
LaunchedEffect(pairUri) {
val raw = pairUri ?: return@LaunchedEffect
onPairUriConsumed()
when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
when {
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
// from the node's login QR — any QR scanner app can hand it over.
// The signer UI lives inside the hub menu: drop the URI where
// WebViewScreen picks it up and route to the session, which opens
// the hub on its signer sub-page.
raw.startsWith("nostrconnect://") -> {
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
SignerLaunch.pendingUri.value = raw
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
else -> when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
}
}
val startDestination = when {
introSeen == false -> Routes.INTRO
needsNodeChoice -> Routes.NODE_PICKER
activeServer != null -> Routes.WEB_VIEW
else -> Routes.SERVER_CONNECT
}
@@ -126,6 +184,37 @@ fun AppNavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Routes.NODE_PICKER) {
NodePickerScreen(
servers = savedServers,
lastActive = activeServer,
onPick = { server ->
LaunchGate.nodeChoiceMade = true
scope.launch {
prefs.setActiveServer(server)
// The mesh follows the choice, and ONLY the choice.
// A non-mesh node gets the tunnel taken down: Android
// hands out one VPN slot, and holding it hostage is
// what broke reaching nodes behind a different VPN.
withContext(Dispatchers.IO) {
if (server.isFipsNode()) {
FipsManager.autoStartIfReady(context)
} else {
FipsManager.stopService(context)
}
}
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
}
},
onAddNode = {
LaunchGate.nodeChoiceMade = true
navController.navigate(Routes.SERVER_CONNECT)
},
)
}
composable(Routes.INTRO) {
IntroScreen(
onMeshParty = {
@@ -107,7 +107,11 @@ fun FlareScreen(onBack: () -> Unit) {
}
val peer = peers.firstOrNull { it.npub == selectedNpub }
val messages = allMessages.filter { it.peerNpub == selectedNpub }
// derivedStateOf: filtering inline re-ran over the whole store on every
// recomposition — including one per keystroke in the composer.
val messages by remember(selectedNpub) {
androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } }
}
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
@@ -305,7 +309,13 @@ private fun MessageBubble(msg: FlareMessage) {
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (msg.photoPath.isNotBlank()) {
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
// Decoded off-main and downsampled to the bubble width —
// full-size decode in remember{} ran on the UI thread mid-
// scroll and held ~8 MB per visible photo (OOM territory).
var bmp by remember(msg.photoPath) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(msg.photoPath) {
bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) }
}
bmp?.let {
Image(
bitmap = it.asImageBitmap(),
@@ -336,6 +346,19 @@ private fun MessageBubble(msg: FlareMessage) {
}
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the
* bubble renders at ~300 dp, so the stored 1600 px original is 25× the
* pixels needed. Blocking — call on IO. */
private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
try {
@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -65,9 +66,10 @@ fun IntroScreen(
var showContent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
logoAlpha.animateTo(1f, animationSpec = tween(800))
delay(300)
// Content fades in WITH the logo, not after it — the serial
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
showContent = true
logoAlpha.animateTo(1f, animationSpec = tween(450))
}
Box(
@@ -111,7 +113,9 @@ fun IntroScreen(
contentDescription = "Archipelago",
modifier = Modifier
.size(160.dp)
.alpha(logoAlpha.value),
// graphicsLayer defers the alpha read to the draw phase —
// .alpha(value) recomposed the whole screen per frame.
.graphicsLayer { alpha = logoAlpha.value },
)
Spacer(modifier = Modifier.height(48.dp))
@@ -0,0 +1,226 @@
package com.archipelago.app.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/**
* "Which node?" — shown at launch when more than one node is saved.
*
* The companion used to dive straight back into whichever node was last
* active, which is wrong the moment a user keeps more than one: they arrive
* somewhere they didn't choose, and (worse) the FIPS tunnel came up before
* anyone said which network this session belongs to. Picking first makes the
* choice explicit and lets the mesh stay down for nodes that aren't on it.
*
* [onPick] carries the entry; the caller decides what the mesh does about it.
*/
@Composable
fun NodePickerScreen(
servers: List<ServerEntry>,
lastActive: ServerEntry?,
onPick: (ServerEntry) -> Unit,
onAddNode: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.65f),
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.85f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
) {
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier.size(88.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.pick_node_title),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.pick_node_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
servers.forEach { server ->
NodeCard(
server = server,
isLast = lastActive?.sameNode(server) == true,
onClick = { onPick(server) },
)
}
Spacer(Modifier.height(8.dp))
GlassButton(
text = stringResource(R.string.pick_node_add),
onClick = onAddNode,
modifier = Modifier.fillMaxWidth().height(52.dp),
)
}
}
}
@Composable
private fun NodeCard(
server: ServerEntry,
isLast: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.08f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(
1.dp,
if (isLast) BitcoinOrange.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.1f),
RoundedCornerShape(14.dp),
)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = server.displayName(),
style = MaterialTheme.typography.titleMedium,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(
text = secondary,
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// The one thing that actually changes behaviour on this screen: a mesh
// node brings the FIPS tunnel up, a plain one deliberately does not.
if (server.isFipsNode()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = BitcoinOrange,
)
Spacer(Modifier.width(4.dp))
Text(
text = "FIPS",
color = BitcoinOrange,
fontSize = 11.sp,
letterSpacing = 1.sp,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@@ -123,9 +123,12 @@ fun PartyScreen(
name = prefs.partyName()
// The hotspot/WiFi address can change while this screen is open
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
// Tight only at first (the hotspot-flip window); interface walks
// allocate, so back off once the screen has been open a while.
var round = 0
while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(3_000)
delay(if (round++ < 10) 3_000 else 30_000)
}
}
@@ -138,7 +141,16 @@ fun PartyScreen(
port = PartyQr.PARTY_UDP_PORT,
)
}
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
// QR encode + bitmap fill off the composition: done in remember{} it ran
// on the UI thread PER KEYSTROKE of the name field (the payload embeds the
// name) — a ZXing encode plus a megabyte-plus allocation per character.
// The 250 ms delay is a free debounce via coroutine cancellation.
var qrBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(qrPayload) {
if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect }
if (qrBitmap != null) delay(250)
qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) }
}
BackHandler {
when {
@@ -337,7 +349,12 @@ fun PartyScreen(
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
// Encoded off-main; done in remember{} it dropped the
// overlay's first fade-in frame.
var dlQr by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(Unit) {
dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) }
}
dlQr?.let { bmp ->
Box(
Modifier
@@ -364,7 +381,7 @@ fun PartyScreen(
"…or send the APK file directly",
color = BitcoinOrange,
fontSize = 13.sp,
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp),
)
Spacer(Modifier.height(6.dp))
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
@@ -473,8 +490,9 @@ fun PartyScreen(
}
}
/** Render a QR payload as a bitmap (dark modules on white). */
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
* 240.dp display size at any density; 640 was a third more pixels for nothing. */
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
val matrix = QRCodeWriter().encode(
payload,
BarcodeFormat.QR_CODE,
@@ -494,16 +512,23 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
}
/** Share this install's own APK via the system share sheet — a nearby friend
* gets the companion with no internet at all (Quick Share / Bluetooth). */
private fun shareCompanionApk(context: android.content.Context) {
* gets the companion with no internet at all (Quick Share / Bluetooth).
* The ~27 MB copy runs on IO — inline in the click handler it froze the UI
* for seconds (ANR territory on slow flash). Copied once per install; the
* cached file is reused while its size still matches the source. */
private suspend fun shareCompanionApk(context: android.content.Context) {
try {
val src = java.io.File(context.applicationInfo.sourceDir)
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
val out = java.io.File(dir, "archipelago-companion.apk")
src.copyTo(out, overwrite = true)
val uri = androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", out,
)
val uri = withContext(Dispatchers.IO) {
val src = java.io.File(context.applicationInfo.sourceDir)
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
val out = java.io.File(dir, "archipelago-companion.apk")
if (!out.exists() || out.length() != src.length()) {
src.copyTo(out, overwrite = true)
}
androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", out,
)
}
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
@@ -33,7 +33,6 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -76,6 +75,7 @@ import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.SlidingLoader
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
@@ -86,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -108,6 +109,20 @@ fun ServerConnectScreen(
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
// Warm the mesh tunnel the moment the screen appears — starting it only
// after the LAN probe failed put full tunnel bring-up + session discovery
// inside the user's wait. By connect-tap time it's usually already up.
//
// Only when there is actually a mesh node to warm for, though: raising the
// tunnel on a phone whose saved nodes are all plain HTTP boxes takes
// Android's single VPN slot for nothing.
LaunchedEffect(savedServers.any { it.isFipsNode() }) {
if (savedServers.none { it.isFipsNode() }) return@LaunchedEffect
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
}
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") }
@@ -121,8 +136,13 @@ fun ServerConnectScreen(
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
var manualMode by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) }
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
// Is the connect currently running aimed at a mesh node? Drives whether
// the loader wears the FIPS brand — see MeshLoadingScreen.
var connectingOverMesh by remember { mutableStateOf(false) }
var connectingName by remember { mutableStateOf("") }
// Brief green landing on the loader before the kiosk takes over, matching
// the platform's install overlay.
var connectSucceeded by remember { mutableStateOf(false) }
fun clearForm() {
name = ""
@@ -171,40 +191,60 @@ fun ServerConnectScreen(
}
isConnecting = true
errorMessage = null
connectingOverMesh = server.isFipsNode()
connectingName = server.displayName()
connectSucceeded = false
scope.launch {
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
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
// started (the off-LAN QR-pairing case, exactly where speed shows).
// 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()) {
// the mesh. Mesh discovery + first session can take 15s+ through
// the public tree (per node diagnosis), and on a
// first-ever pairing the VPN consent dialog is on screen at the
// same time — so the mesh side keeps probing inside its budget
// while the tunnel (already started at screen entry, and kicked
// again here) warms up underneath.
val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let {
FipsManager.autoStartIfReady(context)
val meshServer = server.copy(
address = server.meshIp,
useHttps = false,
port = "",
)
// Mesh discovery + first session can take 15s+ through the
// public tree (per node diagnosis), and on a
// first-ever pairing the VPN consent dialog is on screen at
// the same time — so probe patiently inside a 60s budget with
// per-attempt timeouts wide enough to ride out TCP
// retransmit backoff. The VPN service pre-warms the session
// in parallel (ArchyVpnService.startSessionWarmer).
val deadline = System.currentTimeMillis() + 60_000
while (!reachable && System.currentTimeMillis() < deadline) {
reachable = testConnection(meshServer, timeoutMs = 15_000)
if (!reachable) delay(3000)
server.copy(address = it, useHttps = false, port = "")
}
val reachable = kotlinx.coroutines.coroutineScope {
val lan = async { testConnection(server, timeoutMs = 4_000) }
val mesh = async {
if (meshServer == null) return@async false
val deadline = System.currentTimeMillis() + 45_000
var ok = false
while (!ok && System.currentTimeMillis() < deadline) {
ok = testConnection(meshServer, timeoutMs = 8_000)
if (!ok) delay(2000)
}
ok
}
val first = kotlinx.coroutines.selects.select<Boolean> {
lan.onAwait { it }
mesh.onAwait { it }
}
if (first) {
lan.cancel(); mesh.cancel()
true
} else {
// One side gave up — the verdict is whatever the other says.
if (lan.isCompleted) mesh.await() else lan.await()
}
}
isConnecting = false
if (reachable) {
// Land the loader green before handing over, so the last thing
// seen is "done", not a bar cut mid-sweep.
connectSucceeded = true
prefs.setActiveServer(server)
delay(320)
isConnecting = false
onConnected(server.toUrl())
} else {
isConnecting = false
errorMessage = context.getString(R.string.connection_failed)
}
}
@@ -293,7 +333,7 @@ fun ServerConnectScreen(
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
text = if (editingServer != null) stringResource(R.string.edit_server_title) else stringResource(R.string.connect_to_node),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
@@ -577,10 +617,9 @@ fun ServerConnectScreen(
}
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White.copy(alpha = 0.6f),
strokeWidth = 2.dp,
SlidingLoader(
modifier = Modifier.fillMaxWidth(),
done = connectSucceeded,
)
}
@@ -617,7 +656,11 @@ fun ServerConnectScreen(
// establishing (LAN probe → tunnel up → ULA probe can take a while).
// The small inline spinner stays for context; this owns the screen.
if (isConnecting) {
MeshLoadingScreen()
MeshLoadingScreen(
mesh = connectingOverMesh,
nodeName = connectingName,
done = connectSucceeded,
)
}
}
}
@@ -686,6 +729,17 @@ private fun sanitizeAddress(input: String): String {
.trimEnd('/')
}
// Built once — the connect loop probed up to 20 times, and each attempt was
// paying a fresh SSLContext + SecureRandom init.
private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
* patience than LAN ones (first session through the tree can take 15s+). */
@@ -697,14 +751,7 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000):
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
if (connection is HttpsURLConnection) {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
connection.sslSocketFactory = sc.socketFactory
connection.sslSocketFactory = trustAllSslFactory
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
File diff suppressed because it is too large Load Diff
@@ -2,56 +2,95 @@ package com.archipelago.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
/**
* The platform's brand face. neode-ui sets `font-archipelago: Montserrat` and
* uses it for every heading, title and button label, with body copy left to
* `Avenir Next, system-ui` — which on Android resolves to the system sans
* anyway. Mirroring that split exactly is what makes companion text read as
* the same product as the node UI.
*
* Montserrat is SIL OFL 1.1 (see Android/MONTSERRAT-OFL.txt); the files are
* the ones already vendored for the web UI, so both halves ship the same
* outlines.
*/
val Montserrat = FontFamily(
Font(R.font.montserrat_medium, FontWeight.Medium),
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
Font(R.font.montserrat_bold, FontWeight.Bold),
Font(R.font.montserrat_extrabold, FontWeight.ExtraBold),
)
val Typography = Typography(
// ── Display / headings: Montserrat, tight and heavy like the web hero
// copy (the platform sets tracking negative on its big type).
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontFamily = Montserrat,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = (-0.5).sp,
letterSpacing = (-0.8).sp,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontFamily = Montserrat,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = (-0.5).sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontFamily = Montserrat,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = (-0.4).sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = (-0.2).sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
// ── Body: system sans, exactly as the web falls back to.
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
letterSpacing = 0.2.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
letterSpacing = 0.1.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
),
// ── Buttons / labels: Montserrat again, matching .glass-button.
labelLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
@@ -1,36 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago pixel-art "A" for splash screen -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<!-- System splash icon — deliberately the SAME mark as the adaptive launcher
icon (ic_launcher_background.xml): dark disc + metallic ring + white
Archipelago grid. Tapping the icon and watching the splash should show
one badge, not two different logos.
Geometry is copied from the launcher: the Android 12 splash draws its icon
on a 288dp canvas whose inner 2/3 is the safe area — the same 0.667 ratio
the adaptive-icon mask uses — so the launcher's 0.65 (ring) / 0.55 (grid)
group scales land identically here. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="288dp"
android:height="288dp"
android:viewportWidth="752"
android:viewportHeight="752">
<!-- Dark disc + gradient ring (#000 -> #666), matching logo.svg -->
<group
android:pivotX="512"
android:pivotY="512"
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.65"
android:scaleY="0.65">
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0.000976562">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
</group>
<!-- White Archipelago grid -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.55"
android:scaleY="0.55">
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</group>
</vector>
Binary file not shown.
Binary file not shown.
@@ -49,4 +49,12 @@
<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>
<string name="torch_on">Turn on the torch</string>
<string name="torch_off">Turn off the torch</string>
<!-- Launch node picker (more than one node saved) -->
<string name="pick_node_title">Which node?</string>
<string name="pick_node_hint">Choose the Archipelago this session connects to. The FIPS mesh only comes up for mesh nodes.</string>
<string name="pick_node_add">Add another node</string>
<string name="connect_to_node">Connect to your node</string>
</resources>
+369 -1
View File
@@ -12,6 +12,17 @@ dependencies = [
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -81,17 +92,41 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
name = "archy-fips-core"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"argon2",
"base64",
"bech32",
"cbc",
"chacha20 0.9.1",
"chacha20poly1305",
"fips",
"getrandom 0.2.17",
"hex",
"hkdf",
"hmac",
"jni",
"libc",
"paranoid-android",
"secp256k1 0.29.1",
"serde_json",
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
@@ -124,6 +159,18 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bech32"
version = "0.11.1"
@@ -172,6 +219,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -181,6 +237,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.6.2"
@@ -200,6 +265,15 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.3.0"
@@ -406,6 +480,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -476,7 +561,7 @@ dependencies = [
"libc",
"rand 0.10.2",
"rtnetlink",
"secp256k1",
"secp256k1 0.30.0",
"serde",
"serde_json",
"serde_yaml",
@@ -491,6 +576,15 @@ dependencies = [
"tun",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.33"
@@ -676,6 +770,110 @@ dependencies = [
"digest",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -692,6 +890,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -788,6 +987,12 @@ dependencies = [
"libc",
]
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "log"
version = "0.4.33"
@@ -954,12 +1159,29 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -988,6 +1210,15 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1129,6 +1360,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"secp256k1-sys",
]
[[package]]
name = "secp256k1"
version = "0.30.0"
@@ -1272,6 +1512,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -1306,6 +1552,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1355,6 +1612,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -1519,6 +1786,24 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1657,6 +1942,35 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.55"
@@ -1677,12 +1991,66 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "zmij"
version = "1.0.23"
+29
View File
@@ -37,6 +37,35 @@ tracing = "0.1"
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
libc = "0.2"
# ── Companion backup (#128) ───────────────────────────────────────────────
# ADR-005 envelope: the SAME crates and blob layout as the node's backup code
# (core/archipelago/src/backup/identity.rs) — Argon2id KDF + ChaCha20-Poly1305
# AEAD — applied to the companion's own JSON payload. Do not diverge from
# those parameters: a companion backup and a node backup must decrypt with
# the same code path on either side.
argon2 = "0.5"
chacha20poly1305 = "0.10"
base64 = "0.22"
# ── NIP-46 remote signer (#139) ───────────────────────────────────────────
# BIP340 schnorr signing + secp256k1 ECDH (NIP-44/NIP-04 conversation keys).
# Audited libsecp256k1 via cc; cargo-ndk provides the NDK clang on Android.
secp256k1 = "0.29"
# NIP-44 v2: HKDF-SHA256 (conversation/message keys) + HMAC-SHA256 (MAC).
sha2 = "0.10"
hmac = "0.12"
hkdf = "0.12"
# NIP-44 v2 stream cipher (raw ChaCha20, RFC 8439 — NOT the AEAD).
chacha20 = "0.9"
# NIP-04 fallback (deprecated in the spec but still sent by real clients):
# AES-256-CBC, key = raw ECDH x-coordinate.
aes = "0.8"
cbc = { version = "0.1", features = ["alloc"] }
# npub/nsec (bech32, BIP173 variant — NOT Bech32m).
bech32 = "0.11"
# nostrconnect:// URI parsing (repeated relay params + percent-decoding).
url = "2.5"
# The JNI surface only exists on Android; host builds skip it and drive the
# mesh module directly (tests).
[target.'cfg(target_os = "android")'.dependencies]
+246
View File
@@ -0,0 +1,246 @@
//! Companion app backup — the ADR-005 encrypted-backup envelope.
//!
//! Reuses the node's backup format exactly (ADR-005:
//! `core/archipelago/src/backup/identity.rs`): Argon2id key derivation with
//! default params, ChaCha20-Poly1305 AEAD, and the same blob layout
//! `base64(salt[16] || nonce[12] || ciphertext)`. A companion backup and a
//! node backup share one crypto story — the payload differs (the companion
//! serializes its servers, FIPS identity and signer key instead of a node
//! key), the envelope does not.
//!
//! The envelope is JSON with `version`, `kind`, `encrypted`, `blob` and
//! `timestamp`; [`decrypt`] ignores any extra fields, so node envelopes
//! (which carry `did`/`pubkey`/`kid`) decrypt here too.
use anyhow::{bail, Context, Result};
use argon2::Argon2;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use serde_json::json;
/// Envelope version. Bump only when the blob layout itself changes — and
/// then only with a reader for the old layout (same policy as the node).
const BACKUP_VERSION: u32 = 1;
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
/// Encrypt a JSON payload into an ADR-005 envelope.
///
/// The passphrase never leaves this call; the envelope carries only the
/// salt (Argon2id parameter), the AEAD nonce, and the ciphertext.
pub fn encrypt(payload: &str, passphrase: &str) -> Result<String> {
if payload.is_empty() {
bail!("backup payload is empty");
}
if passphrase.is_empty() {
bail!("backup passphrase must not be empty");
}
let mut salt = [0u8; SALT_LEN];
let mut nonce = [0u8; NONCE_LEN];
// Same CSPRNG discipline as identity generation (getrandom, see mesh.rs):
// OS RNG, never thread-local or derived-from-content randomness for key
// material or nonces.
getrandom::getrandom(&mut salt).context("OS RNG")?;
getrandom::getrandom(&mut nonce).context("OS RNG")?;
let key = derive_key(passphrase, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), payload.as_bytes())
.map_err(|_| anyhow::anyhow!("encryption failed"))?;
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
blob.extend_from_slice(&salt);
blob.extend_from_slice(&nonce);
blob.extend_from_slice(&ciphertext);
Ok(json!({
"version": BACKUP_VERSION,
"kind": "companion",
"encrypted": true,
"blob": BASE64.encode(&blob),
"timestamp": chrono_like_now(),
})
.to_string())
}
/// Decrypt an ADR-005 envelope back into its JSON payload.
///
/// Accepts `version: 1` envelopes regardless of `kind` or extra fields —
/// the node's identity backups use the same blob, and being able to decrypt
/// one here is free interop (the caller decides what to do with it).
pub fn decrypt(envelope: &str, passphrase: &str) -> Result<String> {
let obj: serde_json::Value =
serde_json::from_str(envelope).context("not a JSON backup envelope")?;
if obj.get("version").and_then(|v| v.as_u64()) != Some(BACKUP_VERSION as u64) {
bail!("unsupported backup version (expected {BACKUP_VERSION})");
}
let blob_b64 = obj
.get("blob")
.and_then(|v| v.as_str())
.context("missing 'blob' in backup envelope")?;
let blob = BASE64
.decode(blob_b64)
.context("invalid base64 in backup blob")?;
if blob.len() < SALT_LEN + NONCE_LEN {
bail!("backup blob too short");
}
let salt = &blob[..SALT_LEN];
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
let key = derive_key(passphrase, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce), ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong passphrase or corrupted backup"))?;
String::from_utf8(plaintext).context("decrypted payload is not valid UTF-8")
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
let mut key = [0u8; KEY_LEN];
Argon2::default()
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {e}"))?;
Ok(key)
}
/// RFC 3339 UTC timestamp without pulling chrono into the .so — the node's
/// envelope field is informational (display), not part of the authenticated
/// or derived material.
fn chrono_like_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let rem = secs % 86_400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
// Civil-from-days (Howard Hinnant's algorithm), valid for 1970-2100+.
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
const PAYLOAD: &str = r#"{"app":"archipelago-companion","servers":["192.168.1.10|false|1301||Lab Node|fd00::1|npub1abc"]}"#;
#[test]
fn round_trip() {
let envelope = encrypt(PAYLOAD, "correct horse battery staple").unwrap();
let decrypted = decrypt(&envelope, "correct horse battery staple").unwrap();
assert_eq!(decrypted, PAYLOAD);
}
#[test]
fn wrong_passphrase_fails() {
let envelope = encrypt(PAYLOAD, "right").unwrap();
let err = decrypt(&envelope, "wrong").unwrap_err();
assert!(
err.to_string().contains("wrong passphrase"),
"error should name the likely cause: {err}"
);
}
#[test]
fn envelope_shape_matches_node_format() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
assert_eq!(obj["version"], 1);
assert_eq!(obj["encrypted"], true);
assert!(obj["kind"].as_str().is_some());
assert!(obj["timestamp"].as_str().is_some());
// Blob layout is exactly the node's: base64(salt||nonce||ct) with the
// AEAD tag inside the ciphertext — at least 16+12+16+1 bytes.
let blob = BASE64
.decode(obj["blob"].as_str().unwrap())
.expect("blob is base64");
assert!(blob.len() >= SALT_LEN + NONCE_LEN + 16 + PAYLOAD.len());
}
#[test]
fn fresh_salt_and_nonce_every_time() {
let a = encrypt(PAYLOAD, "pw").unwrap();
let b = encrypt(PAYLOAD, "pw").unwrap();
let (oa, ob): (serde_json::Value, serde_json::Value) = (
serde_json::from_str(&a).unwrap(),
serde_json::from_str(&b).unwrap(),
);
assert_ne!(oa["blob"], ob["blob"], "salt/nonce must never repeat");
}
#[test]
fn tampered_blob_fails_to_decrypt() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
let blob = BASE64.decode(obj["blob"].as_str().unwrap()).unwrap();
let mut tampered = blob.clone();
// Flip a bit inside the ciphertext (past salt+nonce).
tampered[SALT_LEN + NONCE_LEN] ^= 0x01;
obj["blob"] = serde_json::Value::String(BASE64.encode(&tampered));
assert!(decrypt(&obj.to_string(), "pw").is_err());
}
/// Node identity backups use the same blob layout but carry their own
/// envelope fields (did/pubkey/kid). Decrypt must ignore those extras —
/// one envelope reader, two producers.
#[test]
fn node_style_envelope_with_extra_fields_decrypts() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
obj["kind"] = serde_json::Value::String("node-identity".into());
obj["did"] = serde_json::Value::String("did:key:z6Mktest".into());
obj["pubkey"] = serde_json::Value::String("aabbcc".into());
obj["kid"] = serde_json::Value::String("did:key:z6Mktest#key-1".into());
let decrypted = decrypt(&obj.to_string(), "pw").unwrap();
assert_eq!(decrypted, PAYLOAD);
}
#[test]
fn rejects_unknown_version_and_garbage() {
let err = decrypt("{\"version\":99,\"blob\":\"AAAA\"}", "pw").unwrap_err();
assert!(err.to_string().contains("version"));
assert!(decrypt("not json", "pw").is_err());
assert!(decrypt("{\"version\":1}", "pw").is_err());
}
#[test]
fn rejects_empty_passphrase_and_payload() {
assert!(encrypt(PAYLOAD, "").is_err());
assert!(encrypt("", "pw").is_err());
}
#[test]
fn timestamp_is_rfc3339_utc() {
let envelope = encrypt(PAYLOAD, "pw").unwrap();
let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
let ts = obj["timestamp"].as_str().unwrap();
// 2026-08-31T12:34:56Z — 20 chars, RFC 3339 UTC.
assert_eq!(ts.len(), 20);
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[10..11], "T");
assert!(ts.starts_with("20"));
}
}
+177 -2
View File
@@ -1,5 +1,6 @@
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
//! JNI surface for `com.archipelago.app.fips.FipsNative` and
//! `com.archipelago.app.NativeCore` — JSON over strings, no codegen (the
//! myco / nostr-vpn embedding pattern). Errors come back as
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
use std::sync::Once;
@@ -127,3 +128,177 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
) -> jstring {
out(&env, mesh::status_json())
}
// ─────────────────────────────────────────────────────────────────────────────
// com.archipelago.app.NativeCore — companion backup (#128) and NIP-46 remote
// signer crypto (#139). Same library, JSON-over-strings contract.
// ─────────────────────────────────────────────────────────────────────────────
/// Kotlin: `external fun backupEncrypt(payload: String, passphrase: String): String`
/// Returns the ADR-005 envelope JSON or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_backupEncrypt(
mut env: JNIEnv,
_class: JClass,
payload: JString,
passphrase: JString,
) -> jstring {
init_logging();
let payload = jstr(&mut env, &payload);
let passphrase = jstr(&mut env, &passphrase);
let json = match crate::backup::encrypt(&payload, &passphrase) {
Ok(envelope) => envelope,
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun backupDecrypt(envelope: String, passphrase: String): String`
/// Returns the decrypted payload JSON or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_backupDecrypt(
mut env: JNIEnv,
_class: JClass,
envelope: JString,
passphrase: JString,
) -> jstring {
init_logging();
let envelope = jstr(&mut env, &envelope);
let passphrase = jstr(&mut env, &passphrase);
let json = match crate::backup::decrypt(&envelope, &passphrase) {
Ok(payload) => payload,
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun nostrGenerateSecret(): String`
/// Returns `{"secret": hex, "pubkey": hex, "npub": …, "nsec": …}` or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrGenerateSecret(
env: JNIEnv,
_class: JClass,
) -> jstring {
init_logging();
let json = match crate::nostr::generate_secret() {
Ok(secret) => nostr_key_info_json(&secret),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun nostrSecretFromAny(secret: String): String`
/// Accepts hex or `nsec…`; returns key-info JSON or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSecretFromAny(
mut env: JNIEnv,
_class: JClass,
secret: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let json = match crate::nostr::secret_from_any(&secret) {
Ok(hex) => nostr_key_info_json(&hex),
Err(e) => err_json(e),
};
out(&env, json)
}
fn nostr_key_info_json(secret_hex: &str) -> String {
match (
crate::nostr::pubkey_hex(secret_hex),
crate::nostr::npub_from_pubkey(&crate::nostr::pubkey_hex(secret_hex).unwrap_or_default()),
crate::nostr::nsec_from_secret(secret_hex),
) {
(Ok(pubkey), Ok(npub), Ok(nsec)) => serde_json::json!({
"secret": secret_hex,
"pubkey": pubkey,
"npub": npub,
"nsec": nsec,
})
.to_string(),
(e, _, _) => err_json(e.unwrap_err()),
}
}
/// Kotlin: `external fun nostrParseConnectUri(uri: String): String`
/// Returns the parsed URI fields or `{"error": …}`.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrParseConnectUri(
mut env: JNIEnv,
_class: JClass,
uri: JString,
) -> jstring {
init_logging();
let uri = jstr(&mut env, &uri);
let json = match crate::nostr::parse_connect_uri(&uri) {
Ok(info) => info.to_json().to_string(),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun nostrSignEvent(secretHex: String, eventJson: String): String`
/// Returns the signed event JSON or `{"error": …}`. The approve/deny decision
/// is made in Kotlin BEFORE this is called — native code never signs unasked.
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSignEvent(
mut env: JNIEnv,
_class: JClass,
secret_hex: JString,
event_json: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret_hex);
let event = jstr(&mut env, &event_json);
let json = match crate::nostr::sign_event(&secret, &event) {
Ok(signed) => signed,
Err(e) => err_json(e),
};
out(&env, json)
}
macro_rules! nostr_cipher {
($name:ident, $doc:literal, $fn:path) => {
#[doc = $doc]
#[no_mangle]
pub extern "system" fn $name(
mut env: JNIEnv,
_class: JClass,
secret_hex: JString,
peer_pub: JString,
text: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret_hex);
let peer = jstr(&mut env, &peer_pub);
let text = jstr(&mut env, &text);
let json = match $fn(&secret, &peer, &text) {
Ok(out) => serde_json::json!({ "result": out }).to_string(),
Err(e) => err_json(e),
};
out(&env, json)
}
};
}
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip44Encrypt,
"Kotlin: `external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String` — returns `{\"result\": payload}` or `{\"error\": …}`.",
crate::nostr::nip44_encrypt
);
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip44Decrypt,
"Kotlin: `external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String`",
crate::nostr::nip44_decrypt
);
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip04Encrypt,
"Kotlin: `external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String`",
crate::nostr::nip04_encrypt
);
nostr_cipher!(
Java_com_archipelago_app_NativeCore_nostrNip04Decrypt,
"Kotlin: `external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String`",
crate::nostr::nip04_decrypt
);
+2
View File
@@ -11,7 +11,9 @@
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
pub mod backup;
pub mod mesh;
pub mod nostr;
#[cfg(target_os = "android")]
mod jni_glue;
+824
View File
@@ -0,0 +1,824 @@
//! NIP-46 phone-side remote signer ("bunker") crypto core.
//!
//! Everything that must be constant-time correct for the companion to act as
//! a nostr remote signer: key handling (nsec/npub bech32), BIP340 schnorr
//! event signing, NIP-44 v2 payload encryption (the mandated NIP-46
//! transport), NIP-04 fallback decryption (deprecated, but real clients
//! still speak it), and `nostrconnect://` URI parsing. The protocol session
//! — relay WebSocket, JSON-RPC dispatch, approve/deny UX — lives in Kotlin;
//! this module is the crypto and nothing but.
//!
//! Verified against the official NIP-44 vectors and BIP-340 reference
//! vectors (see tests below).
use anyhow::{bail, Context, Result};
use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE as BASE64_URL};
use base64::Engine;
use bech32::{Bech32, Hrp};
use chacha20::cipher::{KeyIvInit, StreamCipher};
use chacha20::ChaCha20;
use hmac::{Hmac, Mac};
use hkdf::Hkdf;
use secp256k1::ecdh;
use secp256k1::schnorr::Signature;
use secp256k1::{
Keypair, Message, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey,
};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const NIP44_VERSION: u8 = 2;
const NIP44_SALT: &[u8] = b"nip44-v2";
const NIP44_MIN_PAYLOAD_LEN: usize = 99; // 1 ver + 32 nonce + 32 ct + 32 mac
const NIP44_MIN_B64_LEN: usize = 132;
// ── keys ──────────────────────────────────────────────────────────────────
/// Generate a fresh nostr secret key (hex) from the OS CSPRNG.
pub fn generate_secret() -> Result<String> {
loop {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).context("OS RNG")?;
// Reject zero and >= curve order — the valid scalar range (mirrors
// the mesh identity loop; rejection is astronomically unlikely).
if bytes.iter().all(|&b| b == 0) {
continue;
}
if SecretKey::from_slice(&bytes).is_ok() {
return Ok(hex::encode(bytes));
}
}
}
/// Parse a secret key from hex or bech32 `nsec…` form into hex.
pub fn secret_from_any(s: &str) -> Result<String> {
let s = s.trim();
if s.starts_with("nsec") {
return secret_from_nsec(s);
}
let bytes = hex::decode(s.trim()).context("secret key must be hex or nsec")?;
let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?;
Ok(hex::encode(sk.secret_bytes()))
}
pub fn secret_from_nsec(nsec: &str) -> Result<String> {
let (hrp, data) = bech32::decode(nsec).context("bad nsec encoding")?;
if hrp.as_str() != "nsec" {
bail!("not an nsec");
}
let sk = SecretKey::from_slice(&data).context("invalid nostr secret key")?;
Ok(hex::encode(sk.secret_bytes()))
}
pub fn nsec_from_secret(secret_hex: &str) -> Result<String> {
let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
let hrp = Hrp::parse("nsec").context("nsec hrp")?;
bech32::encode::<Bech32>(hrp, &bytes).context("nsec encoding")
}
/// x-only public key (hex) for a secret key.
/// NOTE: `Keypair::public_key()` in secp256k1 0.29 is the full compressed
/// (33-byte) key — nostr uses x-only pubkeys, so serialize `.x_only_public_key().0`.
pub fn pubkey_hex(secret_hex: &str) -> Result<String> {
let kp = keypair(secret_hex)?;
Ok(hex::encode(kp.public_key().x_only_public_key().0.serialize()))
}
pub fn npub_from_pubkey(pub_hex: &str) -> Result<String> {
let bytes = hex::decode(pub_hex.trim()).context("bad pubkey hex")?;
let hrp = Hrp::parse("npub").context("npub hrp")?;
bech32::encode::<Bech32>(hrp, &bytes).context("npub encoding")
}
/// Parse an x-only pubkey from hex or bech32 `npub…` form into hex.
pub fn pubkey_from_any(s: &str) -> Result<String> {
let s = s.trim();
let bytes = if s.starts_with("npub") {
let (hrp, data) = bech32::decode(s).context("bad npub encoding")?;
if hrp.as_str() != "npub" {
bail!("not an npub");
}
data
} else {
hex::decode(s).context("pubkey must be hex or npub")?
};
XOnlyPublicKey::from_slice(&bytes).context("invalid x-only pubkey")?;
Ok(hex::encode(bytes))
}
fn keypair(secret_hex: &str) -> Result<Keypair> {
let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?;
Ok(Keypair::from_secret_key(&Secp256k1::new(), &sk))
}
// ── nostrconnect:// URI ───────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct ConnectUri {
/// The client's pubkey, hex.
pub client_pubkey: String,
/// Relays the client is listening on (≥1 by spec; kept in URI order).
pub relays: Vec<String>,
/// One-time pairing secret the client expects to see echoed back.
pub secret: String,
/// Comma-separated permission grants the client requests (display hint
/// only — approval always stays with the human).
pub perms: Vec<String>,
pub name: String,
pub url: String,
pub image: String,
}
impl ConnectUri {
/// JSON shape for the JNI boundary (flat strings/arrays — easy to parse
/// with org.json on the Kotlin side).
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"clientPubkey": self.client_pubkey,
"relays": self.relays,
"secret": self.secret,
"perms": self.perms,
"name": self.name,
"url": self.url,
"image": self.image,
})
}
}
/// Parse `nostrconnect://<client-pubkey>?relay=…&secret=…&perms=…&name=…`.
///
/// Query values are percent-decoded; `relay` may repeat. The pubkey in the
/// host position may be hex or (non-spec but harmless) `npub…`.
pub fn parse_connect_uri(uri: &str) -> Result<ConnectUri> {
let uri = uri.trim();
let rest = uri
.strip_prefix("nostrconnect://")
.ok_or_else(|| anyhow::anyhow!("not a nostrconnect:// URI"))?;
let (host, query) = match rest.split_once('?') {
Some((h, q)) => (h, q),
None => bail!("nostrconnect URI has no query parameters"),
};
let client_pubkey = pubkey_from_any(host).context("nostrconnect URI: bad client pubkey")?;
let mut relays = Vec::new();
let mut secret = String::new();
let mut perms: Vec<String> = Vec::new();
let mut name = String::new();
let mut url = String::new();
let mut image = String::new();
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
let v = v.into_owned();
match k.as_ref() {
"relay" => {
if v.starts_with("ws://") || v.starts_with("wss://") {
relays.push(v);
}
}
"secret" => secret = v,
"perms" => perms = v.split(',').filter(|s| !s.is_empty()).map(String::from).collect(),
"name" => name = v,
"url" => url = v,
"image" => image = v,
_ => {} // forward-compat: ignore unknown params
}
}
if relays.is_empty() {
bail!("nostrconnect URI carries no relay");
}
if secret.is_empty() {
bail!("nostrconnect URI carries no secret");
}
Ok(ConnectUri {
client_pubkey,
relays,
secret,
perms,
name,
url,
image,
})
}
// ── events (NIP-01 id + BIP340 signature) ─────────────────────────────────
/// Compute the NIP-01 event id: sha256 over the compact serialization
/// `[0, pubkey, created_at, kind, tags, content]`.
fn event_id(pubkey: &str, created_at: u64, kind: u64, tags: &serde_json::Value, content: &str) -> [u8; 32] {
let serialized = serde_json::json!([
0,
pubkey,
created_at,
kind,
tags,
content,
]);
let mut hasher = Sha256::new();
hasher.update(serialized.to_string().as_bytes());
hasher.finalize().into()
}
/// Sign an unsigned event `{kind, content, tags, created_at}` (pubkey filled
/// from the secret key; `pubkey` in the input ignored) and return the signed
/// event JSON. This is the `sign_event` NIP-46 method's core — the approve
/// happens before this call, never inside it.
pub fn sign_event(secret_hex: &str, event_json: &str) -> Result<String> {
let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?;
let kind = ev
.get("kind")
.and_then(|v| v.as_u64())
.context("event has no kind")?;
let created_at = ev
.get("created_at")
.and_then(|v| v.as_u64())
.context("event has no created_at")?;
let tags = ev
.get("tags")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
let content = ev
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let kp = keypair(secret_hex)?;
let pubkey = hex::encode(kp.public_key().x_only_public_key().0.serialize());
let id = event_id(&pubkey, created_at, kind, &tags, &content);
let mut aux = [0u8; 32];
getrandom::getrandom(&mut aux).context("OS RNG")?;
let sig = Secp256k1::new().sign_schnorr_with_aux_rand(
&Message::from_digest(id),
&kp,
&aux,
);
Ok(serde_json::json!({
"id": hex::encode(id),
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": tags,
"content": content,
"sig": hex::encode(sig.serialize()),
})
.to_string())
}
/// Verify a signed event's id and schnorr signature (tests + defensive use).
pub fn verify_event(event_json: &str) -> Result<()> {
let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?;
let pubkey = ev.get("pubkey").and_then(|v| v.as_str()).context("no pubkey")?;
let id_hex = ev.get("id").and_then(|v| v.as_str()).context("no id")?;
let sig_hex = ev.get("sig").and_then(|v| v.as_str()).context("no sig")?;
let kind = ev.get("kind").and_then(|v| v.as_u64()).context("no kind")?;
let created_at = ev.get("created_at").and_then(|v| v.as_u64()).context("no created_at")?;
let tags = ev.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
let content = ev.get("content").and_then(|v| v.as_str()).unwrap_or("");
let expected = event_id(pubkey, created_at, kind, &tags, content);
if hex::encode(expected) != id_hex {
bail!("event id mismatch");
}
let pk = XOnlyPublicKey::from_slice(&hex::decode(pubkey)?)
.context("bad pubkey")?;
let sig = Signature::from_slice(&hex::decode(sig_hex)?)
.context("bad signature")?;
Secp256k1::new()
.verify_schnorr(&sig, &Message::from_digest(expected), &pk)
.context("signature verification failed")?;
Ok(())
}
// ── NIP-44 v2 ──────────────────────────────────────────────────────────────
/// ECDH shared x-coordinate (unhashed, 32 bytes) between our secret key and
/// the peer's x-only public key. Lifting the x-only key with even-y parity
/// is safe here: negating a point flips only y, so the shared x — the only
/// thing NIP-44/NIP-04 consume — is unchanged.
fn shared_x(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> {
let sk_bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
let sk = SecretKey::from_slice(&sk_bytes).context("invalid secret key")?;
let peer_hex = pubkey_from_any(peer_pubkey_hex)?;
let peer = XOnlyPublicKey::from_slice(&hex::decode(&peer_hex)?)
.context("invalid peer pubkey")?;
// Lift x-only key to a full public key (even-y representative).
let full = PublicKey::from_x_only_public_key(peer, secp256k1::Parity::Even);
let point = ecdh::shared_secret_point(&full, &sk); // 64 bytes: x || y
let mut x = [0u8; 32];
x.copy_from_slice(&point[..32]);
Ok(x)
}
/// NIP-44 v2 conversation key: HKDF-extract(IKM = ECDH x, salt = 'nip44-v2').
fn conversation_key(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> {
let x = shared_x(secret_hex, peer_pubkey_hex)?;
let mut hk = HkdfExtractSha256::new(Some(NIP44_SALT));
hk.input_ikm(&x);
let (prk, _) = hk.finalize();
let mut ck = [0u8; 32];
ck.copy_from_slice(prk.as_slice());
Ok(ck)
}
/// HKDF-SHA256 extract step, exposing the raw PRK (Hkdf::expand hashes with
/// an info suffix even when info is empty, which is NOT the extract output;
/// finalize returns (PRK, ready-to-expand Hkdf)).
type HkdfExtractSha256 = hkdf::HkdfExtract<Sha256>;
/// Per-message keys: HKDF-expand(PRK = conversation key, info = nonce, L = 76)
/// sliced into chacha_key[32] chacha_nonce[12] hmac_key[32].
fn message_keys(ck: &[u8; 32], nonce: &[u8; 32]) -> ([u8; 32], [u8; 12], [u8; 32]) {
let hk = Hkdf::<Sha256>::from_prk(ck).expect("conversation key is 32 bytes");
let mut okm = [0u8; 76];
hk.expand(nonce, &mut okm).expect("76 <= 255 * hash len");
let mut chacha_key = [0u8; 32];
let mut chacha_nonce = [0u8; 12];
let mut hmac_key = [0u8; 32];
chacha_key.copy_from_slice(&okm[..32]);
chacha_nonce.copy_from_slice(&okm[32..44]);
hmac_key.copy_from_slice(&okm[44..76]);
(chacha_key, chacha_nonce, hmac_key)
}
/// NIP-44 padding: 2-byte big-endian plaintext length (6 bytes, `0x0000` +
/// u32, when ≥ 65536), zero-padded to the next power-of-two-ish chunk.
fn calc_padded_len(unpadded: usize) -> usize {
let unpadded: u64 = unpadded as u64;
if unpadded <= 32 {
return 32;
}
let next_power = 1u64 << ((63 - (unpadded - 1).leading_zeros()) + 1);
let chunk = if next_power <= 256 { 32 } else { next_power / 8 };
(chunk * ((unpadded - 1) / chunk + 1)) as usize
}
fn pad(plaintext: &[u8]) -> Result<Vec<u8>> {
if plaintext.is_empty() || plaintext.len() > u32::MAX as usize {
bail!("invalid plaintext length");
}
let prefix: Vec<u8> = if plaintext.len() >= 65536 {
let mut p = vec![0u8, 0u8];
p.extend_from_slice(&(plaintext.len() as u32).to_be_bytes());
p
} else {
(plaintext.len() as u16).to_be_bytes().to_vec()
};
let padded_len = calc_padded_len(plaintext.len());
let mut out = Vec::with_capacity(prefix.len() + padded_len);
out.extend_from_slice(&prefix);
out.extend_from_slice(plaintext);
out.resize(prefix.len() + padded_len, 0);
Ok(out)
}
fn unpad(padded: &[u8]) -> Result<Vec<u8>> {
if padded.len() < 2 {
bail!("invalid padding");
}
let first_two = u16::from_be_bytes([padded[0], padded[1]]);
let (unpadded_len, prefix_len) = if first_two == 0 {
if padded.len() < 6 {
bail!("invalid padding");
}
(u32::from_be_bytes([padded[2], padded[3], padded[4], padded[5]]) as usize, 6)
} else {
(first_two as usize, 2)
};
if unpadded_len == 0
|| padded.len() < prefix_len + unpadded_len
|| padded.len() != prefix_len + calc_padded_len(unpadded_len)
{
bail!("invalid padding");
}
Ok(padded[prefix_len..prefix_len + unpadded_len].to_vec())
}
/// Constant-time equality (length differs → false; content comparison never
/// short-circuits on a byte).
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// NIP-44 v2 encrypt: returns `base64(0x02 || nonce || ciphertext || mac)`.
pub fn nip44_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result<String> {
let ck = conversation_key(secret_hex, peer_pubkey_hex)?;
let mut nonce = [0u8; 32];
getrandom::getrandom(&mut nonce).context("OS RNG")?;
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
let mut padded = pad(plaintext.as_bytes())?;
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).expect("hmac accepts any key len");
mac.update(&nonce);
mac.update(&padded);
let tag = mac.finalize().into_bytes();
let mut out = Vec::with_capacity(1 + 32 + padded.len() + 32);
out.push(NIP44_VERSION);
out.extend_from_slice(&nonce);
out.extend_from_slice(&padded);
out.extend_from_slice(&tag);
Ok(BASE64.encode(&out))
}
/// NIP-44 v2 decrypt of a `base64(0x02 || …)` payload.
pub fn nip44_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result<String> {
if payload.starts_with('#') {
bail!("unknown NIP-44 version (non-base64 payload)");
}
let data = BASE64
.decode(payload.trim())
.context("payload is not base64")?;
if payload.len() < NIP44_MIN_B64_LEN || data.len() < NIP44_MIN_PAYLOAD_LEN {
bail!("invalid NIP-44 payload size");
}
if data[0] != NIP44_VERSION {
bail!("unknown NIP-44 version {}", data[0]);
}
let nonce: [u8; 32] = data[1..33].try_into().expect("slice is 32");
let ciphertext = &data[33..data.len() - 32];
let mac_bytes = &data[data.len() - 32..];
let ck = conversation_key(secret_hex, peer_pubkey_hex)?;
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).expect("hmac accepts any key len");
mac.update(&nonce);
mac.update(ciphertext);
let expected = mac.finalize().into_bytes();
if !ct_eq(&expected, mac_bytes) {
bail!("invalid NIP-44 MAC");
}
let mut buf = ciphertext.to_vec();
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut buf);
let plaintext = unpad(&buf)?;
String::from_utf8(plaintext).context("decrypted payload is not UTF-8")
}
// ── NIP-04 (deprecated transport, still spoken by real clients) ────────────
/// NIP-04 encrypt: AES-256-CBC, key = raw ECDH x-coordinate (unhashed — the
/// spec's quirk), output `<base64 ct>?iv=<base64 iv>`.
pub fn nip04_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result<String> {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
type Enc = cbc::Encryptor<aes::Aes256>;
let key = shared_x(secret_hex, peer_pubkey_hex)?;
let mut iv = [0u8; 16];
getrandom::getrandom(&mut iv).context("OS RNG")?;
let ct = Enc::new(&key.into(), &iv.into()).encrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(plaintext.as_bytes());
Ok(format!("{}?iv={}", BASE64.encode(&ct), BASE64.encode(iv)))
}
/// NIP-04 decrypt of `<base64 ct>?iv=<base64 iv>`.
pub fn nip04_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result<String> {
use aes::cipher::{BlockDecryptMut, KeyIvInit};
type Dec = cbc::Decryptor<aes::Aes256>;
let (ct_b64, iv_b64) = payload
.trim()
.split_once("?iv=")
.ok_or_else(|| anyhow::anyhow!("not a NIP-04 payload (no iv)"))?;
let ct = BASE64.decode(ct_b64).context("bad NIP-04 ciphertext base64")?;
let iv: [u8; 16] = BASE64
.decode(iv_b64)
.context("bad NIP-04 iv base64")?
.try_into()
.map_err(|_| anyhow::anyhow!("NIP-04 iv must be 16 bytes"))?;
let key = shared_x(secret_hex, peer_pubkey_hex)?;
let pt = Dec::new(&key.into(), &iv.into())
.decrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(&ct)
.map_err(|_| anyhow::anyhow!("NIP-04 decryption failed"))?;
String::from_utf8(pt).context("decrypted payload is not UTF-8")
}
/// URL-safe base64 for keys that cross the JNI boundary — unused by the
/// protocol but handy for the Kotlin side; keep the engine in one place.
pub fn b64_url(data: &[u8]) -> String {
BASE64_URL.encode(data)
}
#[cfg(test)]
mod tests {
use super::*;
// ── official NIP-44 vectors (paulmillr/nip44 nip44.vectors.json) ──────
#[test]
fn nip44_official_conversation_keys() {
let vectors: &[(&str, &str, &str)] = &[
("315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"),
("98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"),
("86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"),
// sec1 == pub2 (ECDH with self)
("0000000000000000000000000000000000000000000000000000000000000001", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e"),
];
for (sec1, pub2, expected) in vectors {
let ck = conversation_key(sec1, pub2).unwrap();
assert_eq!(hex::encode(ck), *expected);
}
}
#[test]
fn nip44_official_message_keys() {
let ck_bytes: [u8; 32] = hex::decode("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54")
.unwrap()
.try_into()
.unwrap();
let vectors: &[(&str, &str, &str, &str)] = &[
("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72", "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76", "c4ad129bb01180c0933a160c", "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"),
("ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967", "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be", "50bb859aa2dde938cc49ec7a", "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"),
];
for (nonce_h, ck_exp, cn_exp, hk_exp) in vectors {
let nonce: [u8; 32] = hex::decode(nonce_h).unwrap().try_into().unwrap();
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck_bytes, &nonce);
assert_eq!(hex::encode(chacha_key), *ck_exp);
assert_eq!(hex::encode(chacha_nonce), *cn_exp);
assert_eq!(hex::encode(hmac_key), *hk_exp);
}
}
#[test]
fn nip44_offical_padded_len() {
let vectors: &[(usize, usize)] = &[
(16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64), (64, 64),
(65, 96), (100, 128), (111, 128), (200, 224), (250, 256), (320, 320),
(383, 384), (384, 384), (400, 448), (500, 512), (512, 512), (515, 640),
(700, 768), (800, 896), (900, 1024), (1020, 1024), (65536, 65536),
];
for (unpadded, padded) in vectors {
assert_eq!(calc_padded_len(*unpadded), *padded, "unpadded {unpadded}");
}
}
#[test]
fn nip44_official_encrypt_vectors() {
// (sec1, sec2, nonce, plaintext, payload) — decrypt with the peer's
// view (sec2, pub(sec1)) so this also proves key symmetry.
let vectors: &[(&str, &str, &str, &str, &str)] = &[
("0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000001",
"a",
"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"),
("0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000001",
"f00000000000000000000000000000f00000000000000000000000000000000f",
"🍕🫃",
"AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"),
("5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
"ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="),
("eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"( ͡° ͜ʖ ͡°)",
"AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"),
("d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="),
];
for (sec1, sec2, nonce_hex, plaintext, payload) in vectors {
// Encrypt from A to B with the fixed nonce must reproduce the
// official payload byte-for-byte.
let pub1 = pubkey_hex(sec1).unwrap();
let made = {
let ck = conversation_key(sec1, &pubkey_hex(sec2).unwrap()).unwrap();
let nonce: [u8; 32] = hex::decode(nonce_hex).unwrap().try_into().unwrap();
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
let mut padded = pad(plaintext.as_bytes()).unwrap();
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).unwrap();
mac.update(&nonce);
mac.update(&padded);
let tag = mac.finalize().into_bytes();
let mut out = vec![NIP44_VERSION];
out.extend_from_slice(&nonce);
out.extend_from_slice(&padded);
out.extend_from_slice(&tag);
BASE64.encode(&out)
};
assert_eq!(&made, payload, "encrypt vector for {plaintext:?}");
// Decrypt from B's view of A (key-role symmetry).
let got = nip44_decrypt(sec2, &pub1, payload).unwrap();
assert_eq!(got, *plaintext);
}
}
#[test]
fn nip44_round_trip_and_failures() {
let sk_a = generate_secret().unwrap();
let sk_b = generate_secret().unwrap();
let pub_b = pubkey_hex(&sk_b).unwrap();
let pub_a = pubkey_hex(&sk_a).unwrap();
let msg = "hello, remote signer";
let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap();
assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), msg);
// Round-trip long content across the 65536 prefix boundary.
let long = "x".repeat(70_000);
let payload = nip44_encrypt(&sk_a, &pub_b, &long).unwrap();
assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), long);
// Wrong peer key must fail the MAC, not return garbage.
let stranger = generate_secret().unwrap();
assert!(nip44_decrypt(&sk_b, &pub_b, &payload).is_err());
let _ = stranger;
// Tampered payload fails.
let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap();
let mut tampered = BASE64.decode(&payload).unwrap();
let n = tampered.len();
tampered[n - 1] ^= 0x01;
assert!(nip44_decrypt(&sk_b, &pub_a, &BASE64.encode(&tampered)).is_err());
// Truncated payload fails.
assert!(nip44_decrypt(&sk_b, &pub_a, "AAAA").is_err());
}
// ── BIP-340 official vectors (github.com/bitcoin/bips test vectors) ────
#[test]
fn bip340_reference_sign_vectors() {
// (seckey, pubkey, aux, msg, expected sig) — indices 0/1/2 of the
// official BIP-340 `bip-0340/test-vectors.csv` "should sign" set,
// transcribed from the file itself (x(3G) additionally verified
// by independent scalar-math in the review notes for this commit).
let vectors: &[(&str, &str, &str, &str, &str)] = &[
("0000000000000000000000000000000000000000000000000000000000000003",
"F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9",
"0000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000000000000000000000000000000000000000000000000000",
"E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0"),
("B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF",
"DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659",
"0000000000000000000000000000000000000000000000000000000000000001",
"243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
"6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A"),
("C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9",
"DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8",
"C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906",
"7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C",
"5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7"),
];
for (sk_hex, pk_hex, aux_hex, msg_hex, sig_hex) in vectors {
let sk_bytes = hex::decode(sk_hex).unwrap();
let sk = SecretKey::from_slice(&sk_bytes).unwrap();
let kp = Keypair::from_secret_key(&Secp256k1::new(), &sk);
assert_eq!(hex::encode(kp.public_key().x_only_public_key().0.serialize()).to_uppercase(), *pk_hex);
let msg: [u8; 32] = hex::decode(msg_hex).unwrap().try_into().unwrap();
let aux: [u8; 32] = hex::decode(aux_hex).unwrap().try_into().unwrap();
let sig = Secp256k1::new().sign_schnorr_with_aux_rand(
&Message::from_digest(msg),
&kp,
&aux,
);
assert_eq!(hex::encode(sig.serialize()).to_uppercase(), *sig_hex);
}
}
#[test]
fn event_signing_round_trip() {
let sk = generate_secret().unwrap();
let unsigned = r#"{"kind":22242,"content":"{\"challenge\":\"abc123\"}","tags":[["relay","ws://127.0.0.1:7777"]],"created_at":1725100000}"#;
let signed = sign_event(&sk, unsigned).unwrap();
verify_event(&signed).unwrap();
let ev: serde_json::Value = serde_json::from_str(&signed).unwrap();
assert_eq!(ev["kind"], 22242);
assert_eq!(ev["pubkey"], pubkey_hex(&sk).unwrap());
// Tampering with content breaks the id, which breaks verification.
let mut tampered = ev.clone();
tampered["content"] = serde_json::Value::String("nope".into());
assert!(verify_event(&tampered.to_string()).is_err());
}
#[test]
fn connect_uri_parsing() {
let uri = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&perms=nip44_encrypt%2Csign_event%3A22242&name=My+Client&secret=0s8j2djs&relay=ws%3A%2F%2F192.168.1.20%3A7777";
let info = parse_connect_uri(uri).unwrap();
assert_eq!(info.client_pubkey, "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5");
assert_eq!(
info.relays,
vec!["wss://relay1.example.com", "ws://192.168.1.20:7777"]
);
assert_eq!(info.secret, "0s8j2djs");
assert_eq!(info.perms, vec!["nip44_encrypt", "sign_event:22242"]);
assert_eq!(info.name, "My Client");
// npub client keys and unknown params tolerated — the npub is
// generated through our own encoder so the test carries no
// hand-transcribed bech32 string.
let sk1 = "0000000000000000000000000000000000000000000000000000000000000001";
let npub = npub_from_pubkey(&pubkey_hex(sk1).unwrap()).unwrap();
let pubkey = pubkey_from_any(&npub).unwrap();
let uri = format!("nostrconnect://{npub}?relay=wss://r&secret=s&future=1");
let info = parse_connect_uri(&uri).unwrap();
assert_eq!(info.client_pubkey, pubkey);
assert_eq!(info.relays, vec!["wss://r"]);
assert!(parse_connect_uri("bunker://abc?relay=wss://r&secret=s").is_err());
assert!(parse_connect_uri("nostrconnect://zz?relay=wss://r&secret=s").is_err());
assert!(parse_connect_uri("nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?name=x").is_err());
}
#[test]
fn nip04_round_trip_and_cross_check() {
let sk_a = generate_secret().unwrap();
let sk_b = generate_secret().unwrap();
let pub_b = pubkey_hex(&sk_b).unwrap();
let pub_a = pubkey_hex(&sk_a).unwrap();
let payload = nip04_encrypt(&sk_a, &pub_b, "old client hello").unwrap();
assert!(payload.contains("?iv="));
assert_eq!(nip04_decrypt(&sk_b, &pub_a, &payload).unwrap(), "old client hello");
// Wrong key must fail (PKCS#7 padding check) rather than return garbage.
assert!(nip04_decrypt(&sk_a, &pub_a, &payload).is_err());
assert!(nip04_decrypt(&sk_b, &pub_b, &payload).is_err());
assert!(nip04_decrypt(&sk_b, &pub_a, "not-a-payload").is_err());
}
#[test]
fn key_encoding_round_trip() {
let sk = generate_secret().unwrap();
let nsec = nsec_from_secret(&sk).unwrap();
assert!(nsec.starts_with("nsec1"));
assert_eq!(secret_from_nsec(&nsec).unwrap(), sk);
assert_eq!(secret_from_any(&nsec).unwrap(), sk);
assert_eq!(secret_from_any(&sk).unwrap(), sk);
let pk = pubkey_hex(&sk).unwrap();
let npub = npub_from_pubkey(&pk).unwrap();
assert!(npub.starts_with("npub1"));
assert_eq!(pubkey_from_any(&npub).unwrap(), pk);
assert_eq!(pubkey_from_any(&pk).unwrap(), pk);
// The famous even-y lift edge case: pubkey of sk=1 is x(G) (y is odd);
// shared_x with oneself is exactly x(G) — pins the unhashed-x ECDH and
// the even-parity lift in one assertion (x is invariant under y-negation,
// so the lift is safe for NIP-44/NIP-04 keys).
let g_x = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
assert_eq!(
pubkey_hex("0000000000000000000000000000000000000000000000000000000000000001").unwrap(),
g_x
);
assert_eq!(
hex::encode(
shared_x("0000000000000000000000000000000000000000000000000000000000000001", g_x).unwrap()
),
g_x
);
assert!(secret_from_nsec("npub1").is_err());
}
/// The mesh ULA is a PURE function of the node's public key:
/// `fd ‖ sha256(x-only pubkey)[0..15]` (fips identity/node_addr.rs →
/// identity/address.rs). That is what makes "address by npub" work —
/// Termux's fipssh helper, and any future DNS-style resolver, just
/// computes what the fips daemon's DNS answers.
#[test]
fn npub_derives_the_same_mesh_ula_as_the_fips_identity() {
for seed in [0x42u8, 0x07, 0x31] {
// 0xff… would exceed the curve order — secret keys must be valid scalars.
let secret = [seed; 32];
let id = fips::Identity::from_secret_bytes(&secret).unwrap();
let npub = id.npub();
let expected = id.address().to_ipv6().to_string();
let pubkey_hex = pubkey_from_any(&npub).unwrap();
let pk = hex::decode(&pubkey_hex).unwrap();
let mut hasher = Sha256::new();
hasher.update(&pk);
let hash = hasher.finalize();
let mut ula = [0u8; 16];
ula[0] = 0xfd;
ula[1..].copy_from_slice(&hash[..15]);
assert_eq!(std::net::Ipv6Addr::from(ula).to_string(), expected, "npub {npub}");
}
}
}
+145
View File
@@ -0,0 +1,145 @@
#!/data/data/com.termux/files/usr/bin/sh
# fipssh — SSH to an Archipelago FIPS mesh node BY NPUB.
#
# The mesh ULA is a pure function of the node's public key (verified against
# the fips crate itself — archy-fips-core's npub_derives_the_same_mesh_ula
# test, and the Android tools commit that shipped this script):
#
# ula = fd || sha256(x-only pubkey)[0..15]
#
# so the npub IS the address: no DNS server, no mesh query, works offline.
# The node's fips daemon answers the same question through its DNS resolver
# (core/archipelago/src/fips/dial.rs) — this is the phone-side equivalent.
#
# Setup (Termux): pkg install python openssh
# Usage:
# fipssh <user>@npub1… [ssh args…] connect
# fipssh npub1… connect as $FIPSSH_USER
# fipssh --resolve npub1… print the ULA and exit
#
# The companion's split tunnel carries the connection (fd00::/8 routes the
# whole device while the mesh is up) — at home on LAN, away via the anchors.
# The node still has to allow port 22 through its fips0 firewall: see
# docs/HANDOFF-2026-08-31-ssh-over-mesh.md (the interim 90-ssh.nft drop-in,
# restricted to your phone's ULA, until the node-side toggle ships).
set -eu
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 1
}
RESOLVE_ONLY=0
if [ "${1:-}" = "--resolve" ]; then
RESOLVE_ONLY=1
shift
fi
[ $# -ge 1 ] || usage
TARGET="$1"
shift 2>/dev/null || true
case "$TARGET" in
*npub1*)
case "$TARGET" in
*@npub1*) USER_PART="${TARGET%%@*}"; N_PUB="${TARGET#*@}" ;;
npub1*)
USER_PART="${FIPSSH_USER:-}"
N_PUB="$TARGET"
if [ -z "$USER_PART" ] && [ "$RESOLVE_ONLY" = 0 ]; then
echo "fipssh: no user given (use user@npub… or set FIPSSH_USER)" >&2
exit 1
fi
;;
*) echo "fipssh: expected [user@]npub1…, got '$TARGET'" >&2; exit 1 ;;
esac
;;
*) echo "fipssh: '$TARGET' is not an npub (expected [user@]npub1…)" >&2; exit 1 ;;
esac
command -v python3 >/dev/null 2>&1 || {
echo "fipssh: python3 not found — run: pkg install python" >&2
exit 1
}
ULA=$(python3 - "$N_PUB" <<'PYEOF'
import hashlib, ipaddress, sys
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ value
for i in range(5):
chk ^= gen[i] if ((top >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def bech32_verify_checksum(hrp, data):
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
def bech32_decode(s):
if any(ord(c) < 33 or ord(c) > 126 for c in s):
raise ValueError("bad character")
if s.lower() != s and s.upper() != s:
raise ValueError("mixed case")
s = s.lower()
pos = s.rfind("1")
if pos < 1 or pos + 7 > len(s) or len(s) > 90:
raise ValueError("bad separator")
hrp = s[:pos]
data = [CHARSET.find(c) for c in s[pos + 1:]]
if -1 in data:
raise ValueError("bad data character")
if not bech32_verify_checksum(hrp, data):
raise ValueError("bad checksum — typo in the npub?")
return hrp, data[:-6]
def convertbits(data, frombits, tobits):
acc = 0
bits = 0
ret = bytearray()
maxv = (1 << tobits) - 1
for value in data:
if value < 0 or (value >> frombits):
raise ValueError("bad value")
acc = (acc << frombits) | value
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if bits >= frombits or ((acc << (tobits - bits)) & maxv):
raise ValueError("bad padding")
return bytes(ret)
npub = sys.argv[1]
hrp, data = bech32_decode(npub)
if hrp != "npub":
raise ValueError(f"expected hrp 'npub', got '{hrp}'")
pubkey = convertbits(data, 5, 8)
if len(pubkey) != 32:
raise ValueError(f"npub data must be 32 bytes, got {len(pubkey)}")
# ula = fd || sha256(pubkey)[0..15] — mirrors fips identity/node_addr.rs +
# identity/address.rs (FIPS_ADDRESS_PREFIX = 0xfd).
ula = bytes([0xFD]) + hashlib.sha256(pubkey).digest()[:15]
print(ipaddress.IPv6Address(ula).compressed)
PYEOF
) || exit 1
if [ "$RESOLVE_ONLY" = 1 ]; then
echo "$ULA"
exit 0
fi
exec ssh "${USER_PART}@${ULA}" "$@"
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
NIP-46 test client for the Archipelago companion's Remote Signer (#139).
Plays the role the node's login flow will play (rust-nostr nostr-connect
client): generates a nostrconnect:// pairing QR, connects to a relay, waits
for the phone's bunker `connect` (secret echo), acks it, then exercises
get_public_key + sign_event and VERIFIES the returned schnorr signature with
independent pure-Python BIP-340 code (no shared code with the phone's Rust).
Run it on your computer next to the phone:
python3 -m venv /tmp/nip46env
/tmp/nip46env/bin/pip install websockets qrcode
/tmp/nip46env/bin/python Android/tools/nip46-test-client.py [--relay wss://relay.damus.io]
…then on the phone: hub menu (three-finger hold) → Remote Signer →
Generate key (once) → Scan pairing QR → point at the terminal QR → Approve.
Pure Python (no deps for the crypto; websockets + qrcode for transport/QR).
"""
import argparse
import asyncio
import base64
import hashlib
import hmac
import json
import os
import secrets
import struct
import sys
import time
import urllib.parse
import websockets # pip install websockets
# ── secp256k1 / BIP-340 (independent of the phone's Rust code) ──────────────
P = 2**256 - 2**32 - 977
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
G = (GX, GY)
def _add(pt1, pt2):
if pt1 is None:
return pt2
if pt2 is None:
return pt1
x1, y1 = pt1
x2, y2 = pt2
if x1 == x2 and (y1 + y2) % P == 0:
return None
if pt1 == pt2:
lam = (3 * x1 * x1) * pow(2 * y1, -1, P) % P
else:
lam = (y2 - y1) * pow(x2 - x1, -1, P) % P
x3 = (lam * lam - x1 - x2) % P
return (x3, (lam * (x1 - x3) - y1) % P)
def _mul(k, pt):
r = None
while k:
if k & 1:
r = _add(r, pt)
pt = _add(pt, pt)
k >>= 1
return r
def lift_x(x):
if x >= P:
return None
y_sq = (pow(x, 3, P) + 7) % P
y = pow(y_sq, (P + 1) // 4, P)
if y * y % P != y_sq:
return None
return (x, y if y % 2 == 0 else P - y)
def tagged(tag: bytes, data: bytes) -> bytes:
"""BIP-340 tagged hash: sha256(hash(tag) || hash(tag) || data)."""
th = hashlib.sha256(tag).digest()
return hashlib.sha256(th + th + data).digest()
def bip340_sign(msg: bytes, seckey: int, aux: bytes) -> bytes:
d = seckey if seckey <= N - 1 else seckey - N
pub = _mul(d, G)
if pub[1] % 2 != 0:
d = N - d
t = bytes(a ^ b for a, b in zip(d.to_bytes(32, "big"), tagged(b"BIP0340/aux", aux)))
rand = tagged(b"BIP0340/nonce", t + pub[0].to_bytes(32, "big") + msg)
k = int.from_bytes(rand, "big") % N
assert k > 0
R = _mul(k, G)
if R[1] % 2 != 0:
k = N - k
e = int.from_bytes(tagged(b"BIP0340/challenge", R[0].to_bytes(32, "big") + pub[0].to_bytes(32, "big") + msg), "big") % N
return R[0].to_bytes(32, "big") + ((k + e * d) % N).to_bytes(32, "big")
def bip340_verify(msg: bytes, pubkey_x: bytes, sig: bytes) -> bool:
"""Check s·G − e·P == R with even-y R and x(R) == r (BIP-340)."""
if len(sig) != 64 or len(pubkey_x) != 32:
return False
pub = lift_x(int.from_bytes(pubkey_x, "big"))
if pub is None:
return False
r = int.from_bytes(sig[:32], "big")
s = int.from_bytes(sig[32:], "big")
if r >= P or s >= N:
return False
e = int.from_bytes(tagged(b"BIP0340/challenge", sig[:32] + pubkey_x + msg), "big") % N
sg = _mul(s, G)
ep = _mul(e, pub)
neg_ep = (ep[0], (P - ep[1]) % P)
rp = _add(sg, neg_ep)
return rp is not None and rp[0] == r and rp[1] % 2 == 0
def ecdh_x(secret_hex: str, peer_x_hex: str) -> bytes:
"""Raw ECDH x-coordinate against an x-only peer key (even-y lift)."""
peer = lift_x(int(peer_x_hex, 16))
assert peer is not None, "peer pubkey not on curve"
pt = _mul(int(secret_hex, 16) % N, peer)
return pt[0].to_bytes(32, "big")
# ── NIP-44 v2 (pure python, spec-literal) ────────────────────────────────────
def hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
t = b""
out = b""
i = 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
out += t
i += 1
return out[:length]
def _rotl(x: int, n: int) -> int:
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
def _qr(s, a, b, c, d):
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] ^= s[a]; s[d] = _rotl(s[d], 16)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] ^= s[c]; s[b] = _rotl(s[b], 12)
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] ^= s[a]; s[d] = _rotl(s[d], 8)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] ^= s[c]; s[b] = _rotl(s[b], 7)
def chacha20_block(key: bytes, counter: int, nonce: bytes) -> bytes:
consts = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574]
state = consts + list(struct.unpack("<8I", key)) + [counter] + list(struct.unpack("<3I", nonce))
working = list(state)
for _ in range(10):
_qr(working, 0, 4, 8, 12); _qr(working, 1, 5, 9, 13)
_qr(working, 2, 6, 10, 14); _qr(working, 3, 7, 11, 15)
_qr(working, 0, 5, 10, 15); _qr(working, 1, 6, 11, 12)
_qr(working, 2, 7, 8, 13); _qr(working, 3, 4, 9, 14)
return struct.pack("<16I", *[(x + y) & 0xFFFFFFFF for x, y in zip(working, state)])
def chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
counter = 0 # NIP-44: "ChaCha20 (RFC 8439) with starting counter set to 0"
out = bytearray()
for i in range(0, len(data), 64):
ks = chacha20_block(key, counter, nonce)
chunk = data[i:i + 64]
out += bytes(a ^ b for a, b in zip(chunk, ks))
counter += 1
return bytes(out)
def calc_padded_len(n: int) -> int:
if n <= 32:
return 32
power = 1 << ((n - 1).bit_length())
chunk = 32 if power <= 256 else power // 8
return chunk * ((n - 1) // chunk + 1)
def nip44_encrypt(secret_hex: str, peer_hex: str, plaintext: str) -> str:
ck = hkdf_extract(b"nip44-v2", ecdh_x(secret_hex, peer_hex))
nonce = secrets.token_bytes(32)
okm = hkdf_expand(ck, nonce, 76)
key, iv, mac_key = okm[:32], okm[32:44], okm[44:76]
pt = plaintext.encode()
padded = (len(pt).to_bytes(2, "big") if len(pt) < 65536 else b"\x00\x00" + len(pt).to_bytes(4, "big")) + pt
padded += b"\x00" * (calc_padded_len(len(pt)) - len(pt))
ct = chacha20(key, iv, padded)
mac = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
return base64.b64encode(bytes([2]) + nonce + ct + mac).decode()
def nip44_decrypt(secret_hex: str, peer_hex: str, payload: str) -> str:
data = base64.b64decode(payload)
assert data[0] == 2, "only NIP-44 v2 supported"
nonce, ct, mac = data[1:33], data[33:-32], data[-32:]
ck = hkdf_extract(b"nip44-v2", ecdh_x(secret_hex, peer_hex))
okm = hkdf_expand(ck, nonce, 76)
key, iv, mac_key = okm[:32], okm[32:44], okm[44:76]
assert hmac.compare_digest(hmac.new(mac_key, nonce + ct, hashlib.sha256).digest(), mac), "bad MAC"
padded = chacha20(key, iv, ct)
ln = int.from_bytes(padded[:2], "big")
body = padded[2:2 + ln] if ln else padded[6:6 + int.from_bytes(padded[2:6], "big")]
return body.decode()
# ── nostr events ─────────────────────────────────────────────────────────────
def event_id(pubkey_hex: str, created_at: int, kind: int, tags, content: str) -> str:
serialized = json.dumps([0, pubkey_hex, created_at, kind, tags, content], separators=(",", ":"))
return hashlib.sha256(serialized.encode()).hexdigest()
def sign_event(secret_hex: str, event: dict) -> dict:
eid = event_id(event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"])
ev = dict(event)
ev["id"] = eid
ev["sig"] = bip340_sign(bytes.fromhex(eid), int(secret_hex, 16), os.urandom(32)).hex()
return ev
# ── the client session ────────────────────────────────────────────────────────
def compact(d) -> str:
return json.dumps(d, separators=(",", ":"))
async def run(relay: str):
client_secret = os.urandom(32).hex()
client_secret_int = int(client_secret, 16) % N
client_pub_hex = _mul(client_secret_int, G)[0].to_bytes(32, "big").hex()
pair_secret = secrets.token_hex(16)
nonce = secrets.token_hex(8)
uri = (
f"nostrconnect://{client_pub_hex}"
f"?relay={urllib.parse.quote(relay, safe='')}"
f"&secret={pair_secret}"
f"&name=Archipelago+Test+Client"
)
print(f"· client key : {client_pub_hex}")
print(f"· relay : {relay}")
print()
print("Scan this QR with: Companion → hub (3-finger) → Remote Signer → Scan pairing QR")
print()
try:
import qrcode
qr = qrcode.QRCode(border=1)
qr.add_data(uri)
qr.make(fit=True)
qr.print_ascii(invert=True)
except ImportError:
print(uri)
print()
print("Waiting for the phone to pair (connect, ack, get_public_key, sign_event)…")
async with websockets.connect(relay, max_size=2**22) as ws:
await ws.send(compact(["REQ", "test", {"kinds": [24133], "#p": [client_pub_hex], "since": int(time.time()) - 60}]))
signer_pub = None
acked = False
requests = []
def send_frame(content: dict):
assert signer_pub is not None
ev = {
"pubkey": client_pub_hex,
"created_at": int(time.time()),
"kind": 24133,
"tags": [["p", signer_pub]],
"content": nip44_encrypt(client_secret, signer_pub, compact(content)),
}
return asyncio.ensure_future(ws.send(compact(["EVENT", sign_event(client_secret, ev)])))
async def request(method, params, rid):
send_frame({"id": rid, "method": method, "params": params})
timeout = time.time() + 120
got_pubkey = None
signed_event = None
while time.time() < timeout:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=timeout - time.time())
except (asyncio.TimeoutError, TimeoutError):
break
arr = json.loads(raw)
if not isinstance(arr, list) or len(arr) < 3 or arr[0] != "EVENT":
continue
ev = arr[2]
if ev.get("kind") != 24133 or ev.get("pubkey") == client_pub_hex:
continue
author = ev["pubkey"]
try:
msg = json.loads(nip44_decrypt(client_secret, author, ev["content"]))
except Exception:
continue
if "method" in msg and msg["method"] == "connect":
params = msg.get("params", [])
if params and params[0] == author and (len(params) < 2 or params[1] == pair_secret):
signer_pub = author
print(f"✓ phone paired — signer pubkey {author[:16]}…")
send_frame({"id": msg["id"], "result": "ack"})
acked = True
await asyncio.sleep(0.5)
await request("get_public_key", [], nonce + "-gpk")
else:
print("✗ phone sent connect but the secret didn't match")
return 1
continue
if "result" in msg or "error" in msg:
rid = msg.get("id", "")
if "error" in msg:
print(f"✗ error for {rid}: {msg['error']}")
if rid.endswith("-sign"):
return 1
continue
result = msg.get("result", "")
if rid.endswith("-gpk"):
got_pubkey = result
print(f"✓ get_public_key → {result}")
await request(
"sign_event",
[compact({
"kind": 1,
"content": "Hello from the Archipelago NIP-46 test client — approved by hand.",
"tags": [],
"created_at": int(time.time()),
})],
nonce + "-sign",
)
elif rid.endswith("-sign"):
signed_event = json.loads(result)
print(f"✓ sign_event → signed event {signed_event.get('id', '')[:16]}…")
break
if not acked:
print("✗ the phone never connected (2-minute timeout)")
return 1
if got_pubkey is None or got_pubkey != signer_pub:
print("✗ get_public_key missing or mismatched")
return 1
if signed_event is None:
return 1
ev = signed_event
expected_id = event_id(ev["pubkey"], ev["created_at"], ev["kind"], ev["tags"], ev["content"])
ok_id = expected_id == ev["id"]
ok_sig = bip340_verify(bytes.fromhex(expected_id), bytes.fromhex(ev["pubkey"]), bytes.fromhex(ev["sig"]))
print(f"· event id correct : {ok_id}")
print(f"· schnorr signature: {'VERIFIED ✓' if ok_sig else 'INVALID ✗'}")
if ok_id and ok_sig:
print()
print("END-TO-END PASS — the companion signed as the identity the phone holds,")
print("and the signature verifies under an independent BIP-340 implementation.")
return 0
return 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--relay", default="wss://relay.damus.io", help="any nostr relay both devices can reach")
args = ap.parse_args()
sys.exit(asyncio.run(run(args.relay)))
if __name__ == "__main__":
main()
+226 -5
View File
@@ -1,5 +1,204 @@
# Changelog
## Unreleased
## v1.8.13-alpha (2026-09-12)
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
- **Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.
- **Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services.
## v1.8.12-alpha (2026-09-11)
- **Fresh IndeedHub installs no longer share a fleet-wide encryption root.** The API now generates a persistent per-node AES master secret and shares it with the media worker through the platform's protected secret environment. Existing nodes migrate the exact legacy value they are already using before any container can be recreated, preserving access to encrypted data; an unreadable or empty existing root fails safely instead of being silently replaced. The manifest path, retired fallback installer, and container repair script follow the same rule.
- **The Companion download advertises and re-announces the APK it actually serves.** The Discover banner and its install prompt now share the no-cache APK metadata, visibly report Companion 0.5.32 build 52, and remember dismissal per Android build rather than forever, so an existing browser gets one useful update prompt when the APK changes. The ISO gate reads the expected version from the Android build itself instead of accepting the stale 0.5.28 payload.
- **GitWorkshop's dependency audit is clean.** The pinned upstream client keeps its separately reviewable Archipelago integration patch and now applies a deterministic dependency patch: safe lock refreshes plus targeted `fflate`, React Router, and Vitest upgrades remove all ten production advisories and all eight development advisories. A clean install reports zero vulnerabilities; type-check, all 152 upstream unit tests, and the exact Archipelago subpath build pass.
- **Every completed payment now gets the full Lightning-style receipt screen.** Cashu and Fedimint sends no longer leave the payment form open behind a token; wallet, QR-scan, Web5, and app-requested sends all replace their forms with the animated success state. Payment hashes, transaction IDs, ecash tokens/notes, mint details, and other useful references remain copyable in the receipt, and receive completions open the same distinct payment-success modal. Minibits claims retain a short-lived durable receipt so the visible modal still reports success when another dashboard or Companion context wins the claim-poll race, while concurrent watchers now share one bounded relay fetch instead of queueing several long polls.
- **TollGate provisioning closes the free-access path without taking over an admin network.** Confirmed upstream `TollGate-*` access points are moved from LAN onto the paid network, mint URLs are normalized consistently, and operators can set a validated Lightning payout address without replacing merchant keys or other revenue-share identities. Malformed existing identity data now stops provisioning safely instead of being overwritten.
- **Cashu receive gains a human-readable Minibits Lightning address.** The node derives the profile from the existing ecash recovery phrase, collects payments from the Minibits Nostr delivery relays, and redeems them into the Cashu wallet. Claim polling is single-flight, state and already-consumed tokens are written atomically with private permissions, same-second events are deduplicated without being skipped, restored seeds cannot reuse another wallet's profile, and pending claims retain the service key that encrypted them across key rotations. The UI identifies Minibits as a third-party beta service and recommends small balances.
- **Nostr sign-in returns directly to the app instead of a black or grey frame.** The top-level signer broker now stays loaded as a 1px non-interactive surface parked physically off-screen; removing or display-hiding its full-screen cross-origin iframe could leave stale compositor pixels above IndeeHub or GitWorkshop in Android WebView and mobile Chromium until refresh. One retained broker also keeps identity selection and its immediately following signing request in a continuous UI, while Companion no longer adds a separate 180ms cover that made GitWorkshop visibly flicker.
- **Gitea is sized for source and release hosting, not an empty demo.** Its manifest storage allowance is now 50GiB, release attachments accept individual files up to 10GiB, container-package owner storage remains unlimited, and HTTP/HTTPS proxy uploads share a streamed 10GiB ceiling. Existing repository, package, LFS and release data is unchanged.
- **Companion browser-tab signing now accepts the app gate's complete session.** A fresh external browser no longer needs a prior dashboard login/localStorage marker before the dashboard-origin signer can load. The app gate now issues both the shared HttpOnly node session and its matching readable CSRF token, so identity discovery and signing RPCs work after that one login instead of rendering a misleading “No identities found” state. Normal dashboard logout/session checks keep their existing behavior.
- **Fast Nostr identity choices now survive app startup and Companion tabs.** The tab/WebView broker waits for the application load event before opening its first-run picker, queues every NIP-07 call until the signer is initialized, and hands the just-selected public key directly to the immediate login request. GitWorkshop now turns that first-run choice into its normal extension account automatically, eliminating the startup race that surfaced as IndeedHub's “Could not get public key from extension.”
- **GitWorkshop makes network projects and Archipelago login explicit.** Its signed-in dashboard now includes recent repositories from the Nostr git index, the NIP-07 action reads “Extension / Archipelago,” and explicit Archipelago logins reopen the node identity chooser instead of silently reusing the first identity. Direct, user-triggered NIP-07 logins receive the same account-switch behavior for upstream apps such as IndeedHub.
- **IndeedHub tab signing now tracks the dashboard signer.** The injected provider supports the contained signer broker in direct tabs, is cache-busted, and is reconciled after dashboard-only updates as well as app installs and starts.
- **App launches now honor credentials everywhere.** Home, Spotlight, Discover, My Apps, and app-detail launches all pass through one platform-owned credential handoff, so Portainer's first-run token and the File Browser/PhotoPrism login details can no longer be skipped by launching from the Home grid.
- **Manage Updates returns to Download immediately after cancellation.** Canceling a stalled OTA now clears both the local staged state and progress state instead of leaving an incorrect Install button visible until the page is refreshed.
- **GitWorkshop no longer probes a desktop-only localhost relay or unauthenticated manifest.** The packaged upstream client disables its default `localhost:4869` nostrdb probe, uses credentialed manifest loading, drops dead lookup relays, and permits the dashboard's contained signer broker in its frame policy.
- **Rootless app ports self-heal when `pasta` drops a listener.** The five-minute container doctor compares every running container's declared Podman port bindings with actual host listeners and restarts only a container whose listener vanished. TCP and UDP are checked separately, avoiding false restarts of services such as NetBird's UDP port 3478. This covers the intermittent Nginx Proxy Manager port 8081 rebind failure without requiring a node reboot.
- **Nostr identity actions now use one contained, companion-safe signing experience.** The old full-screen signer has been replaced by the same in-app consent surface used by embedded apps, with the animated identity circle as a brief signing indicator and an explicit completion state. Editing an identity now ends on a dedicated success screen that reports relay coverage and the event ID instead of disappearing back into the form. The app developer guide defines this platform-owned NIP-07 flow and its browser/Companion test matrix so apps do not add a second signer UI.
- **Discovery merchandising is now owned by the signed app registry.** The catalog declares the Popular Apps set and contribution promotion; Discover renders two desktop rows of popular apps, then the “Your node. Your source.” banner, then the remaining apps. GitWorkshop uses a cache-busted copy of its current upstream mark, and its catalog entry identifies the canonical Archipelago maintainer npub.
- **Companion opens Source in its native WebView and installs the node certificate.** GitWorkshop is a top-level page in the Companion in-app browser—not a dashboard iframe—and its injected provider uses the contained, consent-gated signer broker. The generic native launcher turns relative app paths into complete URLs before handing them to Android. The Node certificate button uses Android's system credential installer in the companion instead of an unsupported WebView download.
- **Node certificate guidance now covers installation and the failures people actually see.** Settings includes the complete macOS, iOS/iPadOS, Windows, Android, Linux, Firefox, and Arch/Manjaro steps; reminds users to restart browsers that cache trust decisions; separates certificate trust from DNS; and maps common browser symptoms to their likely cause.
- **Tab and Companion Nostr sign-in no longer loses the broker or an early identity choice.** The signer route validates the shared app-gate session with the implemented, authenticated `system.get-hostname` RPC instead of the nonexistent `system.get-version`. The provider also exposes a sticky identity subscription so a GitWorkshop React listener that mounts just after selection still completes the normal NIP-07 login. The dashboard service worker no longer precaches the signer route or provider, preventing an old bridge from surviving an update. This repairs GitWorkshop automatic login and IndeeHub's external mobile-browser flow.
- **The App Store now makes Archipelago's source an invitation to contribute.** GitWorkshop has its real upstream icon and source-focused description, plus a dedicated “Your node. Your source.” banner explaining that users can browse the code, clone with ngit, and send issues, patches, and reviews over Nostr.
- **Source now packages GitWorkshop instead of maintaining a separate Nostr Git interface.** The pinned upstream client runs read-only behind the authenticated app gate, launches at the dashboard's same origin under `/app/archipelago-source/`, and uses the node's consent-gated NIP-07 bridge. The upstream revision declares no license; Archipelago's owner accepted that redistribution risk without representing the client as licensed. Production publication still requires a tested canonical Archipelago NIP-34/GRASP announcement.
- **Changing the node password now reports a wrong current password directly.** The backend was already rejecting the request before changing either the web or SSH password, but its error sanitizer replaced that safe, actionable explanation with “check server logs.” The real validation error now reaches the password dialog.
- **The periodic container doctor runs from the same canonical path used by OTA updates.** Its systemd unit and embedded bootstrap still pointed at the retired source-checkout path while release updates installed the script under `/opt/archipelago/scripts`, leaving the doctor failed on nodes without that checkout. ISO, OTA bootstrap, and the deployment smoke test now agree on the `/opt` path.
## v1.8.11-alpha (2026-09-07)
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
## v1.8.10-alpha (2026-09-02)
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
- **A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.
## v1.8.9-alpha (2026-09-01)
- **Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.
- **The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.
- **App frames open over HTTPS again — including the ones that "did not connect."** The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.
- **Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.
- **Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.
- **Portainer's first-run token is in the app page, not buried in "server logs."** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.
## v1.8.8-alpha (2026-09-01)
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
## v1.8.7-alpha (2026-08-31)
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
- **The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.
- **Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
- **Every app in the store is now a first-class platform app.** The remaining platform apps carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. Retired apps are dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
## v1.8.6-alpha (2026-08-31)
- **Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.
- **What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.
- **A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines.
## v1.8.5-alpha (2026-08-30)
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
- **A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.
- **Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so "still there" is never presented as "gone".
- **Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with "Peer is federation-only (no radio twin)" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.
- **Disk cleanup finally has honest numbers.** Space "free" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure.
- **Three small screens that were lying to you, fixed.** The "Bitcoin is synced — fund your wallet" toast no longer appears on a node where the wallet it means (LND) isn't installed — it points at installing LND instead. The seed-reveal screen hides its third prompt unless the password actually fails to decrypt (the backup passphrase only exists if you set one). And multi-version store cards stop quoting a version number you'll be asked to choose on the next screen anyway.
- **Mesh notifications survive a refresh, and a stale router no longer hides the fix.** Radio message unread counts are now remembered per contact instead of guessed from session state (the "one new message showed 11 unread" bug), cover Meshtastic, MeshCore and Reticulum alike, and deep-link to the right conversation; a single new message announces itself once. Separately, when the cached router address goes stale, the error card gains a "Reconfigure router" action instead of a Retry loop that can never succeed.
- **The app updater now knows what upstream shipped.** Every app's manifest records where it comes from — including the odd corners (GitLab-only projects, ghcr-only images) — and a checker sweeps all of them against upstream releases, so a pin that quietly rots for months is now visible instead of invisible. The first full sweep found 27 pins behind; the safe patch-level ones shipped with this release (strfry, BTCPay Server 2.4.3, the two nginx frontends), and the major jumps that may carry data migrations are deliberately held for their own careful passes.
## v1.8.4-alpha (2026-08-20)
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
- **The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.
- **While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.
- **"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.
- **A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.
- **Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)
- **A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.
## v1.8.3-alpha (2026-08-14)
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
- **The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.
- **App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.
## v1.8.2-alpha (2026-08-13)
- **An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.
- **The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)
- **New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically.
## v1.8.1-alpha (2026-08-13)
- **Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.
- **The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine — settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate — invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.
- **New Settings → Display → Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets.
## v1.8.0-alpha (2026-08-12)
- **Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.
- **Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".
- **Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.
- **The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.
- **No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.
- **The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.
- **The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.
## v1.7.129-alpha (2026-08-10)
- **Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.
- **Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" — and a helper must be orphaned for a sustained period before it is touched.
- **A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.
- **The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.
- **An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).
## v1.7.128-alpha (2026-08-10)
- **The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays — nothing holds its key anymore — so the "Discoverable nodes" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off — or factory-resetting the node — actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.
- **You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name — it travels inside the public announcement, so other nodes' discovery lists show "Dorian's basement node" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.
- **The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address — which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.
- **The seed screen stops flashing while the node starts.** During first boot, the lock icon and "server starting" text blinked in and out every few seconds while the node came up — each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.
- **A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about "the authenticated system.factory-reset". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node.
## v1.7.127-alpha (2026-08-09)
- **Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.
@@ -234,6 +433,12 @@
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
## v1.7.107-alpha (2026-07-20)
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
- Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.
## v1.7.106-alpha (2026-07-20)
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
@@ -613,11 +818,13 @@
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
- The gap this closes: apps launched through the orchestrator previously skipped the legacy start-time repair path entirely, so the same stale metadata the old flow cleaned up silently broke the new one. Both paths now converge on the same repairs.
## v1.7.64-alpha (2026-05-18)
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
- For operators mid-incident this changes the recovery loop: a failed apply can now be retried immediately from the System Update page instead of waiting out a throttle window while a node sits half-updated.
## v1.7.63-alpha (2026-05-18)
@@ -727,6 +934,18 @@
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
## v1.7.51-alpha (2026-04-30)
- Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.
- Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.
- Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.
## v1.7.50-alpha (2026-04-30)
- The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.
- Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.
- This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.
## v1.7.49-alpha (2026-04-30)
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
@@ -738,12 +957,15 @@
## v1.7.48-alpha (2026-04-29)
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where that runtime directory wasn't pre-created — the failure surfaced in systemd's mount-namespace setup before the service itself ever ran.
- ExecStartPre now creates /run/containers before the service starts, so the node's service manager finds the directory it needs on every boot; ISO installs from this version forward have the fix baked in.
- Existing nodes pick the fix up with a one-time `systemctl edit archipelago` adding the mkdir — after which the boot failure does not recur.
## v1.7.47-alpha (2026-04-29)
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
- The result: a fresh node's first hours are measurably shorter — initial block download and ElectrumX indexing were the two longest post-install waits, and both now run at the hardware's limit.
## v1.7.46-alpha (2026-04-29)
@@ -766,10 +988,9 @@
## v1.7.44-alpha (2026-04-28)
43de3b73 feat(orchestrator): complete container migration and release hardening
ce39430b feat(self-update): sync and rebuild UI containers on OTA
72dec5aa fix(lnd-ui): align container port across all specs
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
- Container orchestration migration completed, with release hardening across the app lifecycle — installs, updates and removals now run through one orchestrator path instead of the split legacy/Podman flows.
- OTA updates now rebuild and sync the app UI containers they carry, so an updated app serves the UI image that matches its backend instead of whatever happened to be on disk.
- LND UI port handling is aligned across all runtime specs, and release packaging moved to tarball-only payloads with the ISO build recipes archived — update payloads now carry only the files existing nodes need.
All notable changes to Archipelago will be documented in this file.
+8 -1
View File
@@ -57,6 +57,13 @@ ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
- **GitWorkshop** — https://github.com/DanConwayDev/gitworkshop — pinned at
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da`. The upstream revision declares
no software license. Archipelago applies a documented integration patch and
redistributes the resulting static application under an explicit owner risk
acceptance dated 2026-09-11; this notice does not claim or grant upstream
copyright permission. See `docker/archipelago-source/UPSTREAM.md`.
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
@@ -65,7 +72,7 @@ is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
Jellyfin, MariaDB, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
+15 -1
View File
@@ -11,7 +11,21 @@ Podman containers managed by the Rust backend.
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
[![Version](https://img.shields.io/badge/version-1.8.13--alpha-blue)](https://source.archipelago-foundation.org/lfg2025/archy/releases)
## Current release
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
The same source is mirrored through ngit for Nostr-native cloning and
contribution:
```
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
```
Clone with ngit, or use the Gitea mirror when you need a conventional Git
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
## What is here
Submodule aiui/.claude/worktrees/agitated-hofstadter deleted from 10e12a329f
Submodule aiui/.claude/worktrees/funny-hofstadter deleted from 1c5185a15c
Submodule aiui/.claude/worktrees/happy-colden deleted from 666e1232f4
Submodule aiui/.claude/worktrees/hardcore-beaver deleted from a817fa199f
Submodule aiui/.claude/worktrees/heuristic-raman deleted from e8e002debc
Submodule aiui/.claude/worktrees/priceless-colden deleted from aaaef7d710
@@ -93,10 +93,11 @@ describe('useAI', () => {
expect(activeModel.value).toBe('echo')
})
it('lists available providers with models', () => {
it('lists available providers with models, Routstr first', () => {
const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(3)
expect(availableProviders.value.length).toBe(4)
const ids = availableProviders.value.map(p => p.id)
expect(ids[0]).toBe('routstr')
expect(ids).toContain('claude')
expect(ids).toContain('openrouter')
expect(ids).toContain('mock')
@@ -119,7 +119,7 @@
<Transition name="picker">
<div
v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px] max-h-[70vh] overflow-y-auto"
:style="modelPickerDropdownStyle"
@click.stop
>
@@ -332,7 +332,7 @@ const modelDisplayName = computed(() => {
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
+118 -5
View File
@@ -13,12 +13,14 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
type Provider = 'claude' | 'openrouter' | 'mock'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
const OPENROUTER_PATH = `${BASE}api/openrouter`
const ROUTSTR_MODELS_PATH = `${BASE}api/routstr/models`
const ROUTSTR_CHAT_PATH = `${BASE}api/routstr/chat/completions`
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
@@ -148,8 +150,41 @@ function looksLikeMissingApiKey(err: string): boolean {
)
}
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
// The node forwards the live Routstr aggregator's /v1/models; entries carry
// sats_pricing so completions are Cashu-paid against the operator's budget.
const routstrModels = ref<{ id: string; name: string }[]>([])
let routstrModelsFetched = false
async function refreshRoutstrModels() {
if (routstrModelsFetched) return
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
.filter((m: Record<string, unknown>) => typeof m.id === 'string')
.map((m: Record<string, unknown>) => ({
id: m.id as string,
name: (m.name as string) || (m.id as string),
}))
}
} catch {
routstrModelsFetched = false // allow a retry on the next send/open
}
}
const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'routstr',
name: 'Routstr (sats)',
models: routstrModels.value.length > 0
? routstrModels.value
: [{ id: 'routstr-unavailable', name: 'No models — node offline?' }],
},
{
id: 'claude',
name: 'Claude (Max)',
@@ -381,6 +416,71 @@ async function streamOpenRouter(
}, onError, signal)
}
/**
* Routstr: one paid, NON-streaming, OpenAI-shaped completion through the
* node's session-gated `/aiui/api/routstr/` forwarder. The node quotes a
* price from the live catalog, pays with a Cashu token against the
* operator's budget (Settings → System → Routstr AI budget), redeems the
* change, and passes the provider's JSON back. The full answer is emitted
* as a single token — streaming across a paid hop is the planned follow-up.
*/
async function streamRoutstr(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const wireMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const res = await apiFetch(ROUTSTR_CHAT_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: activeModel.value,
messages: wireMessages,
stream: false,
}),
signal,
})
const bodyText = await res.text().catch(() => '')
if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim.
let msg = `Routstr error ${res.status}`
try {
const parsed = JSON.parse(bodyText)
// Node refusals use {error:{message}}; the upstream provider nests
// its own as {detail:{error:{message}}} or a plain {detail:"..."}.
const detail = parsed?.detail
msg =
parsed?.error?.message ??
detail?.error?.message ??
(typeof detail === 'string' ? detail : undefined) ??
msg
} catch { /* keep the status-only message */ }
onError(msg)
return
}
if (signal?.aborted) return
try {
const parsed = JSON.parse(bodyText)
const text = parsed?.choices?.[0]?.message?.content
if (typeof text === 'string' && text.length > 0) {
onToken(text)
} else {
onError('Routstr returned an empty response')
}
} catch {
onError('Routstr returned a malformed response')
}
}
/**
* Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside
* Archy, the model call, the tool-calling loop, and the model key all live
@@ -619,7 +719,11 @@ export async function streamWithModel(
activeModel.value = model
try {
if (useArchy().isEmbedded.value) {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — the whole
// point of the picker entry is that it is a selection, not a fallback.
await streamRoutstr(history, onToken, onError, 'You are a helpful assistant.', signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
@@ -638,8 +742,9 @@ export async function streamWithModel(
export function useAI() {
const chatStore = useChatStore()
// Fetch Wavlake catalog on first use (non-blocking)
// Fetch Wavlake + Routstr catalogs on first use (non-blocking)
refreshWavlakeCatalog()
refreshRoutstrModels()
function stopGeneration() {
if (currentAbort) {
@@ -712,7 +817,11 @@ export function useAI() {
const genParams = getConversationParams(chatStore)
try {
if (useArchy().isEmbedded.value) {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
@@ -828,7 +937,11 @@ export function useAI() {
const genParams = getConversationParams(chatStore)
try {
if (useArchy().isEmbedded.value) {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
+34
View File
@@ -34,6 +34,40 @@ Add an entry to `catalog.json`:
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
For new apps, include `containerConfig` so the backend knows how to create the container.
## Storefront layout
Discovery merchandising is app-registry data, not node-OS layout. The optional
top-level `storefront` block defines the ordered Popular Apps rows and the
promotional banners placed before the remaining `All Apps` grid:
```json
{
"storefront": {
"popular": ["bitcoin-knots", "lnd", "btcpay-server"],
"promotions": [{
"id": "my-app",
"banner": "/assets/img/featured/my-app.webp",
"eyebrow": "open source",
"headline": "Build together.",
"description": "Catalog-controlled promotional copy.",
"tag": "NOSTR // SOURCE",
"path": "/npub1maintainer/project",
"launchLabel": "Open",
"installLabel": "Install",
"detailsLabel": "Learn more →"
}]
}
}
```
Only IDs present in `apps` render. An optional promotion `path` deep-links into
the installed app; Archipelago uses this to open the canonical signed Nostr
repository rather than GitWorkshop's generic dashboard. New dashboards prefer `storefront` from the
daemon-verified signed catalog and use the bundled community copy as a local
fallback. `scripts/generate-app-catalog.sh` carries this block into the signed
release artifact; changing it does not require a node OS release once that
artifact is published.
## Categories
money, commerce, data, home, nostr, networking, community, development, l484
+435 -338
View File
@@ -9,18 +9,61 @@
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"storefront": {
"popular": [
"bitcoin-knots",
"lnd",
"btcpay-server",
"mempool",
"filebrowser",
"homeassistant"
],
"promotions": [
{
"id": "archipelago-source",
"banner": "/assets/img/featured/archipelago-source-banner.webp",
"eyebrow": "open source",
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "bitcoin-core",
@@ -35,76 +78,16 @@
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.2",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "botfights",
@@ -131,15 +114,128 @@
]
}
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "fedimint",
"title": "Fedimint Guardian",
"version": "0.10.0",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.63.23",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"version": "1.27.3",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
@@ -164,42 +260,201 @@
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"id": "archipelago-source",
"title": "GitWorkshop",
"version": "0.4.0",
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
"author": "GitWorkshop contributors",
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
"category": "development",
"tier": "optional",
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
"dockerImage": "localhost/archipelago-source:local"
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": [
"8083:80"
"3000:3000"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
"/var/lib/archipelago/grafana:/var/lib/grafana"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
]
}
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.8.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "lnd",
"title": "LND",
"version": "0.21.2",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager",
"version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -215,25 +470,85 @@
}
},
{
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"id": "ollama",
"title": "Ollama",
"version": "0.5.4",
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama",
"category": "community",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"8082:80"
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.45.0",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "searxng",
"title": "SearXNG",
@@ -254,157 +569,6 @@
]
}
},
{
"id": "fedimint",
"title": "Fedimint Guardian",
"version": "0.10.0",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "grafana/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": [
"3000:3000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
]
}
},
{
"id": "tailscale",
"title": "Tailscale",
@@ -433,51 +597,6 @@
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
@@ -507,44 +626,22 @@
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.37.2",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"2342:2342"
"8082:80"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
"/var/lib/archipelago/vaultwarden:/data"
]
}
}
+2
View File
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
## Development Ports (Offset: +10000)
@@ -53,6 +54,7 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Meshtastic | http://localhost:14403 |
| GitWorkshop | http://localhost:18337 |
## Port Conflict Resolution
+3
View File
@@ -2,6 +2,9 @@ app:
id: aiui
name: AI Assistant
version: 0.1.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
+81
View File
@@ -0,0 +1,81 @@
app:
id: alby-hub
name: Alby Hub
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: getAlby/hub
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
category: money
container:
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0
pull_policy: if-not-present
dependencies:
- storage: 1Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 8187
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/alby-hub
target: /data
options: [rw]
environment:
- WORK_DIR=/data
- PORT=8080
# LDK peers are dialed outbound-only in v1; no inbound p2p port is
# advertised, so no extra port mapping is needed for payments to work.
- LOG_LEVEL=info
health_check:
type: http
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 5
interfaces:
main:
name: Web UI
description: Alby Hub wallet interface
type: ui
port: 8187
protocol: http
metadata:
icon: /assets/img/app-icons/alby-hub.svg
repo: https://github.com/getAlby/hub
tier: optional
launch:
# Embedded: the gate neutralizes Alby Hub's X-Frame-Options: DENY on
# proxied responses. Nodes older than the gate fix show a blocked
# frame — flip to true only if targeting such nodes.
open_in_new_tab: false
features:
- Self-custodial Lightning node (LDK) with a friendly wallet UI
- Connect wallets and apps via Nostr Wallet Connect (NWC)
- Per-app budgets and isolated sub-wallets
- Works with the Alby browser extension and mobile app
+80
View File
@@ -0,0 +1,80 @@
app:
id: archipelago-source
name: GitWorkshop
version: 0.4.0
upstream:
kind: github
repo: DanConwayDev/gitworkshop
description: >-
Get Archipelago's source, clone it with ngit, and contribute issues,
patches, and reviews over Nostr using the upstream GitWorkshop client.
category: development
container:
build:
context: /opt/archipelago/docker/archipelago-source
dockerfile: Dockerfile
tag: localhost/archipelago-source:local
resources:
cpu_limit: 1
memory_limit: 64Mi
disk_limit: 64Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: host
ports:
- host: 8337
container: 8337
protocol: tcp
bind: 127.0.0.1
auth: gated
session_passthrough: true
volumes:
- type: tmpfs
target: /tmp
tmpfs_options: rw,noexec,nosuid,size=16m,mode=1777
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8337
path: /healthz
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: GitWorkshop
description: NIP-34 repository browser, issues, pull requests, and review
type: ui
port: 8337
protocol: http
path: /
metadata:
# Versioned filename deliberately invalidates dashboard/browser icon caches
# when the Source prototype is replaced by the upstream GitWorkshop mark.
icon: /assets/img/app-icons/gitworkshop-dc36db6.svg
author: GitWorkshop contributors
repo: https://github.com/DanConwayDev/gitworkshop
maintainer_npub: npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
tier: optional
launch:
# GitWorkshop is top-level in Companion's native in-app WebView. Its
# injected NIP-07 provider creates the authenticated dashboard-origin
# signer broker itself, so no dashboard parent frame is required.
requires_host_frame: false
features:
- NIP-34 repository discovery and browsing
- Bandwidth-efficient Git explorer over GRASP
- Nostr issues, pull requests, and code review
- NIP-07 extension and NIP-46 remote-signer support
- Archipelago node identity through explicit signing consent
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres backend for BTCPay and NBXplorer.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/mariadb
description: MariaDB backend for the mempool explorer stack.
container:
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
pull_policy: if-not-present
network: archy-net
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dgarage/NBXplorer
description: BTCPay blockchain indexer service.
container:
+8
View File
@@ -2,6 +2,14 @@ app:
id: barkd
name: Ark Wallet
version: 0.3.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. bark ships on GitLab only
# (no GitHub mirror), so the gitlab fetcher is the one that can see it.
# NOTE: a version bump is code work, not a pin move — the REST shapes are
# coded in core/archipelago/src/wallet/ark_client.rs (see Dockerfile note).
upstream:
kind: gitlab
repo: ark-bitcoin/bark
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoin/bitcoin
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
@@ -49,7 +55,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoinknots/bitcoin
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
@@ -55,7 +61,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
+3
View File
@@ -2,6 +2,9 @@ app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
+3
View File
@@ -2,6 +2,9 @@ app:
id: botfights
name: BotFights
version: 1.2.11
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
+19 -3
View File
@@ -1,11 +1,17 @@
app:
id: btcpay-server
name: BTCPay Server
version: 2.4.2
version: 2.4.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: btcpayserver/btcpayserver
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: docker.io/btcpayserver/btcpayserver:2.4.2
image: docker.io/btcpayserver/btcpayserver:2.4.3
pull_policy: if-not-present
network: archy-net
secret_env:
@@ -46,7 +52,17 @@ app:
container: 49392
protocol: tcp
bind: 127.0.0.1
auth: gated
# open, not gated: BTCPay has its own account system, and its public
# surfaces (checkout/invoice pages, payment buttons, webhooks) must be
# reachable by anonymous payers and machines — a dashboard login in
# front of a checkout link breaks the product. The gate still fronts
# the port; the operator can force the dashboard login back on from
# Settings → BTCPay Server → Access control.
auth: open
auth_rationale: >-
BTCPay enforces its own login for administration, and its checkout,
invoice and webhook endpoints are designed to be reached by
anonymous payers and payment processors.
volumes:
- type: bind
+6
View File
@@ -2,6 +2,12 @@ app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ElementsProject/lightning
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
+193
View File
@@ -0,0 +1,193 @@
app:
id: cuprate
name: Cuprate
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
# Cuprate has no stable release yet — this is explicitly work-in-progress
# software (see upstream README). The image tag below pins the exact
# commit built, since "0.1.0-preview" alone is not reproducible.
version: 0.1.0-preview
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: Cuprate/cuprate
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
category: money
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
# no newer tagged release as of this writing. Re-pin to a tagged release
# once upstream cuts one.
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
pull_policy: if-not-present
network: archy-net
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
# appended as its argv, matching the project's own systemd unit
# (cuprated.service) invocation exactly.
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
# user at build time and runs as it unconditionally (USER 1000:1000,
# no shell to switch users at runtime) — same pattern as
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
# apps/barkd. The bind-mounted data dir must be owned by that literal
# uid or cuprated dies on a permission error the first time it writes.
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
resources:
cpu_limit: 0
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
# headroom above the 8GiB cache for the process itself.
memory_limit: 10Gi
disk_limit: 300Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
# can run fully read-only with an empty capability set.
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
# P2P. Cuprate's own default listen address is already 0.0.0.0
# (p2p.clear_net.listen_on), so no config override is needed — only the
# host-side port differs from Monero's canonical 18080 because that
# number is already taken on this fleet by lnd's REST port.
- host: 18183
container: 18080
protocol: tcp
auth: none
auth_rationale: >-
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
# Unrestricted RPC (full node control) is deliberately NOT published.
# cuprated has no RPC authentication, and for a published port to reach
# it the service would have to bind 0.0.0.0 inside the container — at
# which point every other app can reach it directly on 18081, since
# ports[].bind only restricts the HOST side and podman bridges route to
# each other (verified live 2026-08-22: a peer container on archy-net
# got an unauthenticated get_info, from a *different* network). That is
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
# rpcpassword it writes from generated secrets. So unrestricted RPC is
# left at cuprated's own default — container loopback only, reachable by
# nothing — which is also what upstream intends by refusing a non-local
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
# the gate still takes the port over (loopback pin, external binds,
# fronts the Tor onion) but skips the dashboard login challenge, same
# reasoning as electrumx's port — wallet clients (Feather,
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
# cannot complete a browser login or hold a session cookie. The daemon
# still uses its canonical container port 18089, but Penpot already owns
# host port 18089, so this maps the public host port to the free 18090
# instead.
- host: 18090
container: 18089
protocol: tcp
auth: open
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/cuprate
target: /home/cuprate
options: [rw]
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - fast_sync: cuprated's own default is false, which performs full
# cryptographic verification (ring signatures + RandomX PoW) on every
# incoming block instead of trusting checkpointed history. Root-caused
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
# block rate. Set explicitly rather than relying on the binary
# default so fresh deploys don't silently regress into full-verify.
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
# forced constant eviction/flush (595GB/24h block I/O on a node just
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
# the healthy reference node, and
# resources.memory_limit above raised in step to keep headroom above it.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
# (canonical 18089), and advertise stays false — this node is not
# opting in to being listed as a public remote node over the p2p
# network, just reachable if someone points a wallet at it directly.
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
# default (127.0.0.1) looks like the obviously-correct choice for a
# port meant to stay loopback-only, but verified live (2026-08-21)
# that a service bound literally to 127.0.0.1 *inside* the container
# is unreachable through the host's published port — connections
# reset regardless of how long the daemon has been up. Binding
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
# actual restriction is the same pattern apps/bitcoin-knots already
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
# operator reading Cuprated.toml on disk should be able to see and
# tune the log level directly instead of the file silently omitting
# the whole [tracing] table (verified live on the affected node
# 2026-09-01: the deployed file had no [tracing] section at all, and
# the level was only discoverable by running `cuprated
# --generate-config` and diffing). file.level is set to "info", NOT
# cuprated's own raw default of "debug" — matches the reference dev
# config this app was built and tested against (verified 2026-09-01),
# which deliberately runs file logging quieter
# than the binary default. max_log_files similarly follows that
# reference (14, not the binary default of 7).
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
fast_sync = true
target_max_memory = 8589934592
[rpc.restricted]
enable = true
[tracing.stdout]
level = "info"
[tracing.file]
level = "info"
max_log_files = 14
overwrite: false
health_check:
type: tcp
# Restricted RPC — the only RPC surface published now.
endpoint: localhost:18090
interval: 30s
timeout: 5s
retries: 3
start_period: 5m
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Cuprate
repo: https://github.com/Cuprate/cuprate
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-39
View File
@@ -1,39 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/wallet && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV WALLET_STORAGE=/app/wallet
ENV DWN_ENDPOINT=http://web5-dwn:3000
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# DID Wallet
Web5 wallet with Decentralized Identifier (DID) support.
## Building
```bash
# From the apps directory
./build.sh did-wallet
# Or manually
cd did-wallet
docker build -t archipelago/did-wallet:latest .
```
## Development
```bash
cd did-wallet
npm install
npm run dev
```
## Ports
- **8083**: Web UI (dev: 18083)
## Running Locally
```bash
docker run -p 8083:8080 \
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
-e DWN_ENDPOINT=http://localhost:13000 \
archipelago/did-wallet:latest
```
-56
View File
@@ -1,56 +0,0 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: archipelago/did-wallet:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "did-wallet",
"version": "1.0.0",
"description": "Web5 DID Wallet for Archipelago",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DID Wallet</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Web5 DID Wallet</h1>
<p>Decentralized Identity Wallet for Archipelago</p>
<div id="app">
<p>Wallet interface coming soon...</p>
</div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'did-wallet' });
});
// Wallet API endpoints
app.get('/api/wallet/info', (req, res) => {
res.json({
status: 'ok',
wallet: {
dids: [],
balance: 0
}
});
});
app.post('/api/wallet/did/create', async (req, res) => {
// Placeholder for DID creation
res.json({
status: 'ok',
did: 'did:key:placeholder'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+3
View File
@@ -2,6 +2,9 @@ app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
+6
View File
@@ -2,6 +2,12 @@ app:
id: electrumx
name: ElectrumX
version: 1.18.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: spesmilo/electrumx
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint-clientd
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: fedimint
name: Fedimint Guardian
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+8 -2
View File
@@ -1,11 +1,17 @@
app:
id: filebrowser
name: File Browser
version: 2.27.0
version: 2.63.23
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: filebrowser/filebrowser
description: Baseline Archipelago file manager service.
container:
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23
pull_policy: if-not-present
network: archy-net
custom_args: ["--config", "/data/.filebrowser.json"]
+3
View File
@@ -2,6 +2,9 @@ app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
+28 -5
View File
@@ -1,20 +1,28 @@
app:
id: gitea
name: Gitea
version: "1.23"
version: "1.27.3"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: go-gitea/gitea
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
container:
image: docker.io/gitea/gitea:1.23
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
pull_policy: if-not-present
dependencies:
- storage: 500Mi
# Source history, LFS objects, release artifacts and OCI layers all share
# this persistent store. 500Mi was only suitable for an empty demo node.
- storage: 50Gi
resources:
memory_limit: 256Mi
disk_limit: 500Mi
disk_limit: 50Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
@@ -27,7 +35,16 @@ app:
container: 3000
protocol: tcp
bind: 127.0.0.1
auth: gated
# open, not gated: Gitea carries a complete login of its own, and git
# clients speak HTTP basic-auth — a cookie challenge in front of
# git-over-HTTP breaks every clone/push. The gate still fronts the
# port (iframe header fixes, retry page, Tor); the operator can force
# the dashboard login back on from Settings → Gitea → Access control.
auth: open
auth_rationale: >-
Gitea enforces its own account login on every page and API route;
git clients authenticate with basic-auth/tokens and cannot complete
a browser login challenge.
- host: 2222
container: 22
protocol: tcp
@@ -51,6 +68,12 @@ app:
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__LFS_START_SERVER=true
- GITEA__packages__ENABLED=true
# Package/LFS storage remains bounded by the node's disk, not an arbitrary
# per-owner quota. Release artifacts allow installer/OTA images up to 10GiB.
- GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1
- GITEA__packages__LIMIT_SIZE_CONTAINER=-1
- GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240
- GITEA__repository_0x2Erelease__MAX_FILES=20
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: grafana
name: Grafana
version: 10.2.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: grafana/grafana
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
image: grafana/grafana:10.2.0
image: source.archipelago-foundation.org/lfg2025/grafana:10.2.0
image_signature: cosign://...
pull_policy: if-not-present
data_uid: "472:472"
+8 -2
View File
@@ -1,11 +1,17 @@
app:
id: homeassistant
name: Home Assistant
version: 2026.7.3
version: 2026.8.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: home-assistant/core
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
pull_policy: if-not-present
network: pasta
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich-postgres
name: Immich Postgres
version: "14-vectorchord0.4.3-pgvectors0.2.0"
# Upstream is the Immich-built Postgres image, published only on ghcr.io
# (no GitHub release tags, no Docker Hub repo) — the ghcr fetcher in
# scripts/check-upstream-releases.py is the only one that can see it.
upstream:
kind: ghcr
repo: immich-app/postgres
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
# Container named immich_postgres (underscore) to match the runtime's existing
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich-redis
name: Immich Redis
version: "7-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: valkey/valkey
description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich
name: Immich
version: "2.7.4"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: immich-app/immich
description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
+11 -8
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub-api
name: IndeedHub API
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub backend API (Nostr auth, media, payments).
category: community
@@ -16,14 +19,15 @@ app:
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [api]
# The JWT signing secret is owned here (no backend container owns it); the
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
# only consumed here. ensure_generated_secrets no-ops when a file already
# exists, so live values on .228 are preserved (postgres pw is fixed at
# PGDATA init — regenerating would lock the API out).
# The JWT signing secret and stable envelope-encryption root are owned here;
# the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
# and only consumed here. Existing nodes migrate the legacy AES value into
# the secret file once, while fresh nodes receive a unique per-node value.
generated_secrets:
- name: indeedhub-jwt
kind: hex32
- name: indeedhub-aes-master
kind: hex16
secret_env:
- key: DATABASE_PASSWORD
secret_file: indeedhub-db-password
@@ -31,6 +35,8 @@ app:
secret_file: indeedhub-minio-password
- key: NOSTR_JWT_SECRET
secret_file: indeedhub-jwt
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies:
- app_id: indeedhub-postgres
@@ -64,9 +70,6 @@ app:
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- S3_PUBLIC_BUCKET_URL=/storage
- NOSTR_JWT_EXPIRES_IN=7d
# Fixed across the fleet (envelope-encryption master key baked by the legacy
# installer); not node-specific, so a plain env literal, not a secret.
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
- ENVIRONMENT=production
health_check:
+5 -1
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub background media transcoding worker.
category: community
@@ -19,6 +22,8 @@ app:
secret_file: indeedhub-db-password
- key: AWS_SECRET_KEY
secret_file: indeedhub-minio-password
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies:
- app_id: indeedhub-api
@@ -48,4 +53,3 @@ app:
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- ENVIRONMENT=production
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-minio
name: IndeedHub MinIO
version: "RELEASE.2024-11-07T00-52-20Z"
# MinIO's release tags are date-opaque (RELEASE.YYYY-MM-DD…), so the
# checker reports them as UNCOMPARABLE rather than ordering them — the
# latest tag is still shown for hand comparison, which is the point.
upstream:
kind: github
repo: minio/minio
description: MinIO S3-compatible object storage for IndeedHub media.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-postgres
name: IndeedHub Postgres
version: "16.13-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres database backend for IndeedHub.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-redis
name: IndeedHub Redis
version: "7.4.8-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/redis
description: Redis queue/cache backend for IndeedHub.
category: community

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