Compare commits

..
Author SHA1 Message Date
DorianandClaude Fable 5 df403c5a69 fix(cloud): music opens the bottom-bar player, never the lightbox
FileCard audio clicks now emit play (global GlobalAudioPlayer bar) —
wired through the FileGrid list view, the Cloud My Files list and own
search results. Peer Files already used the bottom bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:23:57 +01:00
ai a34634e00f Merge pull request 'feat(demo): intro on every fresh visit + neutral desktop Cloud tabs' (#87) from demo-intro-every-visit into main
Demo images / Build & push demo images (push) Successful in 2m35s
2026-07-15 10:13:30 +00:00
DorianandClaude Fable 5 c13ee58edf feat(demo): typing splash + intro on every fresh visit; neutral desktop Cloud tabs
- Demo boots at '/' (first visit or a browser refresh) always start with
  the typing splash into the onboarding intro — the per-calendar-day
  gate is gone. In-session SPA navigation never replays it, and
  deep-route refreshes keep the visitor's place.
- Cloud top-level tabs return to the standard mode-switcher style on
  desktop; the orange full-width switcher stays mobile-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:12:49 +01:00
archipelagoandClaude Fable 5 6e5a99ef71 fix(tests): CloudPeersRefresh mounts Cloud with Pinia (useCloudStore in setup)
Demo images / Build & push demo images (push) Successful in 2m33s
The cloud tabs rework added useCloudStore() to Cloud.vue's setup; this
older test mounted without a Pinia instance and failed the release gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:05:53 -04:00
archipelagoandClaude Fable 5 749c351e5e docs(release): v1.7.101-alpha notes + What's New sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:59:53 -04:00
archipelagoandClaude Fable 5 dc897064cd fix(iso): derive ARCHIPELAGO_HOST_IP from the default route, not hostname -I
Demo images / Build & push demo images (push) Successful in 2m39s
The unit's ExecStartPre baked the first hostname -I token into
host-ip.env, which config.rs prefers over its own detection — once a
VPN/bridge interface existed (netbird's wg tunnel), every host_ip
consumer (install configs, VPN, mint URLs) got the tunnel IP. Read the
main-table default route's src address instead, falling back to
hostname -I on routeless hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 178ba85c5c fix(system): hostname rename updates /etc/hosts and re-announces mDNS
server.set-name ran hostnamectl but left Debian's 127.0.1.1 line on the
old name (breaking sudo's 'unable to resolve host' and hostname -f) and
waited on avahi to notice the change by itself. Sync /etc/hosts and
avahi-set-host-name (fallback: try-restart avahi-daemon) right after a
successful rename, so http(s)://<hostname>.local works immediately —
best-effort, never blocks the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 0f5ea9ae15 fix(tor): resolve app ports dynamically + auto-create hidden service on install
'Add Service' only worked for the 19 apps in the hardcoded
known_service_port map — everything else failed with 'Unknown app'.
tor.create-service and tor.toggle-app now fall back to the app's live
launch address from the scanner state, so any manifest-driven app gets
a .onion without a per-app entry (the static map still wins for
protocol services like bitcoin, which must expose 8333, not a UI port).
toggle-app also no longer writes a broken port-0 HiddenServicePort for
unknown apps.

Every successful package.install now auto-creates the app's hidden
service (detached, best-effort, retries while the scanner derives the
port; skips protocol services and existing entries).

The demo mock gains stateful tor.create-service/tor.delete-service so
the demo round-trips instead of erroring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 5d4d40e9e8 style: cargo fmt the Ark wallet merge (release-gate fmt guard)
Formatting only — no logic changes to PR #78.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
ai 8caa4c7d07 Merge pull request 'fix(ui): restore first-login dashboard entrance + working Replay Intro' (#86) from intro-entrance-fixes into main
Demo images / Build & push demo images (push) Successful in 2m36s
2026-07-15 09:49:14 +00:00
DorianandClaude Fable 5 4983ba4e20 fix(ui): first-login dashboard entrance restored + working Replay Intro
- playDashboardLoadOomph gated itself silent on the one entry that
  should be loud: onboarding is already flagged complete by the time the
  post-wizard dashboard mounts. force flag restores the full oomph there
  while keeping ordinary re-logins quiet (the intentional limit).
- OnboardingDone now hands Login a one-shot finale flag, so the login
  right after the wizard gets the full zoom + oomph even as a regular
  password login — which is exactly the demo flow (and TOTP logins).
- 'Replay intro' on the login screen actually replays: App.vue was
  instantly re-marking the intro as seen on onboarded nodes; an explicit
  one-shot replay flag now overrides every suppression rule (unit
  tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:48:39 +01:00
ai 994795e4d2 Merge pull request 'feat(cloud): tab/list feedback + rich demo peer content' (#85) from cloud-feedback-demo-content into main
Demo images / Build & push demo images (push) Successful in 2m33s
2026-07-15 09:36:33 +00:00
DorianandClaude Fable 5 4226b5ec0a feat(demo): rich peer content library with never-broken previews
24-item catalog across films/series/books/music/photography/documents/
software with full metadata; deterministic per-peer subsets so every
node shares a different mix. Posters/covers/photos are committed JPEGs
(demo/peer-media, sourced via picsum.photos); previews always serve real
image bytes, and free music/photos/documents stream the actual committed
files so playback genuinely works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:35:42 +01:00
DorianandClaude Fable 5 8f6312fe7b feat(cloud): Folders/My Files/Peer Files polish + readable file rows
- Top-level Cloud tabs get their own orange-tinted switcher (clearly
  distinct from the category pills); full-width thirds on mobile
- 'All Files' tab renamed Folders; categories only show on the file-list
  tabs (My Files / Peer Files / search)
- My Files is now a flat list of every own file rendered with FileCard —
  real actions (share/download/delete) and clicking opens the FILE
  (media lightbox incl. audio, downloads for documents), never a folder;
  own search results get the same treatment
- Folder list rows get card backgrounds (readable over the wallpaper),
  same info as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:35:42 +01:00
ai bdf283fcff Merge pull request 'feat(cloud): Apps-style tabs, categories and federated file search' (#84) from cloud-tabs-search into main
Demo images / Build & push demo images (push) Successful in 2m37s
2026-07-15 09:02:56 +00:00
DorianandClaude Fable 5 df90cdaac9 fix(cloud): satisfy noUncheckedIndexedAccess in categoryMeta fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:02:35 +01:00
DorianandClaude Fable 5 0cf91136f9 feat(cloud): Apps-style tabs, categories and federated file search
Cloud gets the same header layout as Apps: All Files / My Files / Peer
Files tabs, Photos & Video / Music / Documents category filter, and a
search bar that queries your own FileBrowser sections (depth-limited
walk) plus every federation peer's shared catalog (content.browse-peer
fan-out, cached), with a loader while it runs. Peer Files lists the
actual files from all peers with attribution. Mobile uses the Apps
category-pill strips (which already opt out of the page-switch swipe)
with search below — no fixed top tabs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:01:36 +01:00
ai 6d7b39578e Merge pull request 'feat(wallet-ui): Ark send/receive/pay-with + mobile tx modal height' (#83) from ark-wallet-ui-demo into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-15 08:54:24 +00:00
DorianandClaude Fable 5 13909e28bf fix(ui): cap transactions modal at 60% of the live viewport on mobile
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:53:53 +01:00
DorianandClaude Fable 5 97488c83f7 feat(wallet-ui): Ark send/receive tabs + Ark pay-with option on peer files
Send modal gains an Ark tab (address/invoice/lightning-address via
wallet.ark-send), Receive gains an Ark address + QR tab, and the peer
file purchase confirm offers Ark alongside Cashu/Fedimint. Demo mock
exposes ark_sats in wallet.ecash-balance and deducts the chosen rail on
paid downloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:53:53 +01:00
ai eaf8b7b63e Merge pull request 'fix(demo): nginx cache regex was 404ing all /app/ shell assets' (#82) from demo-nginx-app-assets into main
Demo images / Build & push demo images (push) Successful in 2m35s
2026-07-15 08:45:44 +00:00
DorianandClaude Fable 5 46f7ac3fcf fix(demo): stop the static-cache regex from swallowing /app/ assets
nginx regex locations outrank prefix locations, so app-shell assets like
/app/bitcoin-core/tailwind.css and /app/lnd/qrcode.js hit the .css/.js
cache block and 404'd from the web root instead of proxying to the mock
backend — the actual cause of the unstyled Bitcoin UI (and the shabby
lnd-ui) on the demo. ^~ on the /app/ prefixes disables regex matching
for those paths. Config validated with nginx -t.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:44:56 +01:00
ai 76ad14ef64 Merge pull request 'fix(demo): Bitcoin UI styling + placeholder LND dashboard' (#81) from demo-ui-fixes into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-15 08:27:50 +00:00
DorianandClaude Fable 5 15b99a65e0 feat(demo): placeholder LND dashboard instead of the real lnd-ui shell
The official shell reads poorly inside the demo iframe; serve a
demoAppShell dashboard (balances, channels, node URI) consistent with
the /proxy/lnd/v1/* mocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:25:38 +01:00
DorianandClaude Fable 5 c49de3eb01 fix(demo): relative asset paths in bitcoin-ui/fedimint-ui shells
The shells are served at / in their containers but under /app/<id>/ in
the public demo, where absolute /tailwind.css and /assets/... 404 — the
Bitcoin UI rendered unstyled with a giant unconstrained SVG. Relative
paths resolve correctly in both contexts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:25:38 +01:00
archipelagoandClaude Fable 5 2f78fb6907 fix(ui): DNS apply no longer blanks the page + WiFi/DNS modals cover the whole app
Demo images / Build & push demo images (push) Successful in 2m39s
Applying DNS assigned the RPC response's fields into networkData without
guarding the shape; the demo mock answered {success:true} with no
servers array, dnsServers became undefined, and the dnsDisplayLabel
computed crashed the whole page render on .length. Guard the assignment,
teach the mock to round-trip DNS state per session, and Teleport the
WiFi + DNS modals to body so they overlay the full app instead of just
the right panel (position:fixed is containing-block-relative inside the
dashboard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 04:21:39 -04:00
archipelagoandClaude Fable 5 2fce4fb842 fix(network): derive the host IP from the default route, not hostname -I order
On a fresh ISO node, NetBird's own WireGuard tunnel (10.44.0.1) sorted
ahead of the real NIC in hostname -I, so the NetBird launch URL (and the
{{HOST_IP}} baked into its cert/config) pointed at the tunnel instead of
the LAN address. The main routing table's default route names the
physical uplink even while a VPN is active (NetBird/Tailscale steer
traffic via policy-routing rules, not the main table), so read src/dev
from 'ip -4 route show default' first, then fall back to a connected UDP
socket's source address, then to the old hostname -I scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 04:21:39 -04:00
ai 24be2e9e69 Merge pull request 'feat(wallet): Ark protocol support (barkd sidecar) + wallet UI' (#78) from ark-wallet-barkd into main
Demo images / Build & push demo images (push) Successful in 2m38s
2026-07-14 21:09:58 +00:00
DorianandClaude Fable 5 768c358546 feat(ui): Ark chip in transaction rail filter (shown when Ark txs exist)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:08:56 +01:00
Dorian 128c13e965 Merge remote-tracking branch 'origin/main' into ark-merge 2026-07-14 22:08:55 +01:00
DorianandClaude Fable 5 5642aae530 chore(ui): drop unused DISPLAY_MODE_KEY that broke vue-tsc build
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:57:12 +01:00
DorianandClaude Fable 5 6fa0aa46a9 feat(ui): Ark wallet tab, balances, history badge + demo mocks
Ark tab in Wallet Settings (status, balances, receive address,
board/offboard, server config via wallet.ark-*), teal Ark badge in the
transactions modal, Ark row on the home wallet card (hidden until barkd
reports a balance), official bark icon, and wallet.ark-* mocks so the
public demo renders the tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:57:12 +01:00
DorianandClaude Fable 5 bdb9826aba feat(wallet): Ark protocol support via barkd sidecar
barkd (Ark wallet daemon, pinned 0.3.0, checksum-verified release binary)
packaged as an installable app; thin HTTP bridge in wallet/ark_client.rs
mirrors the fedimint_client pattern — the bark SDK stays out of the node
binary. wallet.ark-* RPCs cover status/balance/address/send/invoice/
board/offboard/history/configure; Ark movements merge into the unified
ecash history (kind="ark") and spendable Ark sats into total_sats.
Signet defaults (Second's public Ark server) until Ark matures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:56:21 +01:00
75 changed files with 2501 additions and 176 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## v1.7.101-alpha (2026-07-15)
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
- Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.
- The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
- The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
## v1.7.100-alpha (2026-07-14)
- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.
+19
View File
@@ -298,6 +298,25 @@
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
+32
View File
@@ -0,0 +1,32 @@
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
# No official upstream image exists (their GitLab registry is empty), so we
# package the pinned, checksum-verified release binary ourselves and push to
# the node registry — same approach as fmcd. Keep the version in lockstep with
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
FROM debian:bookworm-slim
ARG BARKD_VERSION=0.3.0
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
-o /usr/local/bin/barkd && \
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
chmod a+x /usr/local/bin/barkd && \
apt-get purge -y curl && apt-get autoremove -y && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod a+x /entrypoint.sh
# The wallet itself is created over REST by the node's Ark bridge
# (wallet.ark-* RPCs) — the container just runs the daemon.
ENV BARKD_DATADIR=/data \
BARKD_BIND_HOST=0.0.0.0 \
BARKD_BIND_PORT=3535
EXPOSE 3535
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Install the node-provided auth secret (64-char hex from the manifest's
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
# random token in the datadir and the bridge won't authenticate — so treat a
# failed refresh as fatal rather than starting an unreachable daemon.
set -eu
if [ -n "${BARKD_SECRET:-}" ]; then
# `secret refresh` prints the Bearer token on stdout — never log it.
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
unset BARKD_SECRET
fi
exec barkd
+76
View File
@@ -0,0 +1,76 @@
app:
id: barkd
name: Ark Wallet
version: 0.3.0
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
# barkd packaged from the pinned upstream release binary (no usable
# upstream image exists — their registry is empty). Built from
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
# (validated against barkd 0.3.0 on signet, 2026-07-14).
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
pull_policy: if-not-present
network: archy-net
# The entrypoint installs the shared secret below via `barkd secret
# refresh` (so the wallet bridge can derive the matching Bearer token) and
# execs the daemon. The Ark wallet itself is created over REST by the
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
# (default: Second's public signet server) — no host provisioning needed.
generated_secrets:
- name: barkd-secret
kind: hex32
secret_env:
- key: BARKD_SECRET
secret_file: barkd-secret
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
# starve the node.
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
readonly_root: true
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
# esplora chain source, plus the published REST port for the wallet
# bridge. No inbound requirements beyond that.
network_policy: bridge
ports:
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
# the host (see port_allocator.rs). The Rust bridge targets
# http://127.0.0.1:3535.
- host: 3535
container: 3535
protocol: tcp
volumes:
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
# on-chain from this datadir (unilateral exit) — include it in backups.
- type: bind
source: /var/lib/archipelago/barkd
target: /data
options: [rw]
environment:
- BARKD_DATADIR=/data
- BARKD_BIND_HOST=0.0.0.0
- BARKD_BIND_PORT=3535
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
# verifies reachability).
health_check:
type: tcp
endpoint: localhost:3535
interval: 30s
timeout: 5s
retries: 3
+229
View File
@@ -0,0 +1,229 @@
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
//!
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
//! history, so unlike Fedimint there is no local transaction log.
use super::RpcHandler;
use crate::wallet::ark_client::{self, ArkClient};
use anyhow::Result;
impl RpcHandler {
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
/// and Ark server parameters. Soft-fails into `available: false` so the
/// settings UI can render an install/enable hint instead of an error.
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
let config = ark_client::load_config(&self.config.data_dir).await;
let client = match ArkClient::from_node(&self.config.data_dir).await {
Ok(c) => c,
Err(_) => {
return Ok(serde_json::json!({
"available": false,
"wallet_ready": false,
"config": config,
}))
}
};
// Make sure the wallet exists before reporting (idempotent, cheap once
// created).
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
let wallet = client.wallet_info().await.ok();
let info = client.ark_info().await.ok();
Ok(serde_json::json!({
"available": true,
"wallet_ready": wallet.is_some(),
"wallet": wallet,
"ark_info": info,
"config": config,
}))
}
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
/// sats. Soft-fails to zeros so unified balances still render.
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
let client = match ArkClient::from_node(&self.config.data_dir).await {
Ok(c) => c,
Err(_) => {
return Ok(serde_json::json!({
"balance_sats": 0,
"spendable_sats": 0,
"pending_sats": 0,
"onchain_sats": 0,
}))
}
};
let bal = client
.balance()
.await
.unwrap_or_else(|_| serde_json::json!({}));
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
let spendable = sat("spendable_sat");
let pending = sat("pending_in_round_sat")
+ sat("pending_board_sat")
+ sat("pending_lightning_send_sat")
+ sat("claimable_lightning_receive_sat")
+ bal
.get("pending_exit_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let onchain = client
.onchain_balance()
.await
.ok()
.and_then(|b| {
b.get("total_sat")
.or_else(|| b.get("confirmed_sat"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
Ok(serde_json::json!({
"balance_sats": spendable,
"spendable_sats": spendable,
"pending_sats": pending,
"onchain_sats": onchain,
}))
}
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
/// `{"onchain": true}` for an on-chain boarding address instead.
pub(super) async fn handle_wallet_ark_address(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
let client = ArkClient::from_node(&self.config.data_dir).await?;
let onchain = params
.as_ref()
.and_then(|p| p.get("onchain"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let address = if onchain {
client.onchain_address().await?
} else {
client.ark_address().await?
};
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
}
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
/// lightning address from Ark funds.
pub(super) async fn handle_wallet_ark_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let destination = params
.get("destination")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
// Optional for BOLT11 invoices that carry their own amount.
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
if amount_sats == Some(0) {
return Err(anyhow::anyhow!("Amount must be greater than zero"));
}
let comment = params.get("comment").and_then(|v| v.as_str());
let client = ArkClient::from_node(&self.config.data_dir).await?;
let movement = client.send(destination, amount_sats, comment).await?;
Ok(serde_json::json!({
"sent": true,
"movement": movement,
}))
}
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
pub(super) async fn handle_wallet_ark_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let amount_sats = params
.get("amount_sats")
.and_then(|v| v.as_u64())
.filter(|&v| v > 0)
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
let client = ArkClient::from_node(&self.config.data_dir).await?;
let res = client.lightning_invoice(amount_sats).await?;
Ok(res)
}
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
/// `amount_sats` boards everything.
pub(super) async fn handle_wallet_ark_board(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let amount_sats = params
.as_ref()
.and_then(|p| p.get("amount_sats"))
.and_then(|v| v.as_u64());
if amount_sats == Some(0) {
return Err(anyhow::anyhow!("Amount must be greater than zero"));
}
let client = ArkClient::from_node(&self.config.data_dir).await?;
let res = client.board(amount_sats).await?;
Ok(res)
}
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
/// optionally to a provided address (defaults to the wallet's own).
pub(super) async fn handle_wallet_ark_offboard(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let address = params
.as_ref()
.and_then(|p| p.get("address"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let client = ArkClient::from_node(&self.config.data_dir).await?;
let res = client.offboard_all(address).await?;
Ok(res)
}
/// `wallet.ark-history` — barkd movements mapped to the unified
/// transaction shape (kind = "ark"), newest first.
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(serde_json::json!({ "transactions": transactions }))
}
/// `wallet.ark-configure` — set the Ark server / esplora / network used
/// when the barkd wallet is (re)created. Does NOT migrate an existing
/// wallet: barkd binds a wallet to its Ark server at creation.
pub(super) async fn handle_wallet_ark_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = ark_client::load_config(&self.config.data_dir).await;
for (key, field) in [
("network", &mut config.network as &mut String),
("ark_server", &mut config.ark_server),
("esplora", &mut config.esplora),
] {
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
let v = v.trim();
if !v.is_empty() {
*field = v.to_string();
}
}
}
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
return Err(anyhow::anyhow!(
"network must be one of: signet, mainnet, regtest"
));
}
ark_client::save_config(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({ "config": config }))
}
}
+1 -1
View File
@@ -804,7 +804,7 @@ async fn http_launch_url_reachable(url: &str) -> bool {
}
}
fn port_from_url(url: &str) -> Option<u16> {
pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
let port = after_colon
.chars()
@@ -258,6 +258,17 @@ impl RpcHandler {
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
// Ark protocol (via barkd sidecar)
"wallet.ark-status" => self.handle_wallet_ark_status().await,
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
"wallet.ark-history" => self.handle_wallet_ark_history().await,
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
// Container registries
"registry.list" => self.handle_registry_list().await,
"registry.add" => self.handle_registry_add(params).await,
+1
View File
@@ -1,4 +1,5 @@
mod analytics;
mod ark;
mod auth;
mod backup_rpc;
mod bitcoin;
@@ -110,6 +110,14 @@ impl RpcHandler {
)
.await;
handler.clear_install_progress(&package_id_spawn).await;
// Auto-expose the app over Tor (best-effort, detached) —
// every installed app gets its .onion without a manual
// "Add Service" step.
let tor_handler = Arc::clone(&handler);
let tor_app = package_id_spawn.clone();
tokio::spawn(async move {
tor_handler.auto_add_tor_service(&tor_app).await;
});
}
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
@@ -53,6 +53,7 @@ impl RpcHandler {
// hit a hostname-mismatch warning on top of the usual self-signed one
// the moment a node is renamed.
if hostname_updated {
sync_hostname_side_effects(&hostname).await;
if let Err(e) = regenerate_tls_cert(&hostname).await {
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
}
@@ -375,6 +376,46 @@ pub(super) fn hostname_from_server_name(name: &str) -> String {
}
}
/// Post-rename side effects that keep the OS consistent with the new
/// hostname — all best-effort, the rename itself has already succeeded.
/// Debian resolves the local hostname via the 127.0.1.1 line in /etc/hosts;
/// leaving the old name there breaks `sudo` ("unable to resolve host") and
/// `hostname -f`. And while avahi eventually follows the kernel hostname,
/// re-announcing immediately makes http(s)://<hostname>.local links work
/// right after the rename instead of minutes later.
async fn sync_hostname_side_effects(hostname: &str) {
// hostname_from_server_name guarantees [a-z0-9-], safe to interpolate.
let script = format!(
"if grep -q '^127\\.0\\.1\\.1' /etc/hosts; then sed -i 's/^127\\.0\\.1\\.1.*/127.0.1.1\\t{h}/' /etc/hosts; else printf '127.0.1.1\\t{h}\\n' >> /etc/hosts; fi",
h = hostname
);
match tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/bin/sh", "-c", &script])
.output()
.await
{
Ok(o) if o.status.success() => {}
Ok(o) => warn!(
"/etc/hosts hostname sync failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
}
let republished = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !republished {
let _ = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/systemctl", "try-restart", "avahi-daemon"])
.output()
.await;
}
}
async fn set_system_hostname(hostname: &str) -> Result<()> {
let output = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
+72 -8
View File
@@ -33,14 +33,12 @@ impl RpcHandler {
validate_service_name(name)?;
let local_port = if raw_port == 0 {
let detected = known_service_port(name);
if detected == 0 {
return Err(anyhow::anyhow!(
"Unknown app '{}' — specify local_port manually",
self.resolve_app_local_port(name).await.ok_or_else(|| {
anyhow::anyhow!(
"No local web port found for '{}' — the app isn't running or exposes no UI port; specify local_port manually",
name
));
}
detected
)
})?
} else {
raw_port
};
@@ -286,7 +284,12 @@ impl RpcHandler {
"changed": false,
}));
}
let port = known_service_port(app_id);
let port = self.resolve_app_local_port(app_id).await.ok_or_else(|| {
anyhow::anyhow!(
"No local web port found for '{}' — the app isn't running or exposes no UI port",
app_id
)
})?;
let is_proto = is_protocol_service(app_id);
config.services.push(TorServiceEntry {
name: app_id.to_string(),
@@ -330,6 +333,67 @@ impl RpcHandler {
}))
}
/// The host-local port Tor should forward to for an app: the static map
/// first (protocol apps like bitcoin must expose 8333, not a UI port),
/// then the live launch address the scanner derived from the app's
/// published container ports — so any manifest-driven app works without
/// a per-app entry.
pub(in crate::api::rpc) async fn resolve_app_local_port(&self, name: &str) -> Option<u16> {
let known = known_service_port(name);
if known != 0 {
return Some(known);
}
let (data, _) = self.state_manager.get_snapshot().await;
let lan = data
.package_data
.get(name)?
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
crate::api::rpc::container::port_from_url(&lan)
}
/// Best-effort auto-exposure of a freshly installed app as a Tor hidden
/// service. Skips protocol services (bitcoin/lnd keep their explicit
/// flows), the node's own service, apps that already have one, and apps
/// with no resolvable web port. Runs detached after install — it never
/// fails the caller, it only logs.
pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) {
if app_id == "archipelago" || is_protocol_service(app_id) {
return;
}
let config_dir = self.config.data_dir.join("tor-config");
// The scanner may still be deriving the launch address on slower
// nodes; retry for up to ~5 minutes before giving up quietly.
for _ in 0..10u32 {
let config = load_services_config(&config_dir).await;
if config.services.iter().any(|s| s.name == app_id) {
return;
}
if let Some(port) = self.resolve_app_local_port(app_id).await {
let params = serde_json::json!({ "name": app_id, "local_port": port });
match self.handle_tor_create_service(Some(params)).await {
Ok(v) => info!(
app = app_id,
port,
onion = ?v.get("onion_address"),
"Auto-created Tor hidden service after install"
),
Err(e) => warn!(app = app_id, "Auto Tor service creation failed: {}", e),
}
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
}
debug!(
app = app_id,
"No web port resolved — skipping auto Tor service"
);
}
/// Restart Tor daemon (system or container).
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
info!("Manual Tor restart requested");
+8 -3
View File
@@ -1,5 +1,5 @@
use super::RpcHandler;
use crate::wallet::{ecash, fedimint_client, profits};
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
use anyhow::Result;
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
@@ -21,13 +21,16 @@ impl RpcHandler {
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
Err(_) => 0,
};
// Spendable Ark (barkd) balance, same best-effort contract.
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
Ok(serde_json::json!({
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
// spendable amount across Cashu + Fedimint.
// spendable amount across Cashu + Fedimint + Ark.
"balance_sats": cashu_sats,
"cashu_sats": cashu_sats,
"fedimint_sats": fedimint_sats,
"total_sats": cashu_sats + fedimint_sats,
"ark_sats": ark_sats,
"total_sats": cashu_sats + fedimint_sats + ark_sats,
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
"mint_url": wallet.mint_url,
}))
@@ -181,6 +184,8 @@ impl RpcHandler {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
let mut transactions = wallet.transactions;
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
// Sort by RFC-3339 timestamp descending (string compare is valid for
// same-offset RFC-3339), newest first.
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+3 -11
View File
@@ -80,19 +80,11 @@ pub struct Config {
}
impl Config {
/// Detect primary host IP (first non-loopback IPv4)
/// Detect primary host IP (default-route interface, not `hostname -I` order)
async fn detect_host_ip() -> Result<String> {
let output = tokio::process::Command::new("hostname")
.args(["-I"])
.output()
Ok(crate::host_ip::primary_host_ipv4()
.await
.context("Failed to run hostname -I")?;
let s = String::from_utf8_lossy(&output.stdout);
let ip = s
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.unwrap_or("127.0.0.1");
Ok(ip.to_string())
.unwrap_or_else(|| "127.0.0.1".to_string()))
}
pub async fn load() -> Result<Self> {
@@ -372,6 +372,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/minmoto/fmcd".to_string(),
tier: "",
},
"barkd" | "bark" => AppMetadata {
title: "Ark Wallet".to_string(),
description: "Ark protocol wallet daemon (barkd) — self-custodial off-chain bitcoin via an Ark server (signet)".to_string(),
icon: "/assets/img/app-icons/bark.png".to_string(),
repo: "https://gitlab.com/ark-bitcoin/bark".to_string(),
tier: "",
},
"morphos" | "morphos-server" => AppMetadata {
title: "Morphos".to_string(),
description: "Self-hosted file converter".to_string(),
@@ -689,22 +696,11 @@ async fn netbird_configured_launch_url() -> Option<String> {
PodmanClient::lan_address_for("netbird")
}
/// First address from `hostname -I` — the node's primary host IP. Mirrors the
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the
/// orchestrator renders for `{{HOST_IP}}`.
/// The node's primary host IP. Mirrors the orchestrator's `detect_host_ip`
/// so launch URLs match the cert/config the orchestrator renders for
/// `{{HOST_IP}}`.
async fn first_host_ip() -> Option<String> {
let out = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.map(ToOwned::to_owned)
crate::host_ip::primary_host_ipv4().await
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
@@ -3087,16 +3087,7 @@ impl ProdContainerOrchestrator {
}
async fn detect_host_ip() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.split_whitespace().next().map(|s| s.to_string())
crate::host_ip::primary_host_ipv4().await
}
async fn detect_host_mdns() -> String {
+135
View File
@@ -0,0 +1,135 @@
//! Primary host LAN IPv4 detection.
//!
//! `hostname -I` lists addresses in interface-creation order, so once a VPN
//! or bridge interface exists (NetBird's WireGuard tunnel, br-tollgate, …)
//! its address can sort ahead of the real NIC — a fresh-ISO node handed out
//! `https://10.44.0.1:8087` as NetBird's launch URL instead of the LAN IP.
//! The main routing table's default route names the physical uplink even when
//! a VPN is active (NetBird/Tailscale steer traffic via policy-routing rules
//! in separate tables, not by replacing the main-table default), so that is
//! the authoritative source, with `hostname -I` kept only as the last resort
//! for hosts with no default route at all.
/// The node's primary LAN IPv4, as a string.
///
/// Resolution order:
/// 1. `src`/`dev` of the main-table default route (`ip -4 route show default`)
/// 2. source address of a connected UDP socket (never transmits)
/// 3. first non-loopback IPv4 from `hostname -I` (legacy behaviour)
pub(crate) async fn primary_host_ipv4() -> Option<String> {
if let Some(ip) = default_route_ip().await {
return Some(ip);
}
if let Some(ip) = udp_route_ip() {
return Some(ip);
}
hostname_i_ip().await
}
async fn default_route_ip() -> Option<String> {
let out = tokio::process::Command::new("ip")
.args(["-4", "route", "show", "default"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let route = String::from_utf8_lossy(&out.stdout);
if let Some(ip) = parse_route_src(&route) {
return Some(ip);
}
// No `src` hint on the route — resolve the device's global address.
let dev = parse_route_dev(&route)?;
let out = tokio::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "dev", &dev, "scope", "global"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
parse_addr_inet(&String::from_utf8_lossy(&out.stdout))
}
fn parse_route_src(route: &str) -> Option<String> {
field_after(route.lines().next()?, "src")
}
fn parse_route_dev(route: &str) -> Option<String> {
field_after(route.lines().next()?, "dev")
}
fn field_after(line: &str, key: &str) -> Option<String> {
let mut words = line.split_whitespace();
while let Some(w) = words.next() {
if w == key {
return words.next().map(ToOwned::to_owned);
}
}
None
}
fn parse_addr_inet(out: &str) -> Option<String> {
let cidr = field_after(out.lines().next()?, "inet")?;
Some(cidr.split('/').next().unwrap_or(&cidr).to_string())
}
/// A connected UDP socket's local address is the source IP the kernel would
/// use to reach the peer; nothing is sent. Can still land on a tunnel IP when
/// a VPN policy-routes all traffic, hence only a fallback.
fn udp_route_ip() -> Option<String> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
match sock.local_addr().ok()?.ip() {
std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_unspecified() => {
Some(v4.to_string())
}
_ => None,
}
}
async fn hostname_i_ip() -> Option<String> {
let out = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_src_wins() {
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600";
assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116"));
}
#[test]
fn route_dev_without_src() {
let route = "default via 192.168.1.1 dev enp0s31f6 proto static";
assert_eq!(parse_route_src(route), None);
assert_eq!(parse_route_dev(route).as_deref(), Some("enp0s31f6"));
}
#[test]
fn addr_inet_strips_prefix() {
let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65"));
}
#[test]
fn empty_route_table() {
assert_eq!(parse_route_src(""), None);
assert_eq!(parse_route_dev(""), None);
}
}
+1
View File
@@ -50,6 +50,7 @@ mod electrs_status;
mod federation;
mod fips;
mod health_monitor;
mod host_ip;
mod identity;
mod identity_manager;
mod marketplace;
+1
View File
@@ -19,6 +19,7 @@ const RESERVED_PORTS: &[u16] = &[
23000, // BTCPay
8173, 8174, 8175, // Fedimint
8178, // Fedimint client daemon (fedimint-clientd REST)
3535, // Ark wallet daemon (barkd REST)
8123, // Home Assistant
3000, // Grafana
11434, // Ollama
+1
View File
@@ -1597,6 +1597,7 @@ fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"fedimint-clientd" => Some(8178),
"barkd" => Some(3535),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),
+486
View File
@@ -0,0 +1,486 @@
//! Thin HTTP bridge to the `barkd` sidecar container (Ark protocol).
//!
//! Same shape as [`super::fedimint_client`]: the heavy `bark-wallet` SDK stays
//! OUT of this binary. The `barkd` daemon (in `apps/barkd`) holds the Ark
//! wallet (VTXOs, rounds, unilateral exits) and we speak its REST API
//! (`/api/v1/*`, Bearer auth). Endpoint/JSON shapes target barkd 0.3.0 and
//! must be pinned to the vendored image tag.
//!
//! ARK is on-chain-anchored: VTXOs expire (`vtxo_expiry_delta` blocks) and the
//! barkd daemon refreshes them by joining rounds on its own — the bridge never
//! has to schedule anything. Unlike Cashu/Fedimint, funds survive the sidecar
//! dying (the wallet mnemonic in barkd's datadir can unilaterally exit
//! on-chain), so back up `/var/lib/archipelago/barkd`.
use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const BARKD_TIMEOUT_SECS: u64 = 15;
/// Send/board/offboard can wait on Ark round participation (signet rounds run
/// every 5 minutes), so give mutating calls generous room.
const BARKD_HEAVY_TIMEOUT_SECS: u64 = 120;
/// Default host port the `barkd` container is mapped to (its in-container
/// REST port; 3535 is unused elsewhere on the node — see `port_allocator`).
const DEFAULT_BARKD_URL: &str = "http://127.0.0.1:3535";
/// Shared secret between the barkd container and this bridge. The barkd
/// manifest generates it via `generated_secrets: [{barkd-secret, hex32}]`; the
/// container entrypoint installs it with `barkd secret refresh --secret` and
/// the bridge derives the matching Bearer token from the same file.
const BARKD_SECRET: &str = "barkd-secret";
/// Wallet configuration used when the bridge has to create the barkd wallet
/// (first use). Persisted so operators can point at their own Ark server.
/// Defaults target Second's public signet deployment while Ark matures —
/// mainnet needs an explicit opt-in edit of `wallet/ark_config.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArkConfig {
pub network: String,
pub ark_server: String,
pub esplora: String,
}
impl Default for ArkConfig {
fn default() -> Self {
Self {
network: "signet".to_string(),
ark_server: "https://ark.signet.2nd.dev".to_string(),
esplora: "https://esplora.signet.2nd.dev".to_string(),
}
}
}
const ARK_CONFIG_FILE: &str = "wallet/ark_config.json";
pub async fn load_config(data_dir: &Path) -> ArkConfig {
match fs::read_to_string(data_dir.join(ARK_CONFIG_FILE)).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ArkConfig::default(),
}
}
pub async fn save_config(data_dir: &Path, config: &ArkConfig) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(config).context("Failed to serialize ark config")?;
fs::write(data_dir.join(ARK_CONFIG_FILE), content)
.await
.context("Failed to write ark config")?;
Ok(())
}
/// Encode barkd's Bearer token from the raw 32-byte shared secret:
/// base64url-nopad of `<version 0x00><32-byte secret>` (see barkd `AuthToken`).
fn encode_auth_token(secret: &[u8; 32]) -> String {
let mut buf = Vec::with_capacity(33);
buf.push(0u8);
buf.extend_from_slice(secret);
URL_SAFE_NO_PAD.encode(&buf)
}
fn secret_hex_to_token(hex: &str) -> Result<String> {
let hex = hex.trim();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("barkd-secret must be exactly 64 hex characters");
}
let mut secret = [0u8; 32];
for (i, byte) in secret.iter_mut().enumerate() {
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex");
}
Ok(encode_auth_token(&secret))
}
/// HTTP client for a `barkd` instance.
pub struct ArkClient {
base_url: String,
token: String,
client: reqwest::Client,
}
impl ArkClient {
pub fn new(base_url: &str, token: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(BARKD_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for barkd")?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
client,
})
}
/// Resolve URL + auth token from env / node secret, with sane defaults.
/// URL: `BARKD_URL` else the default mapped port. Token: `BARKD_TOKEN`
/// (already-encoded Bearer token) else derived from the shared
/// `barkd-secret` the manifest generated for the container.
pub async fn from_node(data_dir: &Path) -> Result<Self> {
let base_url = std::env::var("BARKD_URL").unwrap_or_else(|_| DEFAULT_BARKD_URL.to_string());
let token = match std::env::var("BARKD_TOKEN") {
Ok(t) if !t.is_empty() => t,
_ => {
let path = data_dir.join("secrets").join(BARKD_SECRET);
let hex = fs::read_to_string(&path).await.context(
"Ark wallet not configured (no BARKD_TOKEN and no barkd-secret \
secret). Install the Ark (barkd) app.",
)?;
secret_hex_to_token(&hex)?
}
};
Self::new(&base_url, &token)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req.bearer_auth(&self.token)
}
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.get(&url))
.timeout(std::time::Duration::from_secs(BARKD_TIMEOUT_SECS))
.send()
.await
.with_context(|| format!("barkd GET {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.post(&url))
.json(&body)
.send()
.await
.with_context(|| format!("barkd POST {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
// barkd errors are `{"message": "..."}`; surface the message.
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or(text);
anyhow::bail!("barkd {path} returned {status}: {msg}");
}
if text.is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&text)
.with_context(|| format!("barkd {path} returned non-JSON: {text}"))
}
/// `GET /api/v1/wallet` — wallet info (fingerprint, network, config).
/// Errors with "No wallet set" until `create_wallet` has run.
pub async fn wallet_info(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet").await
}
/// `POST /api/v1/wallet/create` — create (or restore, with a mnemonic) the
/// barkd wallet. Idempotent guard is on the caller (`ensure_wallet`).
pub async fn create_wallet(&self, config: &ArkConfig) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/create",
serde_json::json!({
"network": config.network,
"ark_server": config.ark_server,
"chain_source": { "esplora": { "url": config.esplora } },
}),
)
.await
}
/// `GET /api/v1/wallet/balance` — off-chain balance breakdown, in sats.
pub async fn balance(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet/balance").await
}
/// Spendable off-chain sats (0 on any missing field, never an error once
/// the call itself succeeds).
pub async fn spendable_sats(&self) -> Result<u64> {
let bal = self.balance().await?;
Ok(bal
.get("spendable_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0))
}
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
pub async fn onchain_balance(&self) -> Result<serde_json::Value> {
self.get("/api/v1/onchain/balance").await
}
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
pub async fn ark_address(&self) -> Result<String> {
let res = self
.post("/api/v1/wallet/addresses/next", serde_json::json!({}))
.await?;
res.get("address")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("barkd address: no address in response"))
}
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
pub async fn onchain_address(&self) -> Result<String> {
let res = self
.post("/api/v1/onchain/addresses/next", serde_json::json!({}))
.await?;
res.get("address")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("barkd onchain address: no address in response"))
}
/// `POST /api/v1/wallet/send` — pay an Ark address, BOLT11 invoice, LNURL
/// or lightning address from off-chain funds. Returns the movement barkd
/// reports for the payment.
pub async fn send(
&self,
destination: &str,
amount_sats: Option<u64>,
comment: Option<&str>,
) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/send",
serde_json::json!({
"destination": destination,
"amount_sat": amount_sats,
"comment": comment,
}),
)
.await
}
/// `POST /api/v1/lightning/receives/invoice` — BOLT11 invoice that lands
/// as an Ark VTXO when paid.
pub async fn lightning_invoice(&self, amount_sats: u64) -> Result<serde_json::Value> {
self.post(
"/api/v1/lightning/receives/invoice",
serde_json::json!({ "amount_sat": amount_sats }),
)
.await
}
/// `POST /api/v1/boards/board-amount` (or `board-all` when `amount_sats`
/// is None) — lift on-chain funds into Ark VTXOs.
pub async fn board(&self, amount_sats: Option<u64>) -> Result<serde_json::Value> {
match amount_sats {
Some(sats) => {
self.post(
"/api/v1/boards/board-amount",
serde_json::json!({ "amount_sat": sats }),
)
.await
}
None => {
self.post("/api/v1/boards/board-all", serde_json::json!({}))
.await
}
}
}
/// `POST /api/v1/wallet/offboard/all` — move all VTXOs back on-chain via a
/// collaborative round.
pub async fn offboard_all(&self, address: Option<&str>) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/offboard/all",
serde_json::json!({ "address": address }),
)
.await
}
/// `GET /api/v1/wallet/movements` — barkd's own movement history. This is
/// authoritative (includes receives we never initiated), so unlike the
/// Fedimint bridge there is no local tx log to maintain.
pub async fn movements(&self) -> Result<Vec<serde_json::Value>> {
let res = self.get("/api/v1/wallet/movements").await?;
Ok(res.as_array().cloned().unwrap_or_default())
}
/// `GET /api/v1/wallet/ark-info` — connected Ark server parameters.
pub async fn ark_info(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet/ark-info").await
}
}
/// Idempotently make sure barkd has a wallet, creating one with the node's
/// Ark config on first use. Best-effort no-op when the sidecar isn't
/// installed/running yet — mirrors `fedimint_client::ensure_default_federation`.
pub async fn ensure_wallet(data_dir: &Path) -> Result<()> {
let client = match ArkClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Ok(()), // barkd not configured yet
};
if client.wallet_info().await.is_ok() {
return Ok(());
}
let config = load_config(data_dir).await;
match client.create_wallet(&config).await {
Ok(_) => {
tracing::info!(
"created barkd Ark wallet ({} via {})",
config.network,
config.ark_server
);
// Persist the effective config so the settings UI shows what the
// wallet was actually created with.
let _ = save_config(data_dir, &config).await;
}
Err(e) => tracing::debug!("barkd wallet auto-create skipped: {e}"),
}
Ok(())
}
/// Total spendable Ark sats, soft-failing to 0 when the sidecar is not
/// installed or unreachable so unified balances still render.
pub async fn spendable_sats_or_zero(data_dir: &Path) -> u64 {
match ArkClient::from_node(data_dir).await {
Ok(client) => client.spendable_sats().await.unwrap_or(0),
Err(_) => 0,
}
}
/// Map barkd movements into unified [`EcashTransaction`] history entries
/// (kind = "ark"). Best-effort: empty on any error, never blocks history.
pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
let client = match ArkClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let movements = match client.movements().await {
Ok(m) => m,
Err(_) => return Vec::new(),
};
movements.iter().filter_map(movement_to_tx).collect()
}
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
/// zero-delta movements (e.g. internal refreshes) so history stays meaningful.
fn movement_to_tx(m: &serde_json::Value) -> Option<crate::wallet::ecash::EcashTransaction> {
use crate::wallet::ecash::{EcashTransaction, TransactionType};
let delta = m.get("effective_balance_sat").and_then(|v| v.as_i64())?;
if delta == 0 {
return None;
}
let tx_type = if delta < 0 {
TransactionType::Send
} else {
TransactionType::Receive
};
// `time` holds created/updated/completed; prefer the completion time.
let timestamp = m
.get("time")
.and_then(|t| {
t.get("completed_at")
.or_else(|| t.get("updated_at"))
.or_else(|| t.get("created_at"))
})
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Describe via the recipient list (send) or receive source when present.
let peer = m
.get("sent_to")
.or_else(|| m.get("received_on"))
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|d| {
d.get("destination")
.or_else(|| d.get("address"))
.or_else(|| d.get("invoice"))
.and_then(|v| v.as_str())
})
.unwrap_or_default()
.to_string();
let subsystem = m
.get("subsystem")
.map(|s| match s {
serde_json::Value::String(v) => v.clone(),
other => other
.as_object()
.and_then(|o| o.keys().next().cloned())
.unwrap_or_default(),
})
.unwrap_or_default();
let description = if delta < 0 {
format!("Sent via Ark{}", suffix(&subsystem))
} else {
format!("Received via Ark{}", suffix(&subsystem))
};
Some(EcashTransaction {
id: format!("ark-{}", m.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
tx_type,
amount_sats: delta.unsigned_abs(),
timestamp,
description,
mint_url: String::new(),
peer,
kind: "ark".to_string(),
})
}
fn suffix(subsystem: &str) -> String {
if subsystem.is_empty() {
String::new()
} else {
format!(" ({subsystem})")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_encoding_matches_barkd_format() {
// barkd token = base64url-nopad(0x00 || secret); 33 bytes -> 44 chars.
let token = secret_hex_to_token(&"ab".repeat(32)).unwrap();
assert_eq!(token.len(), 44);
let bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
assert_eq!(bytes.len(), 33);
assert_eq!(bytes[0], 0);
assert_eq!(&bytes[1..], &[0xabu8; 32]);
}
#[test]
fn token_rejects_bad_secret() {
assert!(secret_hex_to_token("deadbeef").is_err(), "too short");
assert!(secret_hex_to_token(&"zz".repeat(32)).is_err(), "not hex");
}
#[test]
fn movement_maps_to_history_entry() {
let m = serde_json::json!({
"id": 7,
"effective_balance_sat": -1500,
"time": { "completed_at": "2026-07-14T12:00:00Z" },
"sent_to": [{ "destination": "tark1abc" }],
"subsystem": "arkoor",
});
let tx = movement_to_tx(&m).expect("mapped");
assert_eq!(tx.amount_sats, 1500);
assert_eq!(tx.kind, "ark");
assert_eq!(tx.peer, "tark1abc");
assert!(matches!(
tx.tx_type,
crate::wallet::ecash::TransactionType::Send
));
// Zero-delta refresh movements are dropped.
let refresh = serde_json::json!({ "id": 8, "effective_balance_sat": 0 });
assert!(movement_to_tx(&refresh).is_none());
}
}
+1
View File
@@ -1,6 +1,7 @@
// WIP Cashu/ecash wallet — many helpers defined for future callers.
#![allow(dead_code)]
pub mod ark_client;
pub mod bdhke;
pub mod cashu;
pub mod ecash;
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+6 -4
View File
@@ -7,7 +7,9 @@
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title id="pageTitle">Bitcoin Node - Archipelago</title>
<link rel="stylesheet" href="/tailwind.css">
<!-- Relative: the shell is served at / in the container but under
/app/bitcoin-ui/ in the public demo — absolute paths 404 there. -->
<link rel="stylesheet" href="tailwind.css">
<style>
* {
margin: 0;
@@ -339,7 +341,7 @@
<div class="logo-gradient-border">
<img
id="implLogo"
src="/assets/img/app-icons/bitcoin-knots.webp"
src="assets/img/app-icons/bitcoin-knots.webp"
alt="Bitcoin Node"
class="w-16 h-16"
style="object-fit: contain;"
@@ -984,8 +986,8 @@
? 'Enhanced Bitcoin node implementation'
: 'Reference Bitcoin node implementation';
const icon = isKnots
? '/assets/img/app-icons/bitcoin-knots.webp'
: '/assets/img/app-icons/bitcoin-core.svg';
? 'assets/img/app-icons/bitcoin-knots.webp'
: 'assets/img/app-icons/bitcoin-core.svg';
const pageTitle = document.getElementById('pageTitle');
const implName = document.getElementById('implName');
const implTagline = document.getElementById('implTagline');
+1 -1
View File
@@ -386,7 +386,7 @@
<section class="glass-card">
<div class="header">
<div class="logo-gradient-border">
<img src="/assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
<img src="assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
</div>
<div class="title">
<h1>Fedimint Guardian</h1>
+4 -1
View File
@@ -21,7 +21,10 @@ EnvironmentFile=-/var/lib/archipelago/telemetry.env
Environment="XDG_RUNTIME_DIR=/run/user/1000"
# + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths)
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
# Host IP from the main-table default route — hostname -I token order breaks
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
+4
View File
@@ -23,6 +23,10 @@ COPY docker/lnd-ui /docker/lnd-ui
COPY docker/fedimint-ui /docker/fedimint-ui
COPY demo/files /demo/files
COPY demo/content /demo/content
# Peer catalog media (posters/covers/photos for content.browse-peer mocks) —
# deliberately OUTSIDE demo/content so it doesn't appear as the visitor's own
# cloud files.
COPY demo/peer-media /demo/peer-media
# This image only ever serves the public demo — scrub the private release/registry
# server address from everything it serves (mock data, catalog.json, demo assets)
+6 -3
View File
@@ -91,7 +91,10 @@ http {
}
# Proxy FileBrowser API to mock backend (demo mode)
location /app/filebrowser/ {
# ^~ on every /app/ prefix: the .css/.js/.img cache regex below must
# never swallow app-shell assets (they live on the backend, not in the
# web root — without ^~ nginx prefers the regex and 404s them).
location ^~ /app/filebrowser/ {
client_max_body_size 10G;
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
@@ -103,7 +106,7 @@ http {
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
# and rewrite its absolute asset paths (/assets, /, src, href) to the
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
location /app/indeedhub/ {
location ^~ /app/indeedhub/ {
proxy_pass https://indee.tx1138.com/;
proxy_http_version 1.1;
proxy_set_header Host indee.tx1138.com;
@@ -129,7 +132,7 @@ http {
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
# generic "Not available in the demo" notice for the rest.
location /app/ {
location ^~ /app/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
+297 -24
View File
@@ -132,6 +132,11 @@ const SEED_WALLET = {
channel_sats: 8_250_000,
ecash_sats: 250_000,
ecash_tokens: 12,
// Ark (barkd sidecar) demo balances — signet defaults
ark_sats: 75_000,
ark_pending_sats: 0,
ark_onchain_sats: 20_000,
ark_config: { network: 'signet', ark_server: 'https://ark.signet.2nd.dev', esplora: 'https://esplora.signet.2nd.dev' },
block_height: 892451,
transactions: [
{ tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, direction: 'incoming', num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding', total_fees: 0, dest_addresses: [] },
@@ -1196,6 +1201,97 @@ function demoAppShell(title, sub, iconPath, bodyHtml) {
</div><div class="demo-tag">Archipelago demo · signet</div></body></html>`
}
// ── Rich peer content library (demo) ─────────────────────────────────────────
// What a healthy decentralised network's shared catalog looks like: films,
// series, books, music, photography and documents with real metadata. Music,
// photos and documents are backed by REAL committed files (they play/open);
// films/series/books are paid so the buy-flow poster/cover previews carry the
// visuals. Preview images live in demo/peer-media (sourced from the web via
// picsum.photos — Unsplash-licensed — seeds pinned in git history).
const PEER_MEDIA = (f) => path.join(__dirname, '..', 'demo', 'peer-media', f)
const PEER_CONTENT = (...p) => path.join(__dirname, '..', 'demo', 'content', ...p)
const PEER_LIBRARY = [
// Films & series — paid, poster previews
{ id: 'film-block-height', filename: 'Block Height (2025) 2160p.mp4', mime_type: 'video/mp4', size_bytes: 4_512_374_784, access: { paid: { price_sats: 2500 } }, preview: PEER_MEDIA('poster-film-block.jpg'),
description: 'Documentary · 2025 · 1h 42m · 3840×2160 HEVC 10-bit · dir. M. Castillo — three node operators ride out a halving epoch.' },
{ id: 'film-the-signal', filename: 'The Signal (2024) 1080p.mkv', mime_type: 'video/x-matroska', size_bytes: 2_147_483_648, access: { paid: { price_sats: 2100 } }, preview: PEER_MEDIA('poster-film-signal.jpg'),
description: 'Thriller · 2024 · 2h 08m · 1920×1080 x264 · A radio engineer discovers her mesh network is carrying more than messages.' },
{ id: 'film-meshwork', filename: 'Meshwork — infrastructure for the willing (720p).mp4', mime_type: 'video/mp4', size_bytes: 891_289_600, access: { paid: { price_sats: 800 } }, preview: PEER_MEDIA('poster-film-mesh.jpg'),
description: 'Documentary short · 2026 · 34m · 1280×720 · Community LoRa + Reticulum builds across the highlands.' },
{ id: 'series-node-runners-s01e01', filename: 'Node Runners S01E01 — Genesis.mkv', mime_type: 'video/x-matroska', size_bytes: 734_003_200, access: { paid: { price_sats: 500 } }, preview: PEER_MEDIA('poster-series-node.jpg'),
description: 'Series · S01E01 · 43m · 1080p · Docuseries following first-time sovereign node builders.' },
// Books — paid, cover previews
{ id: 'book-sovereign-notes', filename: 'The Sovereign Individual — annotated.epub', mime_type: 'application/epub+zip', size_bytes: 3_842_048, access: { paid: { price_sats: 300 } }, preview: PEER_MEDIA('cover-book-sovereign.jpg'),
description: 'EPUB · 512 pp · community margin-notes edition, 2026 · Davidson & Rees-Mogg.' },
{ id: 'book-cypherpunk-essays', filename: 'Cypherpunk Essays Vol. II.epub', mime_type: 'application/epub+zip', size_bytes: 2_097_152, access: { paid: { price_sats: 210 } }, preview: PEER_MEDIA('cover-book-cypher.jpg'),
description: 'EPUB · 348 pp · 2025 anthology — privacy engineering, remailers, digital cash history.' },
{ id: 'book-broadcast-power', filename: 'Broadcast Power — pirate radio to mesh.pdf', mime_type: 'application/pdf', size_bytes: 18_874_368, access: { paid: { price_sats: 150 } }, preview: PEER_MEDIA('cover-book-broadcast.jpg'),
description: 'PDF · 214 pp · illustrated 2024 history of community-run transmission networks.' },
// Music — free, streams the real committed files
{ id: 'song-leaves-brown', filename: 'All the leaves are brown.mp3', mime_type: 'audio/mpeg', size_bytes: 2_936_012, access: 'free', disk: PEER_CONTENT('music', 'All the leaves are brown.mp3'),
description: 'MP3 320 kbps · Archy Sessions, 2026 · folk cover, one-take living-room recording.' },
{ id: 'song-exploited-substrate', filename: 'An Exploited Substrate.mp3', mime_type: 'audio/mpeg', size_bytes: 4_194_304, access: 'free', disk: PEER_CONTENT('music', 'An Exploited Substrate.mp3'),
description: 'MP3 320 kbps · Node Noise EP, 2025 · instrumental synthwave.' },
{ id: 'song-architects', filename: 'Architects of Tomorrow.wav', mime_type: 'audio/wav', size_bytes: 34_734_080, access: 'free', disk: PEER_CONTENT('music', 'Architects of Tomorrow.wav'),
description: 'WAV 16-bit/44.1 kHz lossless master · Node Noise EP, 2025.' },
{ id: 'song-bitcoin-salad', filename: 'Bitcoin Salad.MP3', mime_type: 'audio/mpeg', size_bytes: 3_984_588, access: 'free', disk: PEER_CONTENT('music', 'Bitcoin Salad.MP3'),
description: 'MP3 256 kbps · Kitchen Mix Vol. 3, 2024 · novelty electro.' },
{ id: 'song-builders', filename: 'Builders, not talkers (Remastered).mp3', mime_type: 'audio/mpeg', size_bytes: 4_508_876, access: 'free', disk: PEER_CONTENT('music', 'Builders, not talkers (Remastered).mp3'),
description: 'MP3 320 kbps · 2026 remaster · hip-hop instrumental.' },
// Photography — free, the committed JPEGs themselves (preview = full image)
{ id: 'photo-aurora-fjord', filename: 'aurora-over-the-fjord.jpg', mime_type: 'image/jpeg', size_bytes: 79_422, access: 'free', disk: PEER_MEDIA('photo-aurora-fjord.jpg'),
description: '800×533 · f/1.8 · 8s · ISO 1600 · Sony A7 IV 24mm · Lofoten, Norway · free license, sourced via picsum.photos.' },
{ id: 'photo-mountain-lake', filename: 'mountain-lake-still.jpg', mime_type: 'image/jpeg', size_bytes: 29_200, access: 'free', disk: PEER_MEDIA('photo-mountain-lake.jpg'),
description: '800×533 · f/8 · 1/250s · ISO 100 · Fuji X-T5 23mm · Dolomites, Italy · free license, sourced via picsum.photos.' },
{ id: 'photo-city-neon', filename: 'city-neon-rain.jpg', mime_type: 'image/jpeg', size_bytes: 59_855, access: 'free', disk: PEER_MEDIA('photo-city-neon.jpg'),
description: '800×533 · f/1.4 · 1/60s · ISO 800 · Leica Q3 28mm · Osaka, Japan · free license, sourced via picsum.photos.' },
{ id: 'photo-desert-dunes', filename: 'desert-dunes-dawn.jpg', mime_type: 'image/jpeg', size_bytes: 24_646, access: 'free', disk: PEER_MEDIA('photo-desert-dunes.jpg'),
description: '800×533 · f/11 · 1/125s · ISO 64 · Nikon Z7 II 70-200mm · Erg Chebbi, Morocco · free license, sourced via picsum.photos.' },
{ id: 'photo-forest-mist', filename: 'forest-mist-morning.jpg', mime_type: 'image/jpeg', size_bytes: 48_504, access: 'free', disk: PEER_MEDIA('photo-forest-mist.jpg'),
description: '800×533 · f/4 · 1/200s · ISO 400 · Canon R5 85mm · Black Forest, Germany · free license, sourced via picsum.photos.' },
{ id: 'photo-ocean-cliff', filename: 'ocean-cliff-long-exposure.jpg', mime_type: 'image/jpeg', size_bytes: 30_517, access: 'free', disk: PEER_MEDIA('photo-ocean-cliff.jpg'),
description: '800×533 · f/16 · 30s ND1000 · ISO 50 · Sony A7R V 16-35mm · Moher, Ireland · free license, sourced via picsum.photos.' },
{ id: 'photo-northern-road', filename: 'northern-road-solitude.jpg', mime_type: 'image/jpeg', size_bytes: 72_053, access: 'free', disk: PEER_MEDIA('photo-northern-road.jpg'),
description: '800×533 · f/5.6 · 1/500s · ISO 200 · drone DJI Mavic 3 · Iceland Ring Road · free license, sourced via picsum.photos.' },
{ id: 'photo-autumn-valley', filename: 'autumn-valley-patchwork.jpg', mime_type: 'image/jpeg', size_bytes: 51_148, access: 'free', disk: PEER_MEDIA('photo-autumn-valley.jpg'),
description: '800×533 · f/9 · 1/160s · ISO 100 · Fuji GFX 100S 110mm · Vermont, USA · free license, sourced via picsum.photos.' },
{ id: 'photo-harbor-dawn', filename: 'harbor-dawn-fishing-boats.jpg', mime_type: 'image/jpeg', size_bytes: 36_271, access: 'free', disk: PEER_MEDIA('photo-harbor-dawn.jpg'),
description: '800×533 · f/2.8 · 1/80s · ISO 320 · Leica M11 35mm · Cornwall, UK · free license, sourced via picsum.photos.' },
{ id: 'photo-alpine-ridge', filename: 'alpine-ridge-hiker.jpg', mime_type: 'image/jpeg', size_bytes: 91_938, access: 'free', disk: PEER_MEDIA('photo-alpine-ridge.jpg'),
description: '800×533 · f/7.1 · 1/320s · ISO 100 · Canon R6 II 24-70mm · Bernese Oberland, Switzerland · free license, sourced via picsum.photos.' },
// Documents — free, real committed files
{ id: 'doc-whitepaper-notes', filename: 'bitcoin-whitepaper-notes.md', mime_type: 'text/markdown', size_bytes: 8_192, access: 'free', disk: PEER_CONTENT('documents', 'bitcoin-whitepaper-notes.md'),
description: 'Markdown study notes on the Bitcoin whitepaper, section by section.' },
{ id: 'doc-sovereignty-manifesto', filename: 'sovereignty-manifesto.txt', mime_type: 'text/plain', size_bytes: 4_096, access: 'free', disk: PEER_CONTENT('documents', 'sovereignty-manifesto.txt'),
description: 'Plain-text manifesto on self-hosted infrastructure.' },
{ id: 'doc-node-checklist', filename: 'node-setup-checklist.md', mime_type: 'text/markdown', size_bytes: 6_144, access: 'free', disk: PEER_CONTENT('documents', 'node-setup-checklist.md'),
description: 'Step-by-step hardening checklist for a fresh Archipelago node.' },
{ id: 'doc-lightning-csv', filename: 'lightning-channels.csv', mime_type: 'text/csv', size_bytes: 2_048, access: 'free', disk: PEER_CONTENT('documents', 'lightning-channels.csv'),
description: 'CSV export — public channel set with capacities and fee policies.' },
// Software
{ id: 'sw-archy-iso', filename: 'archipelago-1.7.99-amd64.iso', mime_type: 'application/x-iso9660-image', size_bytes: 2_684_354_560, access: 'free',
description: 'Archipelago Node OS installer ISO · amd64 · SHA256 in release notes.' },
]
/** Deterministic per-peer slice of the library every peer shares a different
* handful, popular items appear on more than one node (it's a network). */
function peerCatalogFor(onion) {
let seed = 0
for (const c of String(onion)) seed = (seed * 31 + c.charCodeAt(0)) >>> 0
const slot = seed % 12
return PEER_LIBRARY
.map((item, i) => ({ item, i }))
.filter(({ i }) => i % 12 === slot || ((seed ^ (i * 2654435761)) >>> 0) % 12 < 3)
.map(({ item }) => ({
id: item.id,
filename: item.filename,
mime_type: item.mime_type,
size_bytes: item.size_bytes,
description: item.description,
access: item.access,
}))
}
// 12 trusted/federated nodes for the demo Federation view.
function demoFederationNodes() {
const names = [
@@ -1244,7 +1340,8 @@ const DOCKER_UI = path.join(__dirname, '..', 'docker')
for (const [prefixes, dir] of [
[['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'],
[['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'],
[['/app/lnd', '/app/lnd-ui', '/app/archy-lnd-ui', '/app/thunderhub'], 'lnd-ui'],
// lnd deliberately NOT here: the real lnd-ui shell reads poorly in the demo
// iframe, so /app/lnd/ gets a DEMO_APP_PAGES placeholder dashboard instead.
[['/app/fedimint', '/app/fedimintd'], 'fedimint-ui'],
]) {
for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir)))
@@ -2160,15 +2257,40 @@ app.post('/rpc/v1', (req, res) => {
// =========================================================================
case 'content.browse-peer': {
const onion = params?.onion || ''
return res.json({
result: {
items: [
{ id: 'peer-doc-1', filename: 'Bitcoin Whitepaper.pdf', mime_type: 'application/pdf', size_bytes: 184292, description: 'The original Bitcoin whitepaper by Satoshi Nakamoto', access: 'free' },
{ id: 'peer-img-1', filename: 'node-setup-guide.png', mime_type: 'image/png', size_bytes: 524800, description: 'Visual guide for setting up a Bitcoin node', access: 'free' },
{ id: 'peer-vid-1', filename: 'Lightning Demo.mp4', mime_type: 'video/mp4', size_bytes: 15728640, description: 'Lightning Network payment channel demo', access: { paid: { price_sats: 500 } } },
],
},
})
return res.json({ result: { items: peerCatalogFor(onion) } })
}
case 'content.preview-peer': {
// Serve the item's committed preview image (film poster, book cover, or
// the photo itself) so the demo NEVER renders a broken thumbnail.
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
const previewAbs = entry && (entry.preview || (entry.mime_type.startsWith('image/') && entry.disk))
if (previewAbs) {
try {
const data = fsSync.readFileSync(typeof previewAbs === 'string' ? previewAbs : entry.disk)
return res.json({ result: { data: data.toString('base64'), content_type: 'image/jpeg' } })
} catch { /* fall through to no preview (icon fallback) */ }
}
return res.json({ result: {} })
}
case 'content.download-peer': {
// Free items backed by a real committed file stream the actual bytes —
// music plays, photos open, documents read. Anything else gets the
// demo placeholder note.
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
if (entry?.disk) {
try {
const data = fsSync.readFileSync(entry.disk)
return res.json({ result: { data: data.toString('base64'), content_type: entry.mime_type } })
} catch { /* fall through to placeholder */ }
}
const placeholder = Buffer.from(
`Archipelago demo — "${entry?.filename || params?.content_id || 'file'}"\n\n` +
'This is sample shared content. On a real node this would be the actual file.\n',
'utf-8'
).toString('base64')
return res.json({ result: { data: placeholder, content_type: 'text/plain' } })
}
case 'content.remove': {
@@ -2186,10 +2308,18 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { items: [] } })
}
case 'content.owned-get':
case 'content.preview-peer':
case 'content.download-peer-paid':
case 'content.download-peer-invoice':
case 'content.download-peer-onchain': {
// Deduct the price from the chosen rail so demo balances react.
const paid = params?.price_sats || 0
if (paid > 0 && method === 'content.download-peer-paid') {
if (params?.method === 'ark') walletState.ark_sats = Math.max(0, walletState.ark_sats - paid)
else if (params?.method === 'fedimint') {
const fed = (mockState.federations || [])[0]
if (fed) fed.balance_sats = Math.max(0, (fed.balance_sats || 0) - paid)
} else walletState.ecash_sats = Math.max(0, walletState.ecash_sats - paid)
}
const filename = params?.filename || 'demo-content'
const body = Buffer.from(
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
@@ -2251,6 +2381,63 @@ app.post('/rpc/v1', (req, res) => {
const total = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
return res.json({ result: { balance_sats: total } })
}
case 'wallet.ark-status': {
return res.json({
result: {
available: true,
wallet_ready: true,
wallet: { network: walletState.ark_config.network },
ark_info: { server: walletState.ark_config.ark_server },
config: walletState.ark_config,
},
})
}
case 'wallet.ark-balance': {
return res.json({
result: {
balance_sats: walletState.ark_sats,
spendable_sats: walletState.ark_sats,
pending_sats: walletState.ark_pending_sats,
onchain_sats: walletState.ark_onchain_sats,
},
})
}
case 'wallet.ark-address': {
const onchain = params?.onchain === true
const address = onchain ? 'tb1q' + randomHex(16) : 'tark1' + randomHex(28)
return res.json({ result: { address, onchain } })
}
case 'wallet.ark-board': {
const amt = params?.amount_sats ?? walletState.ark_onchain_sats
walletState.ark_onchain_sats = Math.max(0, walletState.ark_onchain_sats - amt)
walletState.ark_sats += amt
return res.json({ result: { boarded_sats: amt } })
}
case 'wallet.ark-offboard': {
const amt = walletState.ark_sats
walletState.ark_sats = 0
walletState.ark_onchain_sats += amt
return res.json({ result: { offboarded_sats: amt } })
}
case 'wallet.ark-send': {
const amt = params?.amount_sats || 1000
walletState.ark_sats = Math.max(0, walletState.ark_sats - amt)
return res.json({ result: { sent: true, movement: { id: 1, effective_balance_sat: -amt } } })
}
case 'wallet.ark-invoice': {
return res.json({ result: { invoice: 'lntbs' + randomHex(64) } })
}
case 'wallet.ark-history': {
return res.json({ result: { transactions: [] } })
}
case 'wallet.ark-configure': {
const cfg = { ...walletState.ark_config }
for (const key of ['network', 'ark_server', 'esplora']) {
if (typeof params?.[key] === 'string' && params[key].trim()) cfg[key] = params[key].trim()
}
walletState.ark_config = cfg
return res.json({ result: { config: cfg } })
}
case 'content.request-onchain': {
const price = params?.price_sats || 500
return res.json({
@@ -2356,20 +2543,34 @@ app.post('/rpc/v1', (req, res) => {
}
case 'network.dns-status': {
const dns = mockState.dns || { provider: 'system', servers: ['1.1.1.1', '9.9.9.9'], doh_enabled: false }
return res.json({
result: {
provider: 'system',
servers: ['1.1.1.1', '9.9.9.9'],
doh_enabled: false,
provider: dns.provider,
servers: dns.servers,
doh_enabled: dns.doh_enabled,
doh_url: null,
resolv_conf_servers: ['1.1.1.1', '9.9.9.9'],
resolv_conf_servers: dns.servers,
},
})
}
case 'network.configure-dns': {
console.log(`[Network] DNS configured: ${params?.provider}`)
return res.json({ result: { success: true } })
const dnsProviders = {
system: ['192.168.4.1'],
cloudflare: ['1.1.1.1', '1.0.0.1'],
google: ['8.8.8.8', '8.8.4.4'],
quad9: ['9.9.9.9', '149.112.112.112'],
mullvad: ['194.242.2.2'],
}
const provider = params?.provider || 'system'
const servers = provider === 'custom'
? (Array.isArray(params?.servers) ? params.servers : [])
: (dnsProviders[provider] || dnsProviders.system)
const doh_enabled = ['cloudflare', 'google', 'quad9', 'mullvad'].includes(provider)
mockState.dns = { provider, servers, doh_enabled }
console.log(`[Network] DNS configured: ${provider}${servers.join(', ')}`)
return res.json({ result: { provider, servers, doh_enabled } })
}
case 'network.accept-request': {
@@ -2435,20 +2636,45 @@ app.post('/rpc/v1', (req, res) => {
// Tor & Peer Networking
// =========================================================================
case 'tor.list-services': {
mockState.torServices = mockState.torServices || defaultTorServices()
return res.json({
result: {
tor_running: true,
services: [
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
],
services: mockState.torServices,
},
})
}
case 'tor.create-service': {
mockState.torServices = mockState.torServices || defaultTorServices()
const name = params?.name
if (!name) {
return res.json({ error: { code: -1, message: 'Missing name' } })
}
if (mockState.torServices.some(s => s.name === name)) {
return res.json({ error: { code: -1, message: `Service '${name}' already exists` } })
}
const stem = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}demo`
const onion = (stem + 'x7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioadmnopqrstuv').slice(0, 56) + '.onion'
const svc = { name, local_port: params?.local_port || 8080, onion_address: onion, enabled: true, unauthenticated: false, protocol: false }
mockState.torServices = [...mockState.torServices, svc]
console.log(`[Tor] Created hidden service: ${name}${onion}`)
return res.json({ result: { created: true, name, onion_address: onion } })
}
case 'tor.delete-service': {
mockState.torServices = mockState.torServices || defaultTorServices()
const name = params?.name
if (name === 'archipelago') {
return res.json({ error: { code: -1, message: "Cannot delete the node's own Tor service" } })
}
if (!mockState.torServices.some(s => s.name === name)) {
return res.json({ error: { code: -1, message: `Service '${name}' not found` } })
}
mockState.torServices = mockState.torServices.filter(s => s.name !== name)
return res.json({ result: { deleted: true, name } })
}
case 'node.tor-address': {
return res.json({
result: {
@@ -3253,10 +3479,15 @@ app.post('/rpc/v1', (req, res) => {
// Wallet / Ecash (Fedimint)
// =====================================================================
case 'wallet.ecash-balance': {
const fedSats = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
return res.json({
result: {
balance_sats: walletState.ecash_sats,
balance_msat: walletState.ecash_sats * 1000,
cashu_sats: walletState.ecash_sats,
fedimint_sats: fedSats,
ark_sats: walletState.ark_sats,
total_sats: walletState.ecash_sats + fedSats + walletState.ark_sats,
token_count: walletState.ecash_tokens,
federations: [
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
@@ -3296,6 +3527,8 @@ app.post('/rpc/v1', (req, res) => {
{ type: 'receive', amount_sats: 50000, timestamp: new Date(Date.now() - 86400000).toISOString(), note: 'Minted from Lightning' },
{ type: 'send', amount_sats: 5000, timestamp: new Date(Date.now() - 43200000).toISOString(), note: 'Sent ecash token' },
{ type: 'receive', amount_sats: 10000, timestamp: new Date(Date.now() - 3600000).toISOString(), note: 'Redeemed token' },
// Ark movement in the unified history shape (see wallet.ark-history / load_ark_txs)
{ id: 'ark-1', tx_type: 'receive', type: 'receive', amount_sats: 75000, timestamp: new Date(Date.now() - 7200000).toISOString(), description: 'Received via Ark', note: 'Received via Ark', mint_url: '', peer: '', kind: 'ark' },
],
},
})
@@ -4677,6 +4910,34 @@ app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.for
// something plausible in the in-app iframe. Registered before the generic
// /app/:id notice handler so these win.
const DEMO_APP_PAGES = {
// Placeholder LND dashboard — the real lnd-ui shell reads poorly inside the
// demo iframe. Numbers stay consistent with the /proxy/lnd/v1/* mocks.
lnd: () => demoAppShell('Lightning Network Daemon', 'archipelago-lnd · v0.18.3-beta · signet', '/assets/img/app-icons/lnd.png', `
<div class="grid">
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">Running · synced</span></div></div>
<div class="card"><div class="k">On-chain</div><div class="v">2,450,000 sats</div></div>
<div class="card"><div class="k">Lightning (local)</div><div class="v">8,250,000 sats</div></div>
<div class="card"><div class="k">Inbound capacity</div><div class="v">11,750,000 sats</div></div>
</div>
<div class="grid">
<div class="card"><div class="k">Channels</div><div class="v">4 active</div></div>
<div class="card"><div class="k">Peers</div><div class="v">11</div></div>
<div class="card"><div class="k">Block height</div><div class="v">902,418</div></div>
</div>
<div class="card">
<div class="k" style="margin-bottom:6px">Channels</div>
<table>
<tr><th>Peer</th><th>Capacity</th><th>Local / Remote</th><th style="width:30%">Balance</th></tr>
<tr><td>ACINQ</td><td>5,000,000</td><td>2,450,000 / 2,550,000</td><td><div class="bar"><i style="width:49%"></i></div></td></tr>
<tr><td>Voltage</td><td>10,000,000</td><td>4,500,000 / 5,500,000</td><td><div class="bar"><i style="width:45%"></i></div></td></tr>
<tr><td>Kraken</td><td>3,000,000</td><td>1,800,000 / 1,200,000</td><td><div class="bar"><i style="width:60%"></i></div></td></tr>
<tr><td>Wallet of Satoshi</td><td>2,000,000</td><td>1,200,000 / 800,000</td><td><div class="bar"><i style="width:60%"></i></div></td></tr>
</table>
</div>
<div class="card" style="margin-top:14px">
<div class="k" style="margin-bottom:6px">Node URI</div>
<div class="v mono">02c9f0a1e47b@lnd7f3a2c9d1b4e8f6.onion:9735</div>
</div>`),
'btcpay-server': () => demoAppShell('BTCPay Server', 'Self-hosted payment processor · signet', '/assets/img/app-icons/btcpay-server.png', `
<div class="grid">
<div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div>
@@ -4883,6 +5144,18 @@ const mockData = sessionBucketProxy('mockData')
const walletState = sessionBucketProxy('walletState')
const userState = sessionBucketProxy('userState')
const mockState = sessionBucketProxy('mockState')
// Seed for the per-session Tor services demo state (tor.list-services /
// tor.create-service / tor.delete-service round-trip against this).
function defaultTorServices() {
return [
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
]
}
const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState')
// Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+19
View File
@@ -298,6 +298,25 @@
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
+4
View File
@@ -45,6 +45,10 @@ const customIconUrls = {
'fedimint': [
'https://raw.githubusercontent.com/fedibtc/fedimint-ui/master/apps/router/public/favicon.svg',
],
// Official bark project avatar (Ark protocol wallet daemon)
'bark': [
'https://gitlab.com/uploads/-/system/project/avatar/75519706/bark-smiling-square-white-2.jpg',
],
}
const iconDir = path.join(__dirname, '../public/assets/img/app-icons')
+12 -1
View File
@@ -395,6 +395,16 @@ onMounted(async () => {
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
// One-shot "Replay intro" request from the login screen must survive the
// auto-re-mark below (which otherwise instantly suppresses the replay on any
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && route.path === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
@@ -408,7 +418,7 @@ onMounted(async () => {
}
}
if (!seenIntro && onboardingComplete === true) {
if (!replayRequested && !seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true
}
@@ -421,6 +431,7 @@ onMounted(async () => {
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
replayRequested,
})) {
// Coming from boot screen show the full splash intro (Enter to Exit typing logo)
showSplash.value = true
@@ -3,12 +3,12 @@
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] as const)"
:key="m"
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : m === 'ecash' ? t('receiveBitcoin.ecash') : 'Ark' }}</button>
</div>
<!-- Lightning -->
@@ -43,6 +43,19 @@
</div>
</div>
<!-- Ark -->
<div v-if="receiveMethod === 'ark'">
<div v-if="arkAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Ark address</p>
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
</div>
</div>
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
@@ -57,7 +70,7 @@
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
</button>
</div>
</BaseModal>
@@ -75,15 +88,17 @@ const { t } = useI18n()
defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; received: [] }>()
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('onchain')
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')
const invoiceResult = ref('')
const onchainAddress = ref('')
const arkAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false)
const error = ref('')
@@ -102,6 +117,7 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
function close() {
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
error.value = ''
@@ -131,6 +147,11 @@ async function receive() {
}
onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else if (receiveMethod.value === 'ark') {
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
if (!res.address) throw new Error('barkd did not return an Ark address')
arkAddress.value = res.address
nextTick(() => renderQr(res.address, arkQrCanvas.value))
} else {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
// The backend auto-detects the token type: a Cashu token (cashuA/B) is
+21 -6
View File
@@ -3,12 +3,12 @@
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
@@ -22,9 +22,9 @@
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
@@ -39,6 +39,9 @@
<div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div>
<div v-if="resultArk" class="mb-3 alert-success">
<p class="text-xs">{{ resultArk }}</p>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
@@ -62,13 +65,14 @@ const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
const amount = ref<number>(0)
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const resultArk = ref('')
const ecashToken = ref('')
const effectiveMethod = computed(() => {
@@ -84,6 +88,7 @@ function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
ecashToken.value = ''
emit('close')
}
@@ -99,10 +104,20 @@ async function send() {
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
const method = effectiveMethod.value
try {
if (method === 'ecash') {
if (method === 'ark') {
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
await rpcClient.call<{ sent: boolean }>({
method: 'wallet.ark-send',
params: { destination: dest.value.trim(), amount_sats: amount.value },
// Ark sends can wait on round participation.
timeout: 130000,
})
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
} else if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
params: { amount_sats: amount.value },
+13 -6
View File
@@ -1,5 +1,7 @@
<template>
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh see
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. -->
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
@@ -72,7 +74,7 @@
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : 'bg-blue-500/15 text-blue-400'"
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : tx.kind === 'ark' ? 'bg-teal-500/15 text-teal-400' : 'bg-blue-500/15 text-blue-400'"
>
{{ kindLabel(tx) }}
</span>
@@ -111,7 +113,7 @@ interface WalletTransaction {
label: string
block_height: number
// Which rail the transaction happened on; absent = onchain (older backends)
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
const props = defineProps<{
@@ -122,19 +124,23 @@ const props = defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
const filters: Array<{ key: FilterKey; label: string }> = [
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
// The Ark chip only appears once an Ark transaction exists most nodes
// don't run the barkd sidecar.
const filters = computed<Array<{ key: FilterKey; label: string }>>(() => [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
]
...(props.transactions.some(tx => tx.kind === 'ark') ? [{ key: 'ark' as const, label: 'Ark' }] : []),
])
const activeFilter = ref<FilterKey>('all')
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
if (f === 'all') return true
if (f === 'onchain') return isOnchain(tx)
if (f === 'lightning') return tx.kind === 'lightning'
if (f === 'ark') return tx.kind === 'ark'
return tx.kind === 'cashu' || tx.kind === 'fedimint'
}
@@ -157,6 +163,7 @@ function kindLabel(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Lightning'
if (tx.kind === 'cashu') return 'Cashu'
if (tx.kind === 'fedimint') return 'Fedimint'
if (tx.kind === 'ark') return 'Ark'
return ''
}
+225 -2
View File
@@ -148,11 +148,107 @@
Joining federations lands with the Fedimint client backend.
</p>
</div>
<!-- ===================== Ark ===================== -->
<div v-show="activeTab === 'ark'">
<div class="flex items-start gap-2 mb-4">
<p class="text-white/60 text-sm flex-1">
Ark holds self-custodial off-chain bitcoin via an Ark server. Funds stay recoverable on-chain even if the server disappears.
</p>
<span v-if="arkStatus && !arkStatus.available" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Not installed</span>
<span v-else-if="arkStatus?.config?.network && arkStatus.config.network !== 'mainnet'" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-teal-500/15 text-teal-400">{{ arkStatus.config.network }}</span>
</div>
<div v-if="loadingArk" class="py-6 text-center text-white/50 text-sm">Checking Ark wallet</div>
<template v-else>
<p v-if="arkStatus && !arkStatus.available" class="text-white/40 text-sm text-center py-2 mb-4">
Install the <span class="text-white/70">Ark Wallet</span> app from the app store to enable Ark payments.
</p>
<!-- Balances -->
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Pending</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
</div>
</div>
<!-- Receive address -->
<div v-if="arkStatus?.available" class="mb-4">
<div class="flex gap-2">
<button @click="fetchArkAddress(false)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">Ark address</button>
<button @click="fetchArkAddress(true)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">On-chain (boarding) address</button>
</div>
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<!-- Board / offboard -->
<div v-if="arkStatus?.available" class="flex gap-2 mb-4">
<button
@click="boardArk"
:disabled="arkBusy || (arkBalance?.onchain_sats ?? 0) === 0"
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
title="Lift on-chain funds into Ark"
>{{ arkBoarding ? 'Boarding…' : 'Board on-chain funds' }}</button>
<button
@click="offboardArk"
:disabled="arkBusy || (arkBalance?.spendable_sats ?? 0) === 0"
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
title="Move all Ark funds back on-chain"
>{{ arkOffboarding ? 'Offboarding…' : 'Offboard to on-chain' }}</button>
</div>
<!-- Server configuration -->
<div class="mb-3 space-y-2">
<label class="text-white/60 text-sm block">Ark server</label>
<input v-model="arkConfig.ark_server" type="text" placeholder="https://ark.signet.2nd.dev" class="w-full input-glass font-mono" />
<label class="text-white/60 text-sm block">Esplora (chain source)</label>
<input v-model="arkConfig.esplora" type="text" placeholder="https://esplora.signet.2nd.dev" class="w-full input-glass font-mono" />
<label class="text-white/60 text-sm block">Network</label>
<select v-model="arkConfig.network" class="w-full input-glass">
<option value="signet">signet</option>
<option value="mainnet">mainnet</option>
<option value="regtest">regtest</option>
</select>
<p class="text-[11px] text-white/40">
Applied when the Ark wallet is created an existing wallet stays bound to its server.
</p>
</div>
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="saveArkConfig"
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
</div>
</template>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@@ -167,8 +263,9 @@ const tabs = [
{ key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
{ key: 'ark' as const, label: 'Ark' },
]
const activeTab = ref<'channels' | 'cashu' | 'fedimint'>('channels')
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('channels')
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
@@ -194,12 +291,37 @@ const joiningFed = ref(false)
const fedError = ref('')
const fedJoinedOk = ref(false)
// ---- Ark (barkd sidecar, wallet.ark-* RPCs) ----
interface ArkStatus {
available: boolean
wallet_ready: boolean
config?: { network: string; ark_server: string; esplora: string }
}
interface ArkBalance {
spendable_sats: number
pending_sats: number
onchain_sats: number
}
const arkStatus = ref<ArkStatus | null>(null)
const arkBalance = ref<ArkBalance | null>(null)
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
const arkAddress = ref('')
const arkCopied = ref(false)
const loadingArk = ref(false)
const savingArk = ref(false)
const arkBoarding = ref(false)
const arkOffboarding = ref(false)
const arkError = ref('')
const arkOk = ref('')
const arkBusy = computed(() => savingArk.value || arkBoarding.value || arkOffboarding.value)
watch(
() => props.show,
(open) => {
if (open) {
loadMints()
if (fedimintBackendReady) loadFederations()
loadArk()
}
},
)
@@ -309,10 +431,111 @@ async function joinFederation() {
}
}
async function loadArk() {
loadingArk.value = true
arkError.value = ''
arkOk.value = ''
arkAddress.value = ''
try {
const status = await rpcClient.call<ArkStatus>({ method: 'wallet.ark-status' })
arkStatus.value = status
if (status.config) arkConfig.value = { ...status.config }
if (status.available) {
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
} else {
arkBalance.value = null
}
} catch {
arkStatus.value = { available: false, wallet_ready: false }
arkBalance.value = null
} finally {
loadingArk.value = false
}
}
async function fetchArkAddress(onchain: boolean) {
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ address: string }>({
method: 'wallet.ark-address',
params: { onchain },
})
arkAddress.value = res.address
arkCopied.value = false
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
}
}
async function copyArkAddress() {
if (!arkAddress.value) return
try {
await navigator.clipboard.writeText(arkAddress.value)
arkCopied.value = true
} catch {
/* clipboard unavailable (http) — the address is selectable */
}
}
async function boardArk() {
arkBoarding.value = true
arkError.value = ''
arkOk.value = ''
try {
// Boards everything; waits on Ark round participation, so give it room.
await rpcClient.call({ method: 'wallet.ark-board', timeout: 130000 })
arkOk.value = 'Boarding started — funds appear as spendable once the round confirms.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to board funds'
} finally {
arkBoarding.value = false
}
}
async function offboardArk() {
arkOffboarding.value = true
arkError.value = ''
arkOk.value = ''
try {
await rpcClient.call({ method: 'wallet.ark-offboard', timeout: 130000 })
arkOk.value = 'Offboard requested — funds return on-chain with the next round.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to offboard funds'
} finally {
arkOffboarding.value = false
}
}
async function saveArkConfig() {
savingArk.value = true
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ config: ArkStatus['config'] }>({
method: 'wallet.ark-configure',
params: { ...arkConfig.value },
})
if (res.config) arkConfig.value = { ...res.config }
arkOk.value = 'Ark configuration saved.'
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to save Ark configuration'
} finally {
savingArk.value = false
}
}
function close() {
mintError.value = ''
mintsSavedOk.value = false
fedError.value = ''
arkError.value = ''
arkOk.value = ''
emit('close')
}
</script>
+8 -1
View File
@@ -103,6 +103,7 @@ const emit = defineEmits<{
delete: [path: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
play: [path: string, name: string]
}>()
const cloudStore = useCloudStore()
@@ -110,7 +111,7 @@ const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const { isImage, isVideo, isAudio, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (!isImage.value || imgFailed.value) return null
@@ -122,8 +123,14 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isAudio.value) {
// Music goes to the global bottom-bar player, not the lightbox.
emit('play', props.item.path, props.item.name)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path)
} else {
// Non-media files open by downloading a click should always act on the file.
window.location.href = downloadHref.value
}
}
</script>
@@ -53,6 +53,7 @@
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
@preview="$emit('preview', $event)"
/>
+6 -32
View File
@@ -1,10 +1,10 @@
/**
* Public-demo helpers.
*
* The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but
* only once per calendar day per browser tracked in localStorage so it
* survives the short-lived backend session. Also exposes the shared demo
* credentials shown on the login screen.
* The demo build (VITE_DEMO=1) replays the typing splash + intro/onboarding on
* EVERY fresh boot of the app at '/' (first visit or browser refresh) see
* App.vue and RootRedirect.demoRoute. Also exposes the shared demo credentials
* shown on the login screen.
*/
export const IS_DEMO =
@@ -13,36 +13,10 @@ export const IS_DEMO =
/** Memorable shared password for the public demo (must match the mock backend). */
export const DEMO_PASSWORD = 'entertoexit'
const INTRO_DATE_KEY = 'demo_intro_date'
function todayKey(): string {
// Local calendar day, e.g. "2026-06-22".
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** True if this browser already watched the intro earlier today. */
export function demoIntroSeenToday(): boolean {
try {
return localStorage.getItem(INTRO_DATE_KEY) === todayKey()
} catch {
return false
}
}
/** Record that the intro has been seen today, so it won't replay until tomorrow. */
export function markDemoIntroSeen(): void {
try {
localStorage.setItem(INTRO_DATE_KEY, todayKey())
} catch {
/* ignore (private mode / storage disabled) */
}
}
/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */
/** Forget any legacy per-day gate marker (pre-2026-07 builds stored one). */
export function clearDemoIntroSeen(): void {
try {
localStorage.removeItem(INTRO_DATE_KEY)
localStorage.removeItem('demo_intro_date')
} catch {
/* ignore */
}
+9 -2
View File
@@ -251,8 +251,15 @@ export function playKeyboardTypingSound() {
}
/** Gaming-style boot thud - soft impact when dashboard loads */
export function playDashboardLoadOomph() {
if (!isFirstInstallPhase()) return
/**
* @param force Play regardless of install phase. The first dashboard entry
* right after the onboarding wizard needs this: by then onboarding is already
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
* exists to keep ordinary re-logins quiet) would silence the one entrance
* that SHOULD be loud.
*/
export function playDashboardLoadOomph(force = false) {
if (!force && !isFirstInstallPhase()) return
const ctx = getContext()
if (!ctx) return
-1
View File
@@ -198,7 +198,6 @@ export interface NostrConsentRequest {
reject: () => void
}
export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
+8 -5
View File
@@ -1715,26 +1715,28 @@ html.modal-scroll-locked .dashboard-scroll-panel {
.cloud-file-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
gap: 0.5rem;
padding-bottom: 1rem;
}
.cloud-file-item {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
transition: background-color 0.2s ease;
text-align: left;
width: 100%;
cursor: pointer;
background: none;
border: none;
/* Readable card rows (like the Peer Files list) bare text over the
wallpaper was unreadable. */
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.08);
color: inherit;
align-items: center;
}
.cloud-file-item:hover {
background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.08);
}
.cloud-file-item:active {
background: rgba(255, 255, 255, 0.1);
@@ -2087,6 +2089,7 @@ html.modal-scroll-locked .dashboard-scroll-panel {
.mobile-category-pill {
flex: 0 0 auto;
white-space: nowrap;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
@@ -28,4 +28,14 @@ describe('shouldShowIntroSplash', () => {
onboardingComplete: null,
})).toBe(false)
})
it('an explicit replay request overrides every suppression rule', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: true,
replayRequested: true,
})).toBe(true)
})
})
+3
View File
@@ -4,9 +4,12 @@ export interface IntroSplashDecisionInput {
fromBoot: boolean
devMode?: string
onboardingComplete: boolean | null
/** Explicit "Replay intro" click — overrides every suppression rule. */
replayRequested?: boolean
}
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true
if (input.seenIntro) return false
if (input.onboardingComplete === true) return false
+554 -8
View File
@@ -1,8 +1,199 @@
<template>
<div class="pb-6">
<div class="apps-view pb-6">
<!-- Content Type Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Nav header tabs + categories + search, matching the Apps layout -->
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search on one row -->
<div class="app-header-desktop items-center gap-4">
<div class="flex-shrink-0">
<div class="mode-switcher hidden md:inline-flex">
<button
v-for="tab in TABS"
:key="tab.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.name }}</button>
</div>
</div>
<div v-show="showCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
<button
v-for="category in CATEGORIES"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<div class="app-header-search-wrap flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
placeholder="Search your files and peer files…"
aria-label="Search files"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div>
</div>
<!-- Mobile: full-width tab switcher (distinct from the category pills),
category pill strip, then search. .mobile-category-strip opts the
pills out of the dashboard's swipe-to-switch-page gesture. -->
<div class="app-header-mobile mb-4">
<div class="cloud-tab-switcher cloud-tab-switcher-full mb-3">
<button
v-for="tab in TABS"
:key="tab.id"
@click="activeTab = tab.id"
class="cloud-tab-btn"
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
type="button"
>{{ tab.name }}</button>
</div>
<div v-if="showCategories" class="mobile-category-strip mb-3" aria-label="File categories">
<button
v-for="category in CATEGORIES"
:key="category.id"
@click="selectedCategory = category.id"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
type="button"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search your files and peer files…"
aria-label="Search files"
data-controller-no-submit
class="app-header-search w-full min-w-0 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div>
</div>
<!-- Search results (any tab, when a query is active) -->
<div v-if="searchActive">
<div v-if="searching" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Searching your files and peers
</div>
<template v-else>
<div v-if="filteredSearchResults.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
No files match {{ searchQuery }}.
</div>
<div v-else class="cloud-file-list">
<template v-for="r in filteredSearchResults" :key="r.key">
<!-- Own files act like real file rows: actions + click opens the file -->
<FileCard
v-if="r.item"
:item="r.item"
@delete="handleDelete"
@share="handleShare"
@play="handlePlay"
@preview="(p: string) => handlePreview(p, searchMineItems)"
/>
<button
v-else
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
@click="router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })"
>
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(r.category).iconBg">
<svg class="w-5 h-5" :class="categoryMeta(r.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(p, i) in categoryMeta(r.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
</svg>
</span>
<span class="flex-1 min-w-0">
<span class="block text-sm text-white truncate">{{ r.name }}</span>
<span class="block text-[11px] text-white/40 truncate">{{ r.detail }}</span>
</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ r.peerName }}</span>
</button>
</template>
</div>
</template>
</div>
<!-- My Files every own file, flat, with real file actions -->
<div v-else-if="activeTab === 'mine'">
<div v-if="myFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading your files
</div>
<div v-else-if="!fileBrowserRunning" class="glass-card p-8 text-center">
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open App Store
</RouterLink>
</div>
<div v-else-if="filteredMyFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'No files yet — upload some from the Folders tab.' : 'No files in this category.' }}
</div>
<div v-else class="cloud-file-list">
<FileCard
v-for="item in filteredMyFiles"
:key="item.path"
:item="item"
@delete="handleDelete"
@share="handleShare"
@play="handlePlay"
@preview="(p: string) => handlePreview(p, filteredMyFiles)"
/>
</div>
</div>
<!-- Peer Files tab every file shared by every peer -->
<div v-else-if="activeTab === 'peers'">
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Fetching files from {{ peerNodes.length || '' }} peer{{ peerNodes.length === 1 ? '' : 's' }}
</div>
<template v-else>
<div v-if="peerNodes.length === 0" class="glass-card p-8 text-center">
<p class="text-white/60 mb-3">No peers yet. Set up federation to browse files shared by other nodes.</p>
<RouterLink to="/dashboard/server/federation" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open Federation
</RouterLink>
</div>
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
</div>
<div v-else class="space-y-2">
<button
v-for="f in filteredPeerFiles"
:key="f.key"
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
@click="router.push({ name: 'peer-files', params: { peerId: f.peerOnion } })"
>
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(f.category).iconBg">
<svg class="w-5 h-5" :class="categoryMeta(f.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(p, i) in categoryMeta(f.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
</svg>
</span>
<span class="flex-1 min-w-0">
<span class="block text-sm text-white truncate">{{ f.filename }}</span>
<span class="block text-[11px] text-white/40 truncate">{{ formatSize(f.sizeBytes) }}<template v-if="f.priceSats"> · {{ f.priceSats.toLocaleString() }} sats</template></span>
</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
</button>
</div>
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered.
</p>
</template>
</div>
<!-- Folders section (+ peer) cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="section in contentSections"
:key="section.id"
@@ -48,6 +239,7 @@
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
</div>
</div>
<!-- Individual Peer Cards -->
<div
v-for="peer in peerNodes"
@@ -126,32 +318,84 @@
</div>
<!-- Error State -->
<div v-if="loadError" class="alert-error mb-4">
<div v-if="loadError" class="alert-error mt-4">
{{ loadError }}
</div>
<!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
<!-- Not Installed Hint (Folders tab only My Files has its own) -->
<div v-if="!fileBrowserRunning && !searchActive && activeTab === 'folders'" class="glass-card p-8 mt-6 text-center">
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open App Store
</RouterLink>
</div>
<!-- Share with peers -->
<ShareModal
v-if="shareTarget"
:filename="shareTarget.name"
:filepath="shareTarget.path"
:is-dir="shareTarget.isDir"
@close="shareTarget = null"
@saved="shareTarget = null"
/>
<!-- Media viewer for own files -->
<MediaLightbox
v-if="lightboxIndex !== null"
:items="lightboxItems"
:start-index="lightboxIndex"
:show="lightboxIndex !== null"
:fetch-blob-url="cloudStore.fetchBlobUrl"
:stream-url="cloudStore.streamUrl"
@close="lightboxIndex = null"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { fileBrowserClient } from '@/api/filebrowser-client'
import { useCloudStore } from '../stores/cloud'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType'
import { useAudioPlayer } from '../composables/useAudioPlayer'
import FileCard from '../components/cloud/FileCard.vue'
import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter()
const store = useAppStore()
const cloudStore = useCloudStore()
const audioPlayer = useAudioPlayer()
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers'
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
const TABS: Array<{ id: TabId; name: string }> = [
{ id: 'folders', name: 'Folders' },
{ id: 'mine', name: 'My Files' },
{ id: 'peers', name: 'Peer Files' },
]
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'all', name: 'All' },
{ id: 'photos', name: 'Photos & Video' },
{ id: 'music', name: 'Music' },
{ id: 'documents', name: 'Documents' },
]
const activeTab = ref<TabId>('folders')
const selectedCategory = ref<CategoryId>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0)
// Categories narrow file LISTS; the Folders tab is already organized by kind.
const showCategories = computed(() => activeTab.value !== 'folders' || searchActive.value)
interface PeerNode {
did: string
pubkey: string
@@ -244,6 +488,261 @@ const SECTION_PATHS: Record<string, string> = {
files: '/',
}
// Category helpers
function categoryOf(nameOrMime: string): Exclude<CategoryId, 'all'> {
const s = nameOrMime.toLowerCase()
if (s.startsWith('image/') || s.startsWith('video/') || /\.(jpe?g|png|gif|webp|heic|svg|mp4|mov|mkv|webm|avi)$/.test(s)) return 'photos'
if (s.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/.test(s)) return 'music'
return 'documents'
}
const FALLBACK_SECTION: ContentSection = contentSections[2]!
function categoryMeta(cat: Exclude<CategoryId, 'all'>): ContentSection {
return contentSections.find(s => s.id === cat) ?? FALLBACK_SECTION
}
function formatSize(bytes: number): string {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
// My Files (flat list of every own file across the sections)
const myFiles = ref<FileBrowserItem[]>([])
const myFilesLoading = ref(false)
const myFilesLoaded = ref(false)
/** Depth-limited walk of the section folders; flat file list, capped. */
async function loadMyFiles(force = false) {
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
myFilesLoading.value = true
try {
const ok = await cloudStore.init()
if (!ok) return
const out: FileBrowserItem[] = []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
while (queue.length > 0 && out.length < 500) {
const { path, depth } = queue.shift()!
let items: FileBrowserItem[]
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
for (const item of items) {
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
if (item.isDir) {
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else {
out.push({ ...item, path: itemPath })
}
}
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
myFiles.value = out
myFilesLoaded.value = true
} finally {
myFilesLoading.value = false
}
}
const filteredMyFiles = computed(() =>
selectedCategory.value === 'all'
? myFiles.value
: myFiles.value.filter(f => categoryOf(f.name) === selectedCategory.value),
)
watch(activeTab, (tab) => {
if (tab === 'mine') void loadMyFiles()
if (tab === 'peers') void loadPeerFiles()
if (tab === 'folders') selectedCategory.value = 'all' // no hidden filter behind the cards
})
// File actions shared by My Files rows and own-file search results
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
const lightboxIndex = ref<number | null>(null)
const lightboxItems = ref<FileBrowserItem[]>([])
function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir }
}
/** Music opens in the global bottom-bar player (GlobalAudioPlayer in App.vue). */
async function handlePlay(path: string, name: string) {
const url = await cloudStore.streamUrl(path)
audioPlayer.play(url, name)
}
function handlePreview(path: string, context: FileBrowserItem[]) {
// MediaLightbox filters to media internally; index within that filtered list.
const mediaItems = context.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
const idx = mediaItems.findIndex(item => item.path === path)
lightboxItems.value = context
lightboxIndex.value = idx >= 0 ? idx : 0
}
async function handleDelete(path: string) {
try {
await cloudStore.deleteItem(path)
myFiles.value = myFiles.value.filter(f => f.path !== path)
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Delete failed'
}
}
// Peer files (aggregated across every federation peer)
interface PeerFileEntry {
key: string
filename: string
sizeBytes: number
priceSats: number
category: Exclude<CategoryId, 'all'>
peerName: string
peerOnion: string
}
const peerFiles = ref<PeerFileEntry[]>([])
const peerFilesLoading = ref(false)
const peerFilesLoaded = ref(false)
const peerFilesErrors = ref(0)
interface CatalogItem {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
}
function priceOf(access: CatalogItem['access']): number {
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
}
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
async function loadPeerFiles(force = false) {
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
peerFilesLoading.value = true
peerFilesErrors.value = 0
try {
if (peerNodes.value.length === 0) await loadPeers()
const results = await Promise.allSettled(
peerNodes.value.map(async (peer) => {
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
params: { onion: peer.onion },
timeout: 30000,
})
return { peer, items: res?.items ?? [] }
}),
)
const merged: PeerFileEntry[] = []
for (const r of results) {
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
const { peer, items } = r.value
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
}
}
merged.sort((a, b) => a.filename.localeCompare(b.filename))
peerFiles.value = merged
peerFilesLoaded.value = true
} finally {
peerFilesLoading.value = false
}
}
const filteredPeerFiles = computed(() =>
selectedCategory.value === 'all'
? peerFiles.value
: peerFiles.value.filter(f => f.category === selectedCategory.value),
)
// Search (own files + all peer files)
interface SearchResult {
key: string
name: string
detail: string
category: Exclude<CategoryId, 'all'>
item?: FileBrowserItem
peerName?: string
peerOnion?: string
}
const searching = ref(false)
const searchResults = ref<SearchResult[]>([])
let searchTimer: ReturnType<typeof setTimeout> | null = null
let searchSeq = 0
watch(searchQuery, () => {
if (searchTimer) clearTimeout(searchTimer)
if (!searchActive.value) { searchResults.value = []; searching.value = false; return }
searching.value = true
searchTimer = setTimeout(() => void runSearch(), 350)
})
async function runSearch() {
const query = searchQuery.value.trim()
const seq = ++searchSeq
searching.value = true
try {
// Both corpora are cached flat lists after their first load.
await Promise.all([loadMyFiles(), loadPeerFiles()])
if (seq !== searchSeq) return // a newer query superseded this run
const q = query.toLowerCase()
const mine: SearchResult[] = myFiles.value
.filter(f => f.name.toLowerCase().includes(q))
.map(f => ({
key: `mine:${f.path}`,
name: f.name,
detail: f.path,
category: categoryOf(f.name),
item: f,
}))
const peers: SearchResult[] = peerFiles.value
.filter(f => f.filename.toLowerCase().includes(q))
.map(f => ({
key: `peer:${f.key}`,
name: f.filename,
detail: `${formatSize(f.sizeBytes)}${f.priceSats ? ` · ${f.priceSats.toLocaleString()} sats` : ''}`,
category: f.category,
peerName: f.peerName,
peerOnion: f.peerOnion,
}))
searchResults.value = [...mine, ...peers]
} finally {
if (seq === searchSeq) searching.value = false
}
}
const filteredSearchResults = computed(() =>
selectedCategory.value === 'all'
? searchResults.value
: searchResults.value.filter(r => r.category === selectedCategory.value),
)
/** Own-file items among the current search results (lightbox context). */
const searchMineItems = computed(() =>
filteredSearchResults.value.flatMap(r => (r.item ? [r.item] : [])),
)
// Existing counts / peers loading
async function loadCounts() {
if (!fileBrowserRunning.value) return
countsLoading.value = true
@@ -298,3 +797,50 @@ function openSection(section: ContentSection) {
defineExpose({ loadPeers })
</script>
<style scoped>
/* Mobile-only top-level tab switcher deliberately a DIFFERENT look from the
neutral category pills below it (orange tint). Desktop keeps the standard
mode-switcher styling. */
.cloud-tab-switcher {
gap: 2px;
padding: 3px;
border-radius: 0.5rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.22);
}
.cloud-tab-btn {
padding: 0.45rem 0.9rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.cloud-tab-btn:hover {
color: rgba(255, 255, 255, 0.9);
}
.cloud-tab-btn-active {
background: rgba(247, 147, 26, 0.25);
color: #ffd9a8;
}
/* Mobile: the three tabs share the full width equally. */
.cloud-tab-switcher-full {
display: flex;
width: 100%;
}
.cloud-tab-switcher-full .cloud-tab-btn {
flex: 1 1 0;
text-align: center;
padding: 0.6rem 0.25rem;
}
</style>
+3 -1
View File
@@ -369,7 +369,9 @@ onMounted(() => {
if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry
// right after onboarding (one-time event, persists in feel).
playDashboardLoadOomph()
// force=true: onboarding is already marked complete by now, so the
// un-forced call would gate itself silent.
playDashboardLoadOomph(true)
showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false)
+4 -1
View File
@@ -133,6 +133,7 @@
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-fedimint="walletFedimint"
:wallet-ark="walletArk"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@@ -515,6 +516,7 @@ const showSendModal = ref(false); const showReceiveModal = ref(false); const sho
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
const walletArk = ref(0)
const walletTransactions = ref<WalletTransaction[]>([])
// Overlay the explorer above the current page never navigate away.
@@ -532,7 +534,7 @@ interface EcashTransaction {
description: string
mint_url: string
peer: string
kind: 'cashu' | 'fedimint'
kind: 'cashu' | 'fedimint' | 'ark'
}
function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
@@ -557,6 +559,7 @@ async function loadWeb5Status() {
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
// already unifies both) previously only LND transactions were fetched
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
+19
View File
@@ -477,6 +477,7 @@ async function handleLogin() {
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
consumeOnboardingFinale()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => {
@@ -512,6 +513,7 @@ async function handleTotpVerify() {
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
consumeOnboardingFinale()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => {
@@ -533,11 +535,28 @@ async function handleTotpVerify() {
}
}
/** A login right after the onboarding wizard (flag set by OnboardingDone)
* gets the FULL dashboard entrance zoom + oomph even though it's a
* regular password login (e.g. the demo). Subsequent logins stay
* deliberately low-key (justLoggedIn only). */
function consumeOnboardingFinale() {
try {
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
sessionStorage.removeItem('archy_onboarding_finale')
loginTransition.setJustCompletedOnboarding(true)
}
} catch { /* ignore */ }
}
function replayIntro() {
// Clear the intro seen flag
localStorage.removeItem('neode_intro_seen')
// Demo: also clear the per-day gate so the intro plays again now.
if (IS_DEMO) clearDemoIntroSeen()
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
// what keeps fresh browsers on already-onboarded nodes from replaying it)
// this explicit one-shot flag tells it the replay is deliberate.
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
// Navigate to root to trigger splash screen
window.location.href = '/'
}
+4
View File
@@ -120,6 +120,10 @@ onUnmounted(() => {
function goToLogin() {
playNavSound('action')
// The login that follows the wizard gets the full dashboard entrance
// (zoom + oomph) even when it's a regular password login (e.g. the demo)
// Login.vue consumes this flag on success.
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
router.push('/login').catch(() => {})
}
</script>
+1 -4
View File
@@ -56,16 +56,13 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { playNavSound } from '@/composables/useNavSounds'
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
import { IS_DEMO } from '@/composables/useDemoIntro'
const router = useRouter()
const ctaButton = ref<HTMLButtonElement | null>(null)
const isDemo = IS_DEMO
onMounted(() => {
// Demo: once the visitor has seen the intro today, don't auto-replay it again
// until tomorrow (they can still use "Replay Intro" on the login screen).
if (IS_DEMO) markDemoIntroSeen()
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
setTimeout(() => {
ctaButton.value?.focus({ preventScroll: true })
+15 -12
View File
@@ -441,16 +441,16 @@
switch to the other if it has enough balance. -->
<div class="space-y-2">
<button
v-for="b in (['cashu', 'fedimint'] as const)"
v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
:key="b"
@click="ecashPlan.chosen = b"
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
>
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : b === 'fedimint' ? '🤝' : '⚓' }}</span>
<span class="flex-1 min-w-0">
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : b === 'fedimint' ? 'Fedimint' : 'Ark' }}</span>
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
</span>
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -707,10 +707,11 @@ const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
// Ecash confirmation step: after the user picks "pay from this node's ecash",
// we look at both balances, decide which backend covers the price, and show a
// confirm screen so they see (and can switch) which ecash is spent (#3).
type EcashBackend = 'cashu' | 'fedimint'
type EcashBackend = 'cashu' | 'fedimint' | 'ark'
const ecashPlan = ref<{
cashu: number
fedimint: number
ark: number
total: number
chosen: EcashBackend | null
} | null>(null)
@@ -1135,7 +1136,7 @@ async function pollOnchain(address: string) {
/** Spendable balance for a given ecash backend in the current plan. */
function ecashBalanceOf(b: EcashBackend): number {
if (!ecashPlan.value) return 0
return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint
return b === 'cashu' ? ecashPlan.value.cashu : b === 'fedimint' ? ecashPlan.value.fedimint : ecashPlan.value.ark
}
/**
@@ -1152,23 +1153,25 @@ async function prepareEcashPay() {
try {
let cashu = 0
let fedimint = 0
let ark = 0
try {
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; total_sats?: number; balance_sats?: number }>({
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; ark_sats?: number; total_sats?: number; balance_sats?: number }>({
method: 'wallet.ecash-balance',
})
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
fedimint = res?.fedimint_sats ?? 0
ark = res?.ark_sats ?? 0
} catch {
// Couldn't read balances let the user try anyway (auto backend).
}
const total = cashu + fedimint
// Prefer Cashu when it covers the price, else Fedimint, else leave null
// (insufficient shown in the confirm screen, Confirm disabled).
const total = cashu + fedimint + ark
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
// leave null (insufficient shown in the confirm screen, Confirm disabled).
const chosen: EcashBackend | null =
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null
ecashPlan.value = { cashu, fedimint, total, chosen }
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
if (!chosen) {
purchaseError.value = `Not enough ecash: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Fund a wallet, or pay another way.`
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
}
payMode.value = 'ecash-confirm'
} finally {
+6 -6
View File
@@ -16,20 +16,20 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { isOnboardingComplete } from '@/composables/useOnboarding'
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro'
import { IS_DEMO } from '@/composables/useDemoIntro'
import BootScreen from '@/components/BootScreen.vue'
const router = useRouter()
const showBootScreen = ref(false)
/**
* Public demo: replay the intro on every visit, but at most once per calendar
* day per browser. If already seen today straight to login; otherwise intro.
* Public demo: every fresh boot of the app (first visit OR browser refresh at
* the root) replays the intro for the full effect. In-session SPA navigation
* never re-triggers it this only runs when the app boots at '/'.
*/
function demoRoute() {
const dest = demoIntroSeenToday() ? '/login' : '/onboarding/intro'
log('demoRoute', { dest })
router.replace(dest).catch(() => {})
log('demoRoute', { dest: '/onboarding/intro' })
router.replace('/onboarding/intro').catch(() => {})
}
function log(msg: string, data?: unknown) {
+5 -1
View File
@@ -630,7 +630,11 @@ async function applyDnsConfig(customServers: string) {
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
const res = await rpcClient.configureDns(params)
networkData.value.dnsProvider = res.provider; networkData.value.dnsServers = res.servers; networkData.value.dnsDoH = res.doh_enabled
// Never trust the response shape: an undefined `servers` used to reach the
// dnsDisplayLabel computed and crash the whole page render on `.length`.
networkData.value.dnsProvider = res?.provider ?? provider
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
networkData.value.dnsDoH = !!res?.doh_enabled
showDnsModal.value = false
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
}
@@ -1,4 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import Cloud from '../Cloud.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -43,7 +44,7 @@ describe('Cloud peer list', () => {
it('keeps peer nodes visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
const wrapper = mount(Cloud)
const wrapper = mount(Cloud, { global: { plugins: [createPinia()] } })
await flushPromises()
expect(wrapper.text()).toContain('Peer Alpha')
+10 -1
View File
@@ -136,6 +136,14 @@
</div>
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
</div>
<!-- Only rendered once barkd reports a balance most nodes don't run the Ark sidecar -->
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Ark"></span>
<span class="text-sm text-white/80">Ark</span>
</div>
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
</div>
</div>
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
@@ -173,7 +181,7 @@ export interface WalletTransaction {
label: string
block_height: number
// Which rail the transaction happened on; absent = onchain (older backends)
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
const props = defineProps<{
@@ -183,6 +191,7 @@ const props = defineProps<{
walletLightning: number
walletEcash: number
walletFedimint: number
walletArk?: number
walletTransactions: WalletTransaction[]
isDev: boolean
}>()
@@ -94,6 +94,7 @@
</Teleport>
<!-- WiFi Scan Modal -->
<Teleport to="body">
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
@@ -161,8 +162,10 @@
</div>
</div>
</div>
</Teleport>
<!-- DNS Configuration Modal -->
<Teleport to="body">
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
@@ -223,6 +226,7 @@
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -362,6 +362,25 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.101-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.101-alpha</span>
<span class="text-xs text-white/40">July 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.</p>
<p>Every app you install now automatically gets its own private .onion address your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.</p>
<p>"Add Service" in the Tor panel now works for every app, not just a fixed list the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".</p>
<p>Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.</p>
<p>The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.</p>
<p>Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.</p>
<p>The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.</p>
<p>Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.</p>
<p>The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.</p>
<p>Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).</p>
</div>
</div>
<!-- v1.7.100-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+7
View File
@@ -64,6 +64,13 @@ FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
# federation first (the interim default is node-local). See docs/dual-ecash-design.md.
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.1"
# Ark (bark)
# barkd = Ark wallet daemon, packaged from the pinned upstream release binary
# (apps/barkd/Dockerfile). Signet-only default config; keep the tag in
# lockstep with core/archipelago/src/wallet/ark_client.rs REST shapes. Not in
# the bundled CONTAINER_IMAGES list — install via the barkd app manifest.
BARKD_IMAGE="$ARCHY_REGISTRY/barkd:0.3.0"
# Media
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"