Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c188d9de78 | ||
|
|
37a82fd2f9 | ||
|
|
f1b5d2d267 | ||
|
|
d6b48ce095 |
@@ -0,0 +1,151 @@
|
||||
app:
|
||||
id: cuprate
|
||||
name: Cuprate
|
||||
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
|
||||
# Cuprate has no stable release yet — this is explicitly work-in-progress
|
||||
# software (see upstream README). The image tag below pins the exact
|
||||
# commit built, since "0.1.0-preview" alone is not reproducible.
|
||||
version: 0.1.0-preview
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: Cuprate/cuprate
|
||||
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
|
||||
|
||||
container:
|
||||
# Built from the upstream Dockerfile at the tip of main, 18 commits past
|
||||
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
|
||||
# no newer tagged release as of this writing. Re-pin to a tagged release
|
||||
# once upstream cuts one.
|
||||
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
|
||||
# appended as its argv, matching the project's own systemd unit
|
||||
# (cuprated.service) invocation exactly.
|
||||
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
|
||||
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
|
||||
# user at build time and runs as it unconditionally (USER 1000:1000,
|
||||
# no shell to switch users at runtime) — same pattern as
|
||||
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
|
||||
# apps/barkd. The bind-mounted data dir must be owned by that literal
|
||||
# uid or cuprated dies on a permission error the first time it writes.
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 300Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
|
||||
# can run fully read-only with an empty capability set.
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# P2P. Cuprate's own default listen address is already 0.0.0.0
|
||||
# (p2p.clear_net.listen_on), so no config override is needed — only the
|
||||
# host-side port differs from Monero's canonical 18080 because that
|
||||
# number is already taken on this fleet by lnd's REST port.
|
||||
- host: 18183
|
||||
container: 18080
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
|
||||
# Unrestricted RPC (full node control) is deliberately NOT published.
|
||||
# cuprated has no RPC authentication, and for a published port to reach
|
||||
# it the service would have to bind 0.0.0.0 inside the container — at
|
||||
# which point every other app can reach it directly on 18081, since
|
||||
# ports[].bind only restricts the HOST side and podman bridges route to
|
||||
# each other (verified live 2026-08-22: a peer container on archy-net
|
||||
# got an unauthenticated get_info, from a *different* network). That is
|
||||
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
|
||||
# rpcpassword it writes from generated secrets. So unrestricted RPC is
|
||||
# left at cuprated's own default — container loopback only, reachable by
|
||||
# nothing — which is also what upstream intends by refusing a non-local
|
||||
# bind without an explicit i_know_what_im_doing override.
|
||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||
# what wallets use when connecting to a "remote node". Disabled by
|
||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
||||
# would break wallet clients connecting programmatically, same
|
||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
||||
# container port 18089, but Penpot already owns host port 18089, so this
|
||||
# maps the public host port to the free 18090 instead.
|
||||
- host: 18090
|
||||
container: 18089
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/cuprate
|
||||
target: /home/cuprate
|
||||
options: [rw]
|
||||
|
||||
# Settings that need to differ from cuprated's own documented defaults
|
||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||
# 2026-08-21):
|
||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||
# RAM via sysinfo, which inside a memory-limited container would let
|
||||
# it size caches far past what resources.memory_limit above actually
|
||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||
# so the auth:none host port above actually serves something instead
|
||||
# of refusing every connection. port stays at its documented default
|
||||
# (canonical 18089), and advertise stays false — this node is not
|
||||
# opting in to being listed as a public remote node over the p2p
|
||||
# network, just reachable if someone points a wallet at it directly.
|
||||
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
|
||||
# default (127.0.0.1) looks like the obviously-correct choice for a
|
||||
# port meant to stay loopback-only, but verified live (2026-08-21)
|
||||
# that a service bound literally to 127.0.0.1 *inside* the container
|
||||
# is unreachable through the host's published port — connections
|
||||
# reset regardless of how long the daemon has been up. Binding
|
||||
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
|
||||
# actual restriction is the same pattern apps/bitcoin-knots already
|
||||
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
|
||||
# restricts it externally) — not a new risk, the same one already
|
||||
# reviewed and accepted for Bitcoin's RPC.
|
||||
files:
|
||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||
content: |
|
||||
network = "Mainnet"
|
||||
target_max_memory = 3000000000
|
||||
|
||||
[rpc.restricted]
|
||||
enable = true
|
||||
overwrite: false
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
# Restricted RPC — the only RPC surface published now.
|
||||
endpoint: localhost:18090
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5m
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Cuprate
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
@@ -365,8 +365,18 @@ impl RpcHandler {
|
||||
// after uninstall. The reconciler owns a manifest map independent of
|
||||
// podman state, so a raw `podman rm` alone is not enough.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
let mut teardown_errors = Vec::new();
|
||||
for app_id in orchestrator_uninstall_app_ids(package_id) {
|
||||
let _ = orchestrator.remove(&app_id, preserve_data).await;
|
||||
if let Err(err) = orchestrator.remove(&app_id, preserve_data).await {
|
||||
teardown_errors.push(format!("{app_id}: {err:#}"));
|
||||
}
|
||||
}
|
||||
if !teardown_errors.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Uninstall {} aborted: failed to remove declarative app unit(s): {}",
|
||||
package_id,
|
||||
teardown_errors.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2182,6 +2192,11 @@ mod tests {
|
||||
assert!(!is_missing_container_error("Error: OCI runtime error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_app_uninstall_targets_its_declarative_unit() {
|
||||
assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
|
||||
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
|
||||
|
||||
@@ -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,avail", path])
|
||||
.args(["--block-size=1", "--output=used,size", path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -189,22 +189,11 @@ 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")?;
|
||||
// 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
|
||||
let total: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.parse()
|
||||
.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);
|
||||
.context("parse df total")?;
|
||||
|
||||
Ok((used, total))
|
||||
}
|
||||
|
||||
@@ -4,19 +4,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
let data_line = stdout
|
||||
.lines()
|
||||
@@ -28,19 +18,11 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
// 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
|
||||
let total: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.parse()
|
||||
.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);
|
||||
.context("parse df total")?;
|
||||
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
@@ -62,7 +44,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,avail", data_path])
|
||||
.args(["--block-size=1", "--output=used,size", data_path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -275,8 +257,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_normal() {
|
||||
// df --block-size=1 --output=used,size,avail : used, size, avail
|
||||
let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
|
||||
// Simulates typical df --block-size=1 --output=used,size / output
|
||||
let output = " Used Size\n 500000000000 1000000000000\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);
|
||||
@@ -285,35 +267,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_high_usage() {
|
||||
let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
|
||||
let output = " Used Size\n 900000000000 1000000000000\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 Avail\n999 1000 1\n";
|
||||
let output = "Used Size\n999 1000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 999);
|
||||
assert_eq!(total, 1000);
|
||||
@@ -322,7 +285,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_empty_disk() {
|
||||
let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
|
||||
let output = "Used Size\n0 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -332,7 +295,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 Avail\n0 0 0\n";
|
||||
let output = "Used Size\n0 0\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 0);
|
||||
@@ -375,23 +338,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_extra_whitespace() {
|
||||
let output = " Used Size Avail \n 123456 7890000 7766544 \n";
|
||||
let output = " Used Size \n 123456 7890000 \n";
|
||||
let (used, total, _) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 123456);
|
||||
assert_eq!(total, 7_890_000);
|
||||
assert_eq!(total, 7890000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_real_world_format() {
|
||||
// Closer to real df output with header padding
|
||||
// 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 output = " Used Size\n 328000000000 1800000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 328_000_000_000);
|
||||
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}");
|
||||
assert_eq!(total, 1_800_000_000_000);
|
||||
// ~18.2%
|
||||
assert!(percent > 18.0 && percent < 19.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1746,6 +1746,15 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
// upstream's own safe-for-public
|
||||
// subset that wallets connect to directly as a "remote node" over
|
||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
||||
// (auth: local), not in this set.
|
||||
//
|
||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
||||
// loopback-only JSON API whose own generated http password
|
||||
// authenticates every request (added with the phoenixd onboarding,
|
||||
@@ -1762,7 +1771,7 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
26,
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
@@ -181,7 +181,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
startRemoteRelay()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
screensaverStore.clearInactivityTimer()
|
||||
screensaverStore.deactivate()
|
||||
stopRemoteRelay()
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
@@ -10,7 +9,6 @@ vi.mock('vue-router', () => ({
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
getReceivedMessages: vi.fn(),
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -23,16 +21,13 @@ 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: '', contactId: null }
|
||||
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -148,43 +143,9 @@ 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: '', contactId: null }
|
||||
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -21,7 +20,6 @@ let firedThisSession = false
|
||||
*/
|
||||
export function useIbdFinishWatcher() {
|
||||
const goalStore = useGoalStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -70,16 +68,8 @@ 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(
|
||||
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.",
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
{
|
||||
label: 'Finish setup',
|
||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -15,19 +14,11 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
|
||||
const receivedMessages = ref<ReceivedMessage[]>([])
|
||||
const lastMessageCount = ref(0)
|
||||
const loadingMessages = ref(false)
|
||||
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())
|
||||
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
|
||||
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)
|
||||
@@ -49,7 +40,6 @@ 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 {
|
||||
@@ -65,26 +55,6 @@ 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 {
|
||||
@@ -116,21 +86,16 @@ export function useMessageToast() {
|
||||
}
|
||||
|
||||
function dismissToastAndOpenMessages() {
|
||||
const { fromPubkey: peer, contactId } = toastMessage.value
|
||||
toastMessage.value = emptyToast()
|
||||
const peer = toastMessage.value.fromPubkey
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
markAsRead()
|
||||
// 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 {
|
||||
// Open the specific conversation when we know the sender; else the mesh list.
|
||||
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss the toast without navigating (the close icon).
|
||||
function closeToast() {
|
||||
toastMessage.value = emptyToast()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -288,27 +288,12 @@ 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'
|
||||
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))
|
||||
const lastSeenId = ref<Record<number, number>>(
|
||||
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
|
||||
)
|
||||
// First run after this feature ships: treat existing history as seen so
|
||||
// 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
|
||||
// nobody gets a wall of phantom badges for months-old messages.
|
||||
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
|
||||
function persistLastSeen() {
|
||||
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
|
||||
}
|
||||
@@ -496,18 +481,17 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages(limit?: number): Promise<MeshMessage[]> {
|
||||
async function fetchMessages(limit?: number) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
|
||||
method: 'mesh.messages',
|
||||
params: limit ? { limit } : {},
|
||||
dedup: true,
|
||||
})
|
||||
if (seedLastSeenFromHistory) {
|
||||
if (seedLastSeenFromHistory && res.messages.length > 0) {
|
||||
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
|
||||
}
|
||||
@@ -536,15 +520,8 @@ 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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -565,13 +565,7 @@ 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 : ''
|
||||
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) {
|
||||
if (targetPeer) {
|
||||
const match = mesh.peers.find(
|
||||
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user