Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13ee58edf | ||
|
|
6e5a99ef71 | ||
|
|
749c351e5e | ||
|
|
dc897064cd | ||
|
|
178ba85c5c | ||
|
|
0f5ea9ae15 | ||
|
|
5d4d40e9e8 | ||
|
|
8caa4c7d07 | ||
|
|
4983ba4e20 | ||
|
|
994795e4d2 | ||
|
|
4226b5ec0a | ||
|
|
8f6312fe7b | ||
|
|
bdf283fcff | ||
|
|
df90cdaac9 | ||
|
|
0cf91136f9 | ||
|
|
6d7b39578e | ||
|
|
13909e28bf | ||
|
|
97488c83f7 | ||
|
|
eaf8b7b63e | ||
|
|
46f7ac3fcf | ||
|
|
76ad14ef64 | ||
|
|
15b99a65e0 | ||
|
|
c49de3eb01 | ||
|
|
2f78fb6907 | ||
|
|
2fce4fb842 | ||
|
|
24be2e9e69 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ mod electrs_status;
|
||||
mod federation;
|
||||
mod fips;
|
||||
mod health_monitor;
|
||||
mod host_ip;
|
||||
mod identity;
|
||||
mod identity_manager;
|
||||
mod marketplace;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">02c9f0a1…e47b@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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '/'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||