Compare commits

..
Author SHA1 Message Date
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
56 changed files with 1281 additions and 174 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.
+8 -2
View File
@@ -55,14 +55,20 @@ impl RpcHandler {
}))
}
};
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
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);
+ bal
.get("pending_exit_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let onchain = client
.onchain_balance()
.await
+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()
@@ -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");
+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> {
@@ -696,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;
+15 -8
View File
@@ -210,7 +210,10 @@ impl ArkClient {
/// 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))
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.
@@ -220,7 +223,9 @@ impl ArkClient {
/// `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?;
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)
@@ -229,7 +234,9 @@ impl ArkClient {
/// `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?;
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)
@@ -277,7 +284,10 @@ impl ArkClient {
)
.await
}
None => self.post("/api/v1/boards/board-all", serde_json::json!({})).await,
None => {
self.post("/api/v1/boards/board-all", serde_json::json!({}))
.await
}
}
}
@@ -353,10 +363,7 @@ pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTra
Ok(m) => m,
Err(_) => return Vec::new(),
};
movements
.iter()
.filter_map(movement_to_tx)
.collect()
movements.iter().filter_map(movement_to_tx).collect()
}
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
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;
+233 -24
View File
@@ -1201,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 = [
@@ -1249,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)))
@@ -2165,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': {
@@ -2191,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 ` +
@@ -2418,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': {
@@ -2497,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: {
@@ -3315,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 },
@@ -4741,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>
@@ -4947,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.
+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 },
@@ -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">
+6 -2
View File
@@ -110,7 +110,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 +122,12 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isImage.value || isVideo.value) {
} else if (isImage.value || isVideo.value || isAudio.value) {
// MediaLightbox handles all three media kinds.
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>
+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
+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
+544 -8
View File
@@ -1,8 +1,197 @@
<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"
@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"
@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 +237,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 +316,82 @@
</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 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 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 +484,255 @@ 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 }
}
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 +787,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)
+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')
@@ -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">