Compare commits

..
Author SHA1 Message Date
archipelago 3ec67b8f31 chore(apps): mirror-backed bumps — vaultwarden, nbxplorer, home-assistant (PENDING)
Prepared patch-level bumps with no data migration, held here until the
three images can be mirrored to source.archipelago-foundation.org/lfg2025
(currently blocked: the only registry-push credential on record for the
lfg2025 namespace is dead, and the ai account cannot push user-namespace
packages it does not own):

  vaultwarden       1.37.1-alpine -> 1.37.2-alpine  (docker.io/vaultwarden/server)
  archy-nbxplorer   2.6.0         -> 2.6.11         (docker.io/nicolasdorier/nbxplorer)
  home-assistant    2026.8.2      -> 2026.8.3       (docker.io/homeassistant/home-assistant)

All three target tags verified present upstream. To land: mirror the
images, merge this branch into main, regenerate + re-sign the catalog.
2026-08-30 16:23:05 -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
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
39 changed files with 816 additions and 86 deletions
+16 -4
View File
@@ -52,13 +52,13 @@
{ {
"id": "btcpay-server", "id": "btcpay-server",
"title": "BTCPay Server", "title": "BTCPay Server",
"version": "2.4.2", "version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png", "icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation", "author": "BTCPay Server Foundation",
"category": "commerce", "category": "commerce",
"tier": "core", "tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2", "dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver", "repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [ "requires": [
"bitcoin-knots" "bitcoin-knots"
@@ -378,7 +378,7 @@
"icon": "/assets/img/app-icons/pine.svg", "icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago", "author": "Archipelago",
"category": "home", "category": "home",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine", "dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming" "repoUrl": "https://github.com/rhasspy/wyoming"
}, },
{ {
@@ -464,7 +464,7 @@
"author": "NetBird", "author": "NetBird",
"category": "networking", "category": "networking",
"tier": "recommended", "tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine", "dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird", "repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -571,6 +571,18 @@
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0", "dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd" "repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"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"
} }
] ]
} }
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: archy-nbxplorer id: archy-nbxplorer
name: NBXplorer name: NBXplorer
version: 2.6.0 version: 2.6.11
# Where this app comes from, so scripts/check-upstream-releases.py can # 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: # 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. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: BTCPay blockchain indexer service. description: BTCPay blockchain indexer service.
container: container:
image: source.archipelago-foundation.org/lfg2025/nbxplorer:2.6.0 image: source.archipelago-foundation.org/lfg2025/nbxplorer:2.6.11
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
secret_env: secret_env:
+8
View File
@@ -2,6 +2,14 @@ app:
id: barkd id: barkd
name: Ark Wallet name: Ark Wallet
version: 0.3.0 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. 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: container:
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: btcpay-server id: btcpay-server
name: 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 # 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: # 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. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries. description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container: container:
image: docker.io/btcpayserver/btcpayserver:2.4.2 image: docker.io/btcpayserver/btcpayserver:2.4.3
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
secret_env: secret_env:
+6
View File
@@ -13,6 +13,12 @@ app:
kind: github kind: github
repo: Cuprate/cuprate 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. description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
category: money
metadata:
icon: /assets/img/app-icons/cuprate.svg
repo: https://github.com/Cuprate/cuprate
tier: optional
container: container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past # Built from the upstream Dockerfile at the tip of main, 18 commits past
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: homeassistant id: homeassistant
name: Home Assistant name: Home Assistant
version: 2026.7.3 version: 2026.8.3
# Where this app comes from, so scripts/check-upstream-releases.py can # 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: # 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. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Open source home automation platform. Control and monitor your smart home devices. description: Open source home automation platform. Control and monitor your smart home devices.
container: container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2 image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich-postgres id: immich-postgres
name: Immich Postgres name: Immich Postgres
version: "14-vectorchord0.4.3-pgvectors0.2.0" 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. description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
# Container named immich_postgres (underscore) to match the runtime's existing # Container named immich_postgres (underscore) to match the runtime's existing
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-minio id: indeedhub-minio
name: IndeedHub MinIO name: IndeedHub MinIO
version: "RELEASE.2024-11-07T00-52-20Z" 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. description: MinIO S3-compatible object storage for IndeedHub media.
category: community category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: lightning-stack id: lightning-stack
name: Lightning Stack name: Lightning Stack
version: 0.12.0 version: 0.12.0
# No public listing exists for lightninglabs/lightning-stack (checked
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
# so this one is tracked by hand.
upstream:
kind: manual
url: no public listing for lightninglabs/lightning-stack — verify by hand
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools. description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container: container:
+1 -1
View File
@@ -18,7 +18,7 @@ app:
container_name: netbird container_name: netbird
container: container:
image: docker.io/library/nginx:1.31.3-alpine image: docker.io/library/nginx:1.31.4-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: netbird-net network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a # Self-signed TLS cert materialised before create — the dashboard needs a
+8
View File
@@ -6,6 +6,14 @@ app:
# pick up the args change; the pre-release form "3.4.1-1" would compare # pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out. # LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2" version: "3.4.2"
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
# project's GitHub tags are not the image tags). NOTE: this manifest
# deliberately ships an args-tuned revision AHEAD of the image tag (see
# comment above) — BEHIND here means the image tag moved and the tuned
# revision needs re-basing onto it, not just a pin bump.
upstream:
kind: dockerhub
repo: rhasspy/wyoming-whisper
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist. description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home category: home
+1 -1
View File
@@ -19,7 +19,7 @@ app:
container_name: pine container_name: pine
container: container:
image: docker.io/library/nginx:1.31.3-alpine image: docker.io/library/nginx:1.31.4-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
network_aliases: [pine] network_aliases: [pine]
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: strfry id: strfry
name: Strfry Nostr Relay name: Strfry Nostr Relay
version: 1.1.1 version: 1.1.2
# Where this app comes from, so scripts/check-upstream-releases.py can # 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: # 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. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage. description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container: container:
image: dockurr/strfry:1.1.1 image: dockurr/strfry:1.1.2
image_signature: cosign://... image_signature: cosign://...
pull_policy: verify-signature pull_policy: verify-signature
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: vaultwarden id: vaultwarden
name: Vaultwarden name: Vaultwarden
version: 1.30.0 version: 1.37.2
# Where this app comes from, so scripts/check-upstream-releases.py can # 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: # 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. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Self-hosted password vault with zero-knowledge encryption. description: Self-hosted password vault with zero-knowledge encryption.
container: container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
@@ -405,9 +405,17 @@ impl RpcHandler {
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let device_type = svc.shared_state().status.read().await.device_type; let device_type = svc.shared_state().status.read().await.device_type;
// Resource transfer is a native RNS transfer over LoRa — it needs an
// actual radio route to this contact, not just a Reticulum device on
// our end. A federation-only peer with no radio twin fits the size
// and device-type checks but has no dest_prefix to send to; without
// this check the send falls into send_content_resource and fails
// with "Peer is federation-only (no radio twin)" (picture-send,
// 2026-08-07) instead of falling back to the federation path below.
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
&& device_type == crate::mesh::types::DeviceType::Reticulum && device_type == crate::mesh::types::DeviceType::Reticulum
&& bytes.len() <= RETICULUM_RESOURCE_MAX; && bytes.len() <= RETICULUM_RESOURCE_MAX
&& svc.has_radio_route(contact_id).await;
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer { if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
anyhow::bail!( anyhow::bail!(
@@ -491,6 +499,48 @@ impl RpcHandler {
seq, seq,
) )
.await? .await?
} else {
// Federation-only peers have no radio twin for
// send_typed_wire's LoRa dest-prefix resolution — route over
// Tor federation instead, mirroring mesh.send-content's onion
// lookup, or the send fails with "Peer is federation-only (no
// radio twin)" (picture-send from a federation-only contact,
// 2026-08-07).
let federation_onion = {
let state = svc.shared_state();
let peers = state.peers.read().await;
peers
.get(&contact_id)
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
};
let federation_onion = match federation_onion {
Some((Some(pubkey_hex), did)) => {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
nodes
.iter()
.find(|n| n.pubkey == pubkey_hex)
.map(|n| n.onion.clone())
.or_else(|| {
did.as_ref().and_then(|d| {
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
})
})
}
_ => None,
};
if let Some(onion) = federation_onion {
svc.send_typed_wire_via_federation(
contact_id,
&onion,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
} else { } else {
svc.send_typed_wire( svc.send_typed_wire(
contact_id, contact_id,
@@ -502,6 +552,7 @@ impl RpcHandler {
) )
.await? .await?
} }
}
}; };
info!( info!(
@@ -590,6 +641,16 @@ impl RpcHandler {
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1); let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum; let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
// A Reticulum device on our end doesn't mean THIS peer is radio
// reachable — a federation-only contact (no radio twin) has no dest
// prefix for a resource transfer, even though it's small enough and
// our device type qualifies. Without this check the frontend was
// steered into mesh.send-content-inline's resource-transfer path,
// which fails with "Peer is federation-only (no radio twin)"
// (picture-send, 2026-08-07); the tier below now defers to the
// has_tor branches for such peers, which route via mesh.send-content
// (federation) instead.
let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await;
let (tier, reason) = if size <= MESH_AUTO_MAX { let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh") ("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX { } else if size <= MESH_HARD_MAX {
@@ -598,7 +659,7 @@ impl RpcHandler {
} else { } else {
("auto-mesh", "No Tor path — sending inline over mesh") ("auto-mesh", "No Tor path — sending inline over mesh")
} }
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX { } else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
( (
"resource-mesh", "resource-mesh",
"Sending directly over LoRa via a Reticulum resource transfer", "Sending directly over LoRa via a Reticulum resource transfer",
+15 -4
View File
@@ -168,7 +168,7 @@ pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
/// Read disk usage via `df` for a given path. /// Read disk usage via `df` for a given path.
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> { pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
let output = tokio::process::Command::new("df") let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", path]) .args(["--block-size=1", "--output=used,size,avail", path])
.output() .output()
.await .await
.context("Failed to run df")?; .context("Failed to run df")?;
@@ -189,11 +189,22 @@ pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))? .ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse() .parse()
.context("parse df used")?; .context("parse df used")?;
let total: u64 = parts // Raw `size` includes the filesystem's root-reserved blocks (5% by default
// on ext4 — 92 GiB of this node's 1.8 TiB), which nothing can allocate.
// Reporting it as capacity told the dashboard there were 251 GiB free when
// only 159 GiB were writable. Callers derive free as total - used, so total
// must mean "what can actually be used".
let _size: u64 = parts
.next() .next()
.ok_or_else(|| anyhow::anyhow!("Missing total"))? .ok_or_else(|| anyhow::anyhow!("Missing size"))?
.parse() .parse()
.context("parse df total")?; .context("parse df size")?;
let avail: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
.parse()
.context("parse df avail")?;
let total = used.saturating_add(avail);
Ok((used, total)) Ok((used, total))
} }
+58 -19
View File
@@ -4,9 +4,19 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tracing::{info, warn}; use tracing::{info, warn};
/// Parse df output into (used_bytes, total_bytes, used_percent). /// Parse df output into (used_bytes, usable_total_bytes, used_percent).
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line /// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
/// followed by a data line with two whitespace-separated numbers. /// followed by used, size and avail.
///
/// `size` is deliberately NOT the denominator. ext4 reserves 5% of the
/// filesystem for root — 92 GiB on archi-dev-box's 1.8 TiB disk — which `size`
/// counts but no ordinary process can ever allocate. Dividing by `size`
/// under-reports usage by about five points: on 2026-08-22 that disk was
/// genuinely 90.8% full (159 GiB usable left) while this returned 86.2%, so the
/// 90% auto-cleanup below had never once fired and ~72 GB of dangling images
/// had accumulated. It also meant the dashboard advertised 251 GiB free when
/// only 159 GiB could actually be written. used/(used+avail) is what `df`
/// itself prints and what the operator can actually spend.
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> { fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
let data_line = stdout let data_line = stdout
.lines() .lines()
@@ -18,11 +28,19 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))? .ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse() .parse()
.context("parse df used")?; .context("parse df used")?;
let total: u64 = parts // Parsed to keep the column contract explicit, then intentionally unused —
// see the note above on why raw size is the wrong denominator.
let _size: u64 = parts
.next() .next()
.ok_or_else(|| anyhow::anyhow!("Missing total"))? .ok_or_else(|| anyhow::anyhow!("Missing size"))?
.parse() .parse()
.context("parse df total")?; .context("parse df size")?;
let avail: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
.parse()
.context("parse df avail")?;
let total = used.saturating_add(avail);
let percent = if total > 0 { let percent = if total > 0 {
(used as f64 / total as f64) * 100.0 (used as f64 / total as f64) * 100.0
@@ -44,7 +62,7 @@ pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
"/" "/"
}; };
let output = tokio::process::Command::new("df") let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size", data_path]) .args(["--block-size=1", "--output=used,size,avail", data_path])
.output() .output()
.await .await
.context("Failed to run df")?; .context("Failed to run df")?;
@@ -257,8 +275,8 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_normal() { fn test_parse_df_output_normal() {
// Simulates typical df --block-size=1 --output=used,size / output // df --block-size=1 --output=used,size,avail : used, size, avail
let output = " Used Size\n 500000000000 1000000000000\n"; let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 500_000_000_000); assert_eq!(used, 500_000_000_000);
assert_eq!(total, 1_000_000_000_000); assert_eq!(total, 1_000_000_000_000);
@@ -267,16 +285,35 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_high_usage() { fn test_parse_df_output_high_usage() {
let output = " Used Size\n 900000000000 1000000000000\n"; let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 900_000_000_000); assert_eq!(used, 900_000_000_000);
assert_eq!(total, 1_000_000_000_000); assert_eq!(total, 1_000_000_000_000);
assert!((percent - 90.0).abs() < 0.01); assert!((percent - 90.0).abs() < 0.01);
} }
/// The bug this function existed to hide: reserved blocks are counted by
/// `size` but are not available to anyone. Real numbers from archi-dev-box,
/// 2026-08-22 — 1.8 TiB disk, ext4 5% reserve, genuinely 90.8% full. The old
/// used/size math returned 86.2%, so the 90% auto-cleanup never triggered.
#[test]
fn reserved_blocks_are_not_counted_as_free() {
let output = "Used Size Avail\n1681459122176 1951249276928 170581372928\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 1_681_459_122_176);
// Total is what can actually be written, not the raw device size.
assert_eq!(total, 1_852_040_495_104);
assert!(
total < 1_951_249_276_928,
"raw size must not be the denominator"
);
assert!((percent - 90.8).abs() < 0.1, "got {percent}");
assert!(percent >= 90.0, "must cross the auto-cleanup threshold");
}
#[test] #[test]
fn test_parse_df_output_almost_full() { fn test_parse_df_output_almost_full() {
let output = "Used Size\n999 1000\n"; let output = "Used Size Avail\n999 1000 1\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 999); assert_eq!(used, 999);
assert_eq!(total, 1000); assert_eq!(total, 1000);
@@ -285,7 +322,7 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_empty_disk() { fn test_parse_df_output_empty_disk() {
let output = "Used Size\n0 1000000000000\n"; let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 0); assert_eq!(used, 0);
assert_eq!(total, 1_000_000_000_000); assert_eq!(total, 1_000_000_000_000);
@@ -295,7 +332,7 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_zero_total() { fn test_parse_df_output_zero_total() {
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero) // Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
let output = "Used Size\n0 0\n"; let output = "Used Size Avail\n0 0 0\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 0); assert_eq!(used, 0);
assert_eq!(total, 0); assert_eq!(total, 0);
@@ -338,21 +375,23 @@ mod tests {
#[test] #[test]
fn test_parse_df_output_extra_whitespace() { fn test_parse_df_output_extra_whitespace() {
let output = " Used Size \n 123456 7890000 \n"; let output = " Used Size Avail \n 123456 7890000 7766544 \n";
let (used, total, _) = parse_df_output(output).unwrap(); let (used, total, _) = parse_df_output(output).unwrap();
assert_eq!(used, 123456); assert_eq!(used, 123456);
assert_eq!(total, 7890000); assert_eq!(total, 7_890_000);
} }
#[test] #[test]
fn test_parse_df_output_real_world_format() { fn test_parse_df_output_real_world_format() {
// Closer to real df output with header padding // Closer to real df output with header padding
let output = " Used Size\n 328000000000 1800000000000\n"; // Real df output carries a reserved-block gap: size here is 1.8 TB but
// only 1.382 TB is available, so usable total is used + avail.
let output = " Used Size Avail\n 328000000000 1800000000000 1382000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap(); let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 328_000_000_000); assert_eq!(used, 328_000_000_000);
assert_eq!(total, 1_800_000_000_000); assert_eq!(total, 1_710_000_000_000);
// ~18.2% // ~19.2% against usable space, not 18.2% against the raw device.
assert!(percent > 18.0 && percent < 19.0); assert!(percent > 19.0 && percent < 20.0, "got {percent}");
} }
#[tokio::test] #[tokio::test]
+13
View File
@@ -1222,6 +1222,19 @@ impl MeshService {
Ok(dest_prefix) Ok(dest_prefix)
} }
/// True if `contact_id` is reachable over the mesh radio right now — the
/// same peer/twin resolution `peer_dest_prefix` performs, exposed as a
/// cheap bool so RPC handlers can gate radio-only transports (LXMF
/// native image, Reticulum resource transfer) without duplicating the
/// twin-resolution logic. A federation-only contact_id with no matching
/// radio twin returns false here — offering "resource-mesh" or native
/// image to such a peer sends it straight into `peer_dest_prefix`'s
/// "federation-only (no radio twin)" error (picture-send from a
/// federation-only contact, 2026-08-07).
pub async fn has_radio_route(&self, contact_id: u32) -> bool {
self.peer_dest_prefix(contact_id).await.is_ok()
}
/// Split an oversized wire payload into MC-framed base64 chunks and send /// Split an oversized wire payload into MC-framed base64 chunks and send
/// each via the mesh device. Matches the receive-side reassembly in /// each via the mesh device. Matches the receive-side reassembly in
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`, /// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
+1
View File
@@ -86,4 +86,5 @@ file.
## Roadmap & history ## Roadmap & history
- [Roadmap](ROADMAP.md) — where the project is going - [Roadmap](ROADMAP.md) — where the project is going
- [TODO](TODO.md) — working backlog of unscoped forward-looking items
- [archive/](archive/README.md) — superseded design and status documents, kept for provenance - [archive/](archive/README.md) — superseded design and status documents, kept for provenance
+52
View File
@@ -0,0 +1,52 @@
# TODO
Working backlog of forward-looking items not yet scoped into a dedicated plan
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
## Dev & build process (priority)
- Formalize the contributor workflow: releases, CI, maintainers, automated
builds, PR/issue flow, branch naming, and reproducible builds.
## Federation & peering
- Peering trust model — define tiers (trusted / public / private / peered)
on top of the existing federation DID trust levels.
- Federation architecture built on the above peering model.
## Distributed git & OTA
- Nostr-hosted git for the alpha (see
[`nostr-git-source-hosting.md`](nostr-git-source-hosting.md)).
- Distributed git beyond the nostr-hosting case.
- Distributed OTA / app delivery.
## Nostr integration
- Nostr signer integration.
## Platform / OS
- Source-availability ISO — define the build/distribution story.
- HW/OS update pipeline.
- Deeper OpenWRT integration.
- GrapheneOS integration — backups, attestation, profiles.
## App ecosystem
- Full pass testing every app in the catalog; expect issues across the board.
- App update strategy — finalize the update policy referenced in
[`app-developer-guide.md`](app-developer-guide.md) (pinned vs. mutable
tags, catalog-vs-disk precedence, rollout/rollback).
- App wishlist — candidates not yet packaged: Cashu wallet, phoenixd.
(CLN is already shipped as `apps/core-lightning`.)
## Access & security
- SSH access strategy — define the access model (keys, rotation, recovery
path, remote-support access).
## Observability
- Capture error logs to troubleshoot customer issues.
- Stats & visualization for traffic, blocked attacks, VPNs, routing.
+4 -4
View File
@@ -52,13 +52,13 @@
{ {
"id": "btcpay-server", "id": "btcpay-server",
"title": "BTCPay Server", "title": "BTCPay Server",
"version": "2.4.2", "version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png", "icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation", "author": "BTCPay Server Foundation",
"category": "commerce", "category": "commerce",
"tier": "core", "tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2", "dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver", "repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [ "requires": [
"bitcoin-knots" "bitcoin-knots"
@@ -378,7 +378,7 @@
"icon": "/assets/img/app-icons/pine.svg", "icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago", "author": "Archipelago",
"category": "home", "category": "home",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine", "dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming" "repoUrl": "https://github.com/rhasspy/wyoming"
}, },
{ {
@@ -464,7 +464,7 @@
"author": "NetBird", "author": "NetBird",
"category": "networking", "category": "networking",
"tier": "recommended", "tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine", "dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird", "repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
+1 -1
View File
@@ -181,7 +181,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
startRemoteRelay() startRemoteRelay()
} else { } else {
messageToast.stopPolling() messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' } toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
screensaverStore.clearInactivityTimer() screensaverStore.clearInactivityTimer()
screensaverStore.deactivate() screensaverStore.deactivate()
stopRemoteRelay() stopRemoteRelay()
+17 -4
View File
@@ -50,6 +50,7 @@ const showRevealModal = ref(false)
const revealPassword = ref('') const revealPassword = ref('')
const revealCode = ref('') const revealCode = ref('')
const revealPassphrase = ref('') const revealPassphrase = ref('')
const showRevealPassphrase = ref(false)
const revealing = ref(false) const revealing = ref(false)
const revealError = ref('') const revealError = ref('')
const revealedWords = ref<string[]>([]) const revealedWords = ref<string[]>([])
@@ -60,6 +61,7 @@ function openReveal() {
revealPassword.value = '' revealPassword.value = ''
revealCode.value = '' revealCode.value = ''
revealPassphrase.value = '' revealPassphrase.value = ''
showRevealPassphrase.value = false
revealError.value = '' revealError.value = ''
revealedWords.value = [] revealedWords.value = []
showRevealModal.value = true showRevealModal.value = true
@@ -83,7 +85,17 @@ async function submitReveal() {
// to set up a backup that now exists. // to set up a backup that now exists.
void loadStatus() void loadStatus()
} catch (e: unknown) { } catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase' const message = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
// Most operators used their login password as the backup passphrase. Do
// not confront everyone with an unexplained third credential up front;
// disclose it only when the authenticated password could not decrypt the
// node seed and a distinct setup-time passphrase may actually exist.
if (!status.value?.active && /could not decrypt the saved seed/i.test(message)) {
showRevealPassphrase.value = true
revealError.value = 'Your login password did not unlock the saved seed. Enter the separate backup passphrase you chose during setup.'
} else {
revealError.value = message
}
} finally { } finally {
revealing.value = false revealing.value = false
} }
@@ -95,6 +107,7 @@ function closeReveal() {
revealPassword.value = '' revealPassword.value = ''
revealCode.value = '' revealCode.value = ''
revealPassphrase.value = '' revealPassphrase.value = ''
showRevealPassphrase.value = false
} }
async function copyRevealedWords() { async function copyRevealedWords() {
@@ -376,9 +389,9 @@ async function restoreFromPhrase() {
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label> <label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" /> <input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div> </div>
<div v-if="!status?.active"> <div v-if="showRevealPassphrase">
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label> <label class="block text-xs text-white/60 mb-1">Separate backup passphrase</label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" /> <input v-model="revealPassphrase" type="password" autocomplete="off" autofocus class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Passphrase chosen during setup" />
</div> </div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p> <p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1"> <div class="flex gap-2 pt-1">
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import EcashSeedBackup from '../EcashSeedBackup.vue'
let wrapper: VueWrapper | null = null
describe('EcashSeedBackup reveal credentials (#127)', () => {
beforeEach(() => {
document.body.innerHTML = ''
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
it('asks for a separate backup passphrase only after password decryption fails', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({
active: false,
source: null,
can_activate: true,
derivable_from_node_seed: true,
})
.mockRejectedValueOnce(new Error(
'Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.',
))
wrapper = mount(EcashSeedBackup, { attachTo: document.body })
await flushPromises()
await wrapper.get('button').trigger('click')
expect(document.body.textContent).not.toContain('Separate backup passphrase')
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
password.value = 'login-password'
password.dispatchEvent(new Event('input', { bubbles: true }))
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
expect(document.body.textContent).toContain('Separate backup passphrase')
expect(document.body.textContent).toContain('Your login password did not unlock the saved seed')
expect(document.body.querySelector('input[placeholder="Passphrase chosen during setup"]')).not.toBeNull()
})
})
@@ -0,0 +1,150 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick } from 'vue'
// Controllable doubles shared between the hoisted block and the mock
// factories. Plain holders — each test writes to them before importing the
// composable under a fresh module registry (the watcher keeps a module-level
// `firedThisSession` session guard, so every case needs its own module).
const state = vi.hoisted(() => ({
packages: {} as Record<string, unknown>,
goalStatus: 'in-progress',
goalProgress: {} as Record<string, { completedSteps: string[] }>,
toastAction: vi.fn(),
routerPush: vi.fn(),
// Re-bound every time the useBitcoinSync factory is (re)evaluated; holds the
// exact refs the freshly imported composable watches.
syncRefs: null as null | { synced: { value: boolean }; loaded: { value: boolean } },
}))
vi.mock('@/composables/useBitcoinSync', async () => {
const { ref } = await import('vue')
const synced = ref(false)
const loaded = ref(false)
state.syncRefs = { synced, loaded }
return {
bitcoinSynced: synced,
bitcoinSyncLoaded: loaded,
acquireBitcoinSync: () => () => {},
}
})
vi.mock('@/stores/goals', () => ({
useGoalStore: () => ({
getGoalStatus: () => state.goalStatus,
progress: state.goalProgress,
}),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
get packages() {
return state.packages
},
}),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ action: state.toastAction }),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: state.routerPush }),
}))
/**
* Fresh module registry → fresh `firedThisSession`, then mount the composable
* inside a real component so its watchers live in a proper effect scope.
*/
async function mountWatcher() {
vi.resetModules()
const { useIbdFinishWatcher } = await import('../useIbdFinishWatcher')
const Host = defineComponent({
setup() {
useIbdFinishWatcher()
return () => null
},
})
return mount(Host)
}
/** Drive a real unsynced→synced transition through the mocked sync refs. */
async function completeSync() {
const refs = state.syncRefs!
refs.loaded.value = true
refs.synced.value = false // the watcher must observe unsynced at least once
await nextTick()
refs.synced.value = true
await nextTick()
await nextTick()
}
describe('useIbdFinishWatcher', () => {
beforeEach(() => {
state.packages = {}
state.goalStatus = 'in-progress'
state.goalProgress = {}
state.toastAction.mockClear()
state.routerPush.mockClear()
})
it('says to install LND next when Lightning is not installed yet (#143)', async () => {
// Bitcoin synced mid-goal, but the goal's install-LND step is still
// pending: the on-chain wallet lives in LND, so "fund your wallet" would
// promise a flow that cannot work yet.
state.packages = { 'bitcoin-knots': { state: 'running' } }
const wrapper = await mountWatcher()
await completeSync()
expect(state.toastAction).toHaveBeenCalledTimes(1)
const [message, opts] = state.toastAction.mock.calls[0]!
expect(message).toBe(
"Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
)
expect(opts.label).toBe('Finish setup')
opts.onClick()
// "Finish setup" lands on the goal wizard, whose active step is the
// pending install-LND one — the correct next action.
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
wrapper.unmount()
})
it('says to fund the wallet when LND is already installed', async () => {
state.packages = { 'bitcoin-knots': { state: 'running' }, lnd: { state: 'running' } }
const wrapper = await mountWatcher()
await completeSync()
expect(state.toastAction).toHaveBeenCalledTimes(1)
const [message, opts] = state.toastAction.mock.calls[0]!
expect(message).toBe(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
)
expect(opts.label).toBe('Finish setup')
opts.onClick()
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
wrapper.unmount()
})
it('stays silent when no Lightning goal is in progress', async () => {
state.goalStatus = 'not-started'
const wrapper = await mountWatcher()
await completeSync()
expect(state.toastAction).not.toHaveBeenCalled()
wrapper.unmount()
})
it('stays silent when the chain was already synced at page load', async () => {
// A node that's already synced never shows unsynced this session, so the
// toast must not fire (it only marks real IBD-completion transitions).
const wrapper = await mountWatcher()
const refs = state.syncRefs!
refs.loaded.value = true
refs.synced.value = true
await nextTick()
await nextTick()
expect(state.toastAction).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockPush = vi.fn() const mockPush = vi.fn()
@@ -9,6 +10,7 @@ vi.mock('vue-router', () => ({
vi.mock('@/api/rpc-client', () => ({ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
getReceivedMessages: vi.fn(), getReceivedMessages: vi.fn(),
call: vi.fn(),
}, },
})) }))
@@ -21,13 +23,16 @@ describe('useMessageToast', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers() vi.useFakeTimers()
localStorage.clear()
setActivePinia(createPinia())
vi.mocked(rpcClient.call).mockResolvedValue({ messages: [], count: 0 })
// Reset shared singleton state // Reset shared singleton state
const toast = useMessageToast() const toast = useMessageToast()
toast.stopPolling() toast.stopPolling()
toast.receivedMessages.value = [] toast.receivedMessages.value = []
toast.lastMessageCount.value = 0 toast.lastMessageCount.value = 0
toast.loadingMessages.value = false toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' } toast.toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
}) })
afterEach(() => { afterEach(() => {
@@ -143,9 +148,43 @@ describe('useMessageToast', () => {
expect(toast.unreadCount.value).toBe(0) expect(toast.unreadCount.value).toBe(0)
}) })
it('shows a radio-mesh toast and deep-links to its contact', async () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
// Initialize an empty node, then deliver its first Meshtastic message.
await toast.loadReceivedMessages()
vi.mocked(rpcClient.call).mockResolvedValueOnce({
messages: [{
id: 1,
direction: 'received',
peer_contact_id: 42,
peer_name: 'Alice',
plaintext: 'Over LoRa',
timestamp: '2026-01-01',
delivered: true,
encrypted: true,
transport: 'meshtastic',
}],
count: 1,
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value).toMatchObject({
show: true,
text: 'Over LoRa',
contactId: 42,
})
toast.dismissToastAndOpenMessages()
expect(mockPush).toHaveBeenCalledWith({
path: '/dashboard/mesh',
query: { contact: '42' },
})
})
it('dismissToastAndOpenMessages clears toast and navigates', () => { it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast() const toast = useMessageToast()
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' } toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '', contactId: null }
toast.dismissToastAndOpenMessages() toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false) expect(toast.toastMessage.value.show).toBe(false)
@@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { GOALS } from '@/data/goals' import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals' import { useGoalStore } from '@/stores/goals'
import { useAppStore } from '@/stores/app'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { import {
acquireBitcoinSync, acquireBitcoinSync,
@@ -20,6 +21,7 @@ let firedThisSession = false
*/ */
export function useIbdFinishWatcher() { export function useIbdFinishWatcher() {
const goalStore = useGoalStore() const goalStore = useGoalStore()
const appStore = useAppStore()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -68,8 +70,16 @@ export function useIbdFinishWatcher() {
const goalId = pendingLightningGoalId.value const goalId = pendingLightningGoalId.value
if (!goalId) return if (!goalId) return
firedThisSession = true firedThisSession = true
// The on-chain wallet lives in LND, not Bitcoin Core — the address the
// fund flow shows comes from `lnd.newaddress`. While the goal's
// install-LND step is still pending, "fund your wallet" would point at
// something that doesn't exist yet, so the toast names the actual next
// step instead (issue #143).
const lndInstalled = Object.keys(appStore.packages).includes('lnd')
toast.action( toast.action(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.', lndInstalled
? 'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.'
: "Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
{ {
label: 'Finish setup', label: 'Finish setup',
onClick: () => { router.push(`/dashboard/goals/${goalId}`) }, onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
+40 -5
View File
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useMeshStore } from '@/stores/mesh'
export interface ReceivedMessage { export interface ReceivedMessage {
from_pubkey: string from_pubkey: string
@@ -14,11 +15,19 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
const receivedMessages = ref<ReceivedMessage[]>([]) const receivedMessages = ref<ReceivedMessage[]>([])
const lastMessageCount = ref(0) const lastMessageCount = ref(0)
const loadingMessages = ref(false) const loadingMessages = ref(false)
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' }) type MessageToast = {
show: boolean
text: string
fromPubkey: string
contactId: number | null
}
const emptyToast = (): MessageToast => ({ show: false, text: '', fromPubkey: '', contactId: null })
const toastMessage = ref<MessageToast>(emptyToast())
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
export function useMessageToast() { export function useMessageToast() {
const router = useRouter() const router = useRouter()
const mesh = useMeshStore()
const unreadCount = computed(() => const unreadCount = computed(() =>
Math.max(0, receivedMessages.value.length - lastMessageCount.value) Math.max(0, receivedMessages.value.length - lastMessageCount.value)
@@ -40,6 +49,7 @@ export function useMessageToast() {
// Only deep-link to a specific chat when it's a single new message // Only deep-link to a specific chat when it's a single new message
// from one sender; otherwise open the mesh list. // from one sender; otherwise open the mesh list.
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '', fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
contactId: null,
} }
lastMessageCount.value = msgs.length lastMessageCount.value = msgs.length
} else { } else {
@@ -55,6 +65,26 @@ export function useMessageToast() {
} finally { } finally {
loadingMessages.value = false loadingMessages.value = false
} }
// Federation messages and radio-mesh messages use separate backend
// queues. Poll the mesh store too so Meshtastic/MeshCore/Reticulum
// arrivals produce the same app-wide toast. fetchMessages returns only
// the newly-unread batch computed from its durable per-contact watermark.
const newMeshMessages = await mesh.fetchMessages()
if (newMeshMessages.length > 0) {
const latest = newMeshMessages[newMeshMessages.length - 1]!
const oneConversation = newMeshMessages.every(
msg => msg.peer_contact_id === latest.peer_contact_id
)
toastMessage.value = {
show: true,
text: newMeshMessages.length === 1
? latest.plaintext
: `${newMeshMessages.length} new messages`,
fromPubkey: '',
contactId: oneConversation ? latest.peer_contact_id : null,
}
}
} }
function isAuthenticated(): boolean { function isAuthenticated(): boolean {
@@ -86,16 +116,21 @@ export function useMessageToast() {
} }
function dismissToastAndOpenMessages() { function dismissToastAndOpenMessages() {
const peer = toastMessage.value.fromPubkey const { fromPubkey: peer, contactId } = toastMessage.value
toastMessage.value = { show: false, text: '', fromPubkey: '' } toastMessage.value = emptyToast()
markAsRead() markAsRead()
// Open the specific conversation when we know the sender; else the mesh list. // Open the exact radio conversation by contact id, or the federation
// conversation by pubkey. Multiple conversations fall back to the list.
if (contactId !== null) {
router.push({ path: '/dashboard/mesh', query: { contact: String(contactId) } })
} else {
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh') router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
} }
}
// Dismiss the toast without navigating (the close icon). // Dismiss the toast without navigating (the close icon).
function closeToast() { function closeToast() {
toastMessage.value = { show: false, text: '', fromPubkey: '' } toastMessage.value = emptyToast()
} }
return { return {
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import { useMeshStore, type MeshMessage } from '../mesh'
const message = (id: number, contact = 7): MeshMessage => ({
id,
direction: 'received',
peer_contact_id: contact,
peer_name: 'Alice',
plaintext: `message ${id}`,
timestamp: `2026-01-${String(id).padStart(2, '0')}`,
delivered: true,
encrypted: true,
transport: 'meshtastic',
})
function reply(messages: MeshMessage[]) {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ messages, count: messages.length })
}
describe('mesh unread persistence', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('does not swallow the first message after initializing with empty history', async () => {
const store = useMeshStore()
reply([])
expect(await store.fetchMessages()).toEqual([])
expect(localStorage.getItem('archipelago.mesh.last-seen.v1')).toBe('{}')
reply([message(1)])
expect(await store.fetchMessages()).toEqual([message(1)])
expect(store.unreadCounts[7]).toBe(1)
})
it('keeps read messages read across a page refresh', async () => {
const firstPage = useMeshStore()
reply([message(1), message(2)])
await firstPage.fetchMessages() // migration seeds existing history as read
firstPage.markChatRead(7)
setActivePinia(createPinia()) // simulate a full page/store reload
const refreshedPage = useMeshStore()
reply([message(1), message(2), message(3)])
const newlyUnread = await refreshedPage.fetchMessages()
expect(newlyUnread.map(m => m.id)).toEqual([3])
expect(refreshedPage.unreadCounts[7]).toBe(1)
refreshedPage.markChatRead(7)
setActivePinia(createPinia())
const readAgain = useMeshStore()
reply([message(1), message(2), message(3)])
expect(await readAgain.fetchMessages()).toEqual([])
expect(readAgain.totalUnread).toBe(0)
})
})
+30 -7
View File
@@ -288,12 +288,27 @@ export const useMeshStore = defineStore('mesh', () => {
// are safe watermarks: the backend allocates them monotonically and // are safe watermarks: the backend allocates them monotonically and
// restores the counter as max(persisted)+1 across restarts. // restores the counter as max(persisted)+1 across restarts.
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1' const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
const lastSeenId = ref<Record<number, number>>( let storedLastSeen = localStorage.getItem(LAST_SEEN_KEY)
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number> function parseLastSeen(raw: string | null): Record<number, number> {
) if (!raw) return {}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<number, number>
}
} catch {
// Treat corrupt browser state like a first run and safely reseed it.
}
storedLastSeen = null
return {}
}
const lastSeenId = ref<Record<number, number>>(parseLastSeen(storedLastSeen))
// First run after this feature ships: treat existing history as seen so // First run after this feature ships: treat existing history as seen so
// nobody gets a wall of phantom badges for months-old messages. // nobody gets a wall of phantom badges for months-old messages. Complete
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null // this initialization even when history is empty; otherwise the first real
// message to arrive on a brand-new node is mistaken for old history and its
// notification is silently swallowed.
let seedLastSeenFromHistory = storedLastSeen === null
function persistLastSeen() { function persistLastSeen() {
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value)) localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
} }
@@ -481,17 +496,18 @@ export const useMeshStore = defineStore('mesh', () => {
} }
} }
async function fetchMessages(limit?: number) { async function fetchMessages(limit?: number): Promise<MeshMessage[]> {
try { try {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({ const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages', method: 'mesh.messages',
params: limit ? { limit } : {}, params: limit ? { limit } : {},
dedup: true, dedup: true,
}) })
if (seedLastSeenFromHistory && res.messages.length > 0) { if (seedLastSeenFromHistory) {
for (const m of res.messages) { for (const m of res.messages) {
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id) if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
} }
// Persist even an empty object as the initialization sentinel.
persistLastSeen() persistLastSeen()
seedLastSeenFromHistory = false seedLastSeenFromHistory = false
} }
@@ -520,8 +536,15 @@ export const useMeshStore = defineStore('mesh', () => {
messages.value = res.messages messages.value = res.messages
// Extract node positions from coordinate messages // Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages) updateNodePositionsFromMessages(res.messages)
// The app-wide notification poll uses this exact batch, rather than a
// session message-count delta, so one arrival can never resurrect old
// messages as "11 unread" after a refresh.
return newMsgs.filter(msg => !(
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
))
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages' error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
return []
} }
} }
+7 -1
View File
@@ -565,7 +565,13 @@ function armMeshLive() {
// match an entry in mesh.peers, so without this fallback the deep-link // match an entry in mesh.peers, so without this fallback the deep-link
// silently failed and just landed on the bare mesh page every time. // silently failed and just landed on the bare mesh page every time.
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : '' const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
if (targetPeer) { const targetContact = typeof route.query.contact === 'string'
? Number(route.query.contact)
: NaN
if (Number.isInteger(targetContact)) {
const match = mesh.peers.find(p => p.contact_id === targetContact)
if (match) openChat(match)
} else if (targetPeer) {
const match = mesh.peers.find( const match = mesh.peers.find(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer (p) => p.pubkey_hex === targetPeer || p.did === targetPeer
) )
@@ -51,6 +51,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"botfights": "BotFights", "botfights": "BotFights",
"btcpay-server": "BTCPay Server", "btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)", "core-lightning": "Core Lightning (CLN)",
"cuprate": "Cuprate",
"did-wallet": "Web5 DID Wallet", "did-wallet": "Web5 DID Wallet",
"electrs-ui": "Electrs UI", "electrs-ui": "Electrs UI",
"electrumx": "ElectrumX", "electrumx": "ElectrumX",
@@ -26,7 +26,8 @@
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'" :class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tierLabel }}</span> >{{ tierLabel }}</span>
</h3> </h3>
<p class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p> <p v-if="!isMultiVersion" class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
<p v-else class="text-sm text-white/60">Choose version when installing</p>
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p> <p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
</div> </div>
</div> </div>
@@ -175,7 +176,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { MarketplaceApp, InstallProgress } from './marketplaceData' import { MULTI_VERSION_APP_IDS, type MarketplaceApp, type InstallProgress } from './marketplaceData'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig' import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
const { t } = useI18n() const { t } = useI18n()
@@ -200,6 +201,8 @@ defineEmits<{
launch: [app: MarketplaceApp] launch: [app: MarketplaceApp]
}>() }>()
const isMultiVersion = computed(() => MULTI_VERSION_APP_IDS.has(props.app.id))
const signatureLabel = computed(() => { const signatureLabel = computed(() => {
switch (props.app.signature?.status) { switch (props.app.signature?.status) {
case 'valid': return 'signed' case 'valid': return 'signed'
@@ -15,7 +15,7 @@ const app: MarketplaceApp = {
source: 'community', source: 'community',
} }
function mountCard(installed: boolean, installBlockedReason?: string) { function mountCard(installed: boolean, installBlockedReason?: string, appOverride: MarketplaceApp = app) {
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,
locale: 'en', locale: 'en',
@@ -29,7 +29,7 @@ function mountCard(installed: boolean, installBlockedReason?: string) {
return mount(MarketplaceAppCard, { return mount(MarketplaceAppCard, {
props: { props: {
app, app: appOverride,
index: 0, index: 0,
stagger: false, stagger: false,
installed, installed,
@@ -65,4 +65,16 @@ describe('MarketplaceAppCard', () => {
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.') expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
expect(wrapper.text()).toContain('Bitcoin Pruned') expect(wrapper.text()).toContain('Bitcoin Pruned')
}) })
it('does not present one catalog version as definitive for multi-version apps (#129)', () => {
const wrapper = mountCard(false, undefined, {
...app,
id: 'bitcoin-core',
title: 'Bitcoin Core',
version: '28.4.0',
})
expect(wrapper.text()).toContain('Choose version when installing')
expect(wrapper.text()).not.toContain('28.4')
})
}) })
@@ -57,6 +57,12 @@ export interface InstallProgress {
attempt: number attempt: number
} }
/** Apps that ask for their concrete version in InstallVersionModal. Their
* store tiles deliberately omit a single catalog version: showing “v28.4”
* there implies that is the only version immediately before asking the user
* to choose a different one. */
export const MULTI_VERSION_APP_IDS = new Set(['bitcoin-knots', 'bitcoin-core'])
/** Archipelago app registry — all app images are mirrored here */ /** Archipelago app registry — all app images are mirrored here */
const REGISTRY = 'source.archipelago-foundation.org/lfg2025' const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import OpenWrtGateway from './OpenWrtGateway.vue'
describe('OpenWrtGateway stale cached router recovery (#103)', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
it('offers reconfiguration when the saved router can no longer connect', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Connection timed out'))
const wrapper = mount(OpenWrtGateway, {
global: { plugins: [createPinia()] },
})
await flushPromises()
const reconfigure = wrapper.findAll('button').find(button => button.text() === 'Reconfigure router')
expect(reconfigure).toBeDefined()
await reconfigure!.trigger('click')
expect(wrapper.text()).toContain('Connect to Router')
expect(wrapper.text()).not.toContain('Connection timed out')
wrapper.unmount()
})
})
+5 -1
View File
@@ -260,6 +260,7 @@ function pickDetectedRouter(ip: string) {
function disconnectRouter() { function disconnectRouter() {
host.value = status.value?.host ?? host.value host.value = status.value?.host ?? host.value
connectedParams.value = null connectedParams.value = null
error.value = ''
detectError.value = '' detectError.value = ''
detectedCandidates.value = [] detectedCandidates.value = []
showConnectForm.value = true showConnectForm.value = true
@@ -533,7 +534,10 @@ onMounted(() => {
<!-- Error state --> <!-- Error state -->
<div v-else-if="error" class="glass-card p-6 mb-4"> <div v-else-if="error" class="glass-card p-6 mb-4">
<p class="text-sm text-red-300">{{ error }}</p> <p class="text-sm text-red-300">{{ error }}</p>
<button class="mt-3 text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button> <div class="mt-3 flex items-center gap-4">
<button class="text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
<button class="text-xs text-orange-300/80 hover:text-orange-200 transition-colors underline" @click="disconnectRouter">Reconfigure router</button>
</div>
</div> </div>
<!-- Status panels --> <!-- Status panels -->
+45 -1
View File
@@ -39,6 +39,7 @@ import os
import re import re
import sys import sys
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -191,7 +192,50 @@ def _highest(tags: list[str], current: str = "") -> str:
return max(ranked)[1] return max(ranked)[1]
FETCHERS = {"github": latest_github, "dockerhub": latest_dockerhub} def latest_gitlab(project: str, current: str = "") -> str:
"""Newest release tag for a GitLab `group/project`.
Some projects publish releases only on GitLab with no GitHub mirror
(bark lives at ark-bitcoin/bark and nowhere else). GitLab release tags
sometimes carry the project name as a prefix (`bark-0.6.2`); strip it so
version ordering can see the number.
"""
esc = urllib.parse.quote(project, safe="")
releases = http_json(
f"https://gitlab.com/api/v4/projects/{esc}/releases?per_page=100"
)
tags = [str(r["tag_name"]) for r in releases]
prefix = project.rsplit("/", 1)[-1].lower() + "-"
tags = [t[len(prefix):] if t.lower().startswith(prefix) else t for t in tags]
return _highest(tags, current)
def latest_ghcr(repo: str, current: str = "") -> str:
"""Newest version-like tag on GitHub's container registry.
Some images exist only on ghcr.io (immich-app/postgres publishes there
and nowhere else), so neither the GitHub-release nor the Docker Hub
fetcher can see them. Anonymous pull token first, then the tag list —
the same handshake any `docker pull ghcr.io/...` performs.
"""
token = http_json(
f"https://ghcr.io/token?scope=repository:{repo}:pull&service=ghcr.io"
)["token"]
req = urllib.request.Request(
f"https://ghcr.io/v2/{repo}/tags/list",
headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as res: # noqa: S310
tags = [str(t) for t in json.loads(res.read().decode()).get("tags", [])]
return _highest(tags, current)
FETCHERS = {
"github": latest_github,
"dockerhub": latest_dockerhub,
"gitlab": latest_gitlab,
"ghcr": latest_ghcr,
}
# ── Manifest reading ─────────────────────────────────────────────────────── # ── Manifest reading ───────────────────────────────────────────────────────
+4 -4
View File
@@ -37,19 +37,19 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.3.1"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10" MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay # BTCPay
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2" BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.3"
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0" NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.11"
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17" POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17" BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
# Apps # Apps
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.8.2" HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.8.3"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0" GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1" UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.11.11" JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.11.11"
PHOTOPRISM_IMAGE="$ARCHY_REGISTRY/photoprism:240915" PHOTOPRISM_IMAGE="$ARCHY_REGISTRY/photoprism:240915"
OLLAMA_IMAGE="$ARCHY_REGISTRY/ollama:latest" OLLAMA_IMAGE="$ARCHY_REGISTRY/ollama:latest"
VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.37.1-alpine" VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.37.2-alpine"
NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29" NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest" SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail) # OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)