Compare commits

...
Author SHA1 Message Date
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
21 changed files with 694 additions and 61 deletions
@@ -405,9 +405,17 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
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
&& 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 {
anyhow::bail!(
@@ -492,15 +500,58 @@ impl RpcHandler {
)
.await?
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
// 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 {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
}
}
};
@@ -590,6 +641,16 @@ impl RpcHandler {
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;
// 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 {
("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX {
@@ -598,7 +659,7 @@ impl RpcHandler {
} else {
("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",
"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.
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
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()
.await
.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"))?
.parse()
.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()
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
.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))
}
+58 -19
View File
@@ -4,9 +4,19 @@
use anyhow::{Context, Result};
use tracing::{info, warn};
/// Parse df output into (used_bytes, total_bytes, used_percent).
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line
/// followed by a data line with two whitespace-separated numbers.
/// Parse df output into (used_bytes, usable_total_bytes, used_percent).
/// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
/// 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)> {
let data_line = stdout
.lines()
@@ -18,11 +28,19 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse()
.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()
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
.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 {
(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")
.args(["--block-size=1", "--output=used,size", data_path])
.args(["--block-size=1", "--output=used,size,avail", data_path])
.output()
.await
.context("Failed to run df")?;
@@ -257,8 +275,8 @@ mod tests {
#[test]
fn test_parse_df_output_normal() {
// Simulates typical df --block-size=1 --output=used,size / output
let output = " Used Size\n 500000000000 1000000000000\n";
// df --block-size=1 --output=used,size,avail : used, size, avail
let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 500_000_000_000);
assert_eq!(total, 1_000_000_000_000);
@@ -267,16 +285,35 @@ mod tests {
#[test]
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();
assert_eq!(used, 900_000_000_000);
assert_eq!(total, 1_000_000_000_000);
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]
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();
assert_eq!(used, 999);
assert_eq!(total, 1000);
@@ -285,7 +322,7 @@ mod tests {
#[test]
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();
assert_eq!(used, 0);
assert_eq!(total, 1_000_000_000_000);
@@ -295,7 +332,7 @@ mod tests {
#[test]
fn test_parse_df_output_zero_total() {
// 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();
assert_eq!(used, 0);
assert_eq!(total, 0);
@@ -338,21 +375,23 @@ mod tests {
#[test]
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();
assert_eq!(used, 123456);
assert_eq!(total, 7890000);
assert_eq!(total, 7_890_000);
}
#[test]
fn test_parse_df_output_real_world_format() {
// 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();
assert_eq!(used, 328_000_000_000);
assert_eq!(total, 1_800_000_000_000);
// ~18.2%
assert!(percent > 18.0 && percent < 19.0);
assert_eq!(total, 1_710_000_000_000);
// ~19.2% against usable space, not 18.2% against the raw device.
assert!(percent > 19.0 && percent < 20.0, "got {percent}");
}
#[tokio::test]
+13
View File
@@ -1222,6 +1222,19 @@ impl MeshService {
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
/// each via the mesh device. Matches the receive-side reassembly in
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
+1
View File
@@ -86,4 +86,5 @@ file.
## Roadmap & history
- [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
+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.
+1 -1
View File
@@ -181,7 +181,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
startRemoteRelay()
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' }
toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
screensaverStore.clearInactivityTimer()
screensaverStore.deactivate()
stopRemoteRelay()
+17 -4
View File
@@ -50,6 +50,7 @@ const showRevealModal = ref(false)
const revealPassword = ref('')
const revealCode = ref('')
const revealPassphrase = ref('')
const showRevealPassphrase = ref(false)
const revealing = ref(false)
const revealError = ref('')
const revealedWords = ref<string[]>([])
@@ -60,6 +61,7 @@ function openReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
showRevealPassphrase.value = false
revealError.value = ''
revealedWords.value = []
showRevealModal.value = true
@@ -83,7 +85,17 @@ async function submitReveal() {
// to set up a backup that now exists.
void loadStatus()
} 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 {
revealing.value = false
}
@@ -95,6 +107,7 @@ function closeReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
showRevealPassphrase.value = false
}
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>
<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 v-if="!status?.active">
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></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" />
<div v-if="showRevealPassphrase">
<label class="block text-xs text-white/60 mb-1">Separate backup passphrase</label>
<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>
<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">
@@ -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 { createPinia, setActivePinia } from 'pinia'
const mockPush = vi.fn()
@@ -9,6 +10,7 @@ vi.mock('vue-router', () => ({
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
getReceivedMessages: vi.fn(),
call: vi.fn(),
},
}))
@@ -21,13 +23,16 @@ describe('useMessageToast', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
localStorage.clear()
setActivePinia(createPinia())
vi.mocked(rpcClient.call).mockResolvedValue({ messages: [], count: 0 })
// Reset shared singleton state
const toast = useMessageToast()
toast.stopPolling()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 0
toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
toast.toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
})
afterEach(() => {
@@ -143,9 +148,43 @@ describe('useMessageToast', () => {
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', () => {
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()
expect(toast.toastMessage.value.show).toBe(false)
@@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import { useAppStore } from '@/stores/app'
import { useToast } from '@/composables/useToast'
import {
acquireBitcoinSync,
@@ -20,6 +21,7 @@ let firedThisSession = false
*/
export function useIbdFinishWatcher() {
const goalStore = useGoalStore()
const appStore = useAppStore()
const router = useRouter()
const toast = useToast()
@@ -68,8 +70,16 @@ export function useIbdFinishWatcher() {
const goalId = pendingLightningGoalId.value
if (!goalId) return
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(
'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',
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
+41 -6
View File
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useMeshStore } from '@/stores/mesh'
export interface ReceivedMessage {
from_pubkey: string
@@ -14,11 +15,19 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
const receivedMessages = ref<ReceivedMessage[]>([])
const lastMessageCount = ref(0)
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
export function useMessageToast() {
const router = useRouter()
const mesh = useMeshStore()
const unreadCount = computed(() =>
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
// from one sender; otherwise open the mesh list.
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
contactId: null,
}
lastMessageCount.value = msgs.length
} else {
@@ -55,6 +65,26 @@ export function useMessageToast() {
} finally {
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 {
@@ -86,16 +116,21 @@ export function useMessageToast() {
}
function dismissToastAndOpenMessages() {
const peer = toastMessage.value.fromPubkey
toastMessage.value = { show: false, text: '', fromPubkey: '' }
const { fromPubkey: peer, contactId } = toastMessage.value
toastMessage.value = emptyToast()
markAsRead()
// Open the specific conversation when we know the sender; else the mesh list.
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
// 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')
}
}
// Dismiss the toast without navigating (the close icon).
function closeToast() {
toastMessage.value = { show: false, text: '', fromPubkey: '' }
toastMessage.value = emptyToast()
}
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
// restores the counter as max(persisted)+1 across restarts.
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
const lastSeenId = ref<Record<number, number>>(
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
)
let storedLastSeen = localStorage.getItem(LAST_SEEN_KEY)
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
// nobody gets a wall of phantom badges for months-old messages.
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
// nobody gets a wall of phantom badges for months-old messages. Complete
// 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() {
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 {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
dedup: true,
})
if (seedLastSeenFromHistory && res.messages.length > 0) {
if (seedLastSeenFromHistory) {
for (const m of res.messages) {
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
}
// Persist even an empty object as the initialization sentinel.
persistLastSeen()
seedLastSeenFromHistory = false
}
@@ -520,8 +536,15 @@ export const useMeshStore = defineStore('mesh', () => {
messages.value = res.messages
// Extract node positions from coordinate 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) {
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
// silently failed and just landed on the bare mesh page every time.
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(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
)
@@ -26,7 +26,8 @@
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tierLabel }}</span>
</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>
</div>
</div>
@@ -175,7 +176,7 @@
<script setup lang="ts">
import { computed } from 'vue'
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'
const { t } = useI18n()
@@ -200,6 +201,8 @@ defineEmits<{
launch: [app: MarketplaceApp]
}>()
const isMultiVersion = computed(() => MULTI_VERSION_APP_IDS.has(props.app.id))
const signatureLabel = computed(() => {
switch (props.app.signature?.status) {
case 'valid': return 'signed'
@@ -15,7 +15,7 @@ const app: MarketplaceApp = {
source: 'community',
}
function mountCard(installed: boolean, installBlockedReason?: string) {
function mountCard(installed: boolean, installBlockedReason?: string, appOverride: MarketplaceApp = app) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -29,7 +29,7 @@ function mountCard(installed: boolean, installBlockedReason?: string) {
return mount(MarketplaceAppCard, {
props: {
app,
app: appOverride,
index: 0,
stagger: false,
installed,
@@ -65,4 +65,16 @@ describe('MarketplaceAppCard', () => {
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
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
}
/** 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 */
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() {
host.value = status.value?.host ?? host.value
connectedParams.value = null
error.value = ''
detectError.value = ''
detectedCandidates.value = []
showConnectForm.value = true
@@ -533,7 +534,10 @@ onMounted(() => {
<!-- Error state -->
<div v-else-if="error" class="glass-card p-6 mb-4">
<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>
<!-- Status panels -->