Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec67b8f31 | ||
|
|
7b88ba59b2 | ||
|
|
b12d1d3826 | ||
|
|
698e915df2 | ||
|
|
a179df66d8 | ||
|
|
771ff0d28b | ||
|
|
92111385b7 | ||
|
|
a9e52fa310 | ||
|
|
b4714f1773 | ||
|
|
d79ca54019 | ||
|
|
758332d63d | ||
|
|
ee5123af68 | ||
|
|
a624d11b6a | ||
|
|
2c984fbd49 | ||
|
|
a9a30406df |
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}`) },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user