Compare commits

..
Author SHA1 Message Date
Archipelago 1df8903f87 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
16 changed files with 752 additions and 147 deletions
+3 -11
View File
@@ -80,19 +80,11 @@ pub struct Config {
} }
impl 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> { async fn detect_host_ip() -> Result<String> {
let output = tokio::process::Command::new("hostname") Ok(crate::host_ip::primary_host_ipv4()
.args(["-I"])
.output()
.await .await
.context("Failed to run hostname -I")?; .unwrap_or_else(|| "127.0.0.1".to_string()))
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())
} }
pub async fn load() -> Result<Self> { pub async fn load() -> Result<Self> {
@@ -696,22 +696,11 @@ async fn netbird_configured_launch_url() -> Option<String> {
PodmanClient::lan_address_for("netbird") PodmanClient::lan_address_for("netbird")
} }
/// First address from `hostname -I` — the node's primary host IP. Mirrors the /// The node's primary host IP. Mirrors the orchestrator's `detect_host_ip`
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the /// so launch URLs match the cert/config the orchestrator renders for
/// orchestrator renders for `{{HOST_IP}}`. /// `{{HOST_IP}}`.
async fn first_host_ip() -> Option<String> { async fn first_host_ip() -> Option<String> {
let out = tokio::process::Command::new("hostname") crate::host_ip::primary_host_ipv4().await
.arg("-I")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.map(ToOwned::to_owned)
} }
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> { 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> { async fn detect_host_ip() -> Option<String> {
let output = tokio::process::Command::new("hostname") crate::host_ip::primary_host_ipv4().await
.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())
} }
async fn detect_host_mdns() -> String { async fn detect_host_mdns() -> String {
+135
View File
@@ -0,0 +1,135 @@
//! Primary host LAN IPv4 detection.
//!
//! `hostname -I` lists addresses in interface-creation order, so once a VPN
//! or bridge interface exists (NetBird's WireGuard tunnel, br-tollgate, …)
//! its address can sort ahead of the real NIC — a fresh-ISO node handed out
//! `https://10.44.0.1:8087` as NetBird's launch URL instead of the LAN IP.
//! The main routing table's default route names the physical uplink even when
//! a VPN is active (NetBird/Tailscale steer traffic via policy-routing rules
//! in separate tables, not by replacing the main-table default), so that is
//! the authoritative source, with `hostname -I` kept only as the last resort
//! for hosts with no default route at all.
/// The node's primary LAN IPv4, as a string.
///
/// Resolution order:
/// 1. `src`/`dev` of the main-table default route (`ip -4 route show default`)
/// 2. source address of a connected UDP socket (never transmits)
/// 3. first non-loopback IPv4 from `hostname -I` (legacy behaviour)
pub(crate) async fn primary_host_ipv4() -> Option<String> {
if let Some(ip) = default_route_ip().await {
return Some(ip);
}
if let Some(ip) = udp_route_ip() {
return Some(ip);
}
hostname_i_ip().await
}
async fn default_route_ip() -> Option<String> {
let out = tokio::process::Command::new("ip")
.args(["-4", "route", "show", "default"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let route = String::from_utf8_lossy(&out.stdout);
if let Some(ip) = parse_route_src(&route) {
return Some(ip);
}
// No `src` hint on the route — resolve the device's global address.
let dev = parse_route_dev(&route)?;
let out = tokio::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "dev", &dev, "scope", "global"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
parse_addr_inet(&String::from_utf8_lossy(&out.stdout))
}
fn parse_route_src(route: &str) -> Option<String> {
field_after(route.lines().next()?, "src")
}
fn parse_route_dev(route: &str) -> Option<String> {
field_after(route.lines().next()?, "dev")
}
fn field_after(line: &str, key: &str) -> Option<String> {
let mut words = line.split_whitespace();
while let Some(w) = words.next() {
if w == key {
return words.next().map(ToOwned::to_owned);
}
}
None
}
fn parse_addr_inet(out: &str) -> Option<String> {
let cidr = field_after(out.lines().next()?, "inet")?;
Some(cidr.split('/').next().unwrap_or(&cidr).to_string())
}
/// A connected UDP socket's local address is the source IP the kernel would
/// use to reach the peer; nothing is sent. Can still land on a tunnel IP when
/// a VPN policy-routes all traffic, hence only a fallback.
fn udp_route_ip() -> Option<String> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
match sock.local_addr().ok()?.ip() {
std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_unspecified() => {
Some(v4.to_string())
}
_ => None,
}
}
async fn hostname_i_ip() -> Option<String> {
let out = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_src_wins() {
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600";
assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116"));
}
#[test]
fn route_dev_without_src() {
let route = "default via 192.168.1.1 dev enp0s31f6 proto static";
assert_eq!(parse_route_src(route), None);
assert_eq!(parse_route_dev(route).as_deref(), Some("enp0s31f6"));
}
#[test]
fn addr_inet_strips_prefix() {
let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65"));
}
#[test]
fn empty_route_table() {
assert_eq!(parse_route_src(""), None);
assert_eq!(parse_route_dev(""), None);
}
}
+1
View File
@@ -50,6 +50,7 @@ mod electrs_status;
mod federation; mod federation;
mod fips; mod fips;
mod health_monitor; mod health_monitor;
mod host_ip;
mod identity; mod identity;
mod identity_manager; mod identity_manager;
mod marketplace; mod marketplace;
+6 -4
View File
@@ -7,7 +7,9 @@
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<title id="pageTitle">Bitcoin Node - Archipelago</title> <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> <style>
* { * {
margin: 0; margin: 0;
@@ -339,7 +341,7 @@
<div class="logo-gradient-border"> <div class="logo-gradient-border">
<img <img
id="implLogo" id="implLogo"
src="/assets/img/app-icons/bitcoin-knots.webp" src="assets/img/app-icons/bitcoin-knots.webp"
alt="Bitcoin Node" alt="Bitcoin Node"
class="w-16 h-16" class="w-16 h-16"
style="object-fit: contain;" style="object-fit: contain;"
@@ -984,8 +986,8 @@
? 'Enhanced Bitcoin node implementation' ? 'Enhanced Bitcoin node implementation'
: 'Reference Bitcoin node implementation'; : 'Reference Bitcoin node implementation';
const icon = isKnots const icon = isKnots
? '/assets/img/app-icons/bitcoin-knots.webp' ? 'assets/img/app-icons/bitcoin-knots.webp'
: '/assets/img/app-icons/bitcoin-core.svg'; : 'assets/img/app-icons/bitcoin-core.svg';
const pageTitle = document.getElementById('pageTitle'); const pageTitle = document.getElementById('pageTitle');
const implName = document.getElementById('implName'); const implName = document.getElementById('implName');
const implTagline = document.getElementById('implTagline'); const implTagline = document.getElementById('implTagline');
+1 -1
View File
@@ -386,7 +386,7 @@
<section class="glass-card"> <section class="glass-card">
<div class="header"> <div class="header">
<div class="logo-gradient-border"> <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>
<div class="title"> <div class="title">
<h1>Fedimint Guardian</h1> <h1>Fedimint Guardian</h1>
+6 -3
View File
@@ -91,7 +91,10 @@ http {
} }
# Proxy FileBrowser API to mock backend (demo mode) # 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; client_max_body_size 10G;
proxy_pass http://neode-backend:5959; proxy_pass http://neode-backend:5959;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -103,7 +106,7 @@ http {
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers, # IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
# and rewrite its absolute asset paths (/assets, /, src, href) to the # and rewrite its absolute asset paths (/assets, /, src, href) to the
# /app/indeedhub/ prefix so the SPA loads inside the iframe. # /app/indeedhub/ prefix so the SPA loads inside the iframe.
location /app/indeedhub/ { location ^~ /app/indeedhub/ {
proxy_pass https://indee.tx1138.com/; proxy_pass https://indee.tx1138.com/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host indee.tx1138.com; 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 # 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 # the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
# generic "Not available in the demo" notice for the rest. # generic "Not available in the demo" notice for the rest.
location /app/ { location ^~ /app/ {
proxy_pass http://neode-backend:5959; proxy_pass http://neode-backend:5959;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
+64 -7
View File
@@ -1249,7 +1249,8 @@ const DOCKER_UI = path.join(__dirname, '..', 'docker')
for (const [prefixes, dir] of [ for (const [prefixes, dir] of [
[['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'], [['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'],
[['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-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'], [['/app/fedimint', '/app/fedimintd'], 'fedimint-ui'],
]) { ]) {
for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir))) for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir)))
@@ -2195,6 +2196,15 @@ app.post('/rpc/v1', (req, res) => {
case 'content.download-peer-paid': case 'content.download-peer-paid':
case 'content.download-peer-invoice': case 'content.download-peer-invoice':
case 'content.download-peer-onchain': { 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 filename = params?.filename || 'demo-content'
const body = Buffer.from( const body = Buffer.from(
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` + `Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
@@ -2418,20 +2428,34 @@ app.post('/rpc/v1', (req, res) => {
} }
case 'network.dns-status': { 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({ return res.json({
result: { result: {
provider: 'system', provider: dns.provider,
servers: ['1.1.1.1', '9.9.9.9'], servers: dns.servers,
doh_enabled: false, doh_enabled: dns.doh_enabled,
doh_url: null, doh_url: null,
resolv_conf_servers: ['1.1.1.1', '9.9.9.9'], resolv_conf_servers: dns.servers,
}, },
}) })
} }
case 'network.configure-dns': { case 'network.configure-dns': {
console.log(`[Network] DNS configured: ${params?.provider}`) const dnsProviders = {
return res.json({ result: { success: true } }) 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': { case 'network.accept-request': {
@@ -3315,10 +3339,15 @@ app.post('/rpc/v1', (req, res) => {
// Wallet / Ecash (Fedimint) // Wallet / Ecash (Fedimint)
// ===================================================================== // =====================================================================
case 'wallet.ecash-balance': { case 'wallet.ecash-balance': {
const fedSats = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
return res.json({ return res.json({
result: { result: {
balance_sats: walletState.ecash_sats, balance_sats: walletState.ecash_sats,
balance_msat: walletState.ecash_sats * 1000, 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, token_count: walletState.ecash_tokens,
federations: [ federations: [
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true }, { federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
@@ -4741,6 +4770,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 // something plausible in the in-app iframe. Registered before the generic
// /app/:id notice handler so these win. // /app/:id notice handler so these win.
const DEMO_APP_PAGES = { 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', ` 'btcpay-server': () => demoAppShell('BTCPay Server', 'Self-hosted payment processor · signet', '/assets/img/app-icons/btcpay-server.png', `
<div class="grid"> <div class="grid">
<div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div> <div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div>
@@ -3,12 +3,12 @@
<!-- Method tabs --> <!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
v-for="m in (['onchain', 'lightning', 'ecash'] as const)" v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] as const)"
:key="m" :key="m"
@click="receiveMethod = m" @click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors" 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'" :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> </div>
<!-- Lightning --> <!-- Lightning -->
@@ -43,6 +43,19 @@
</div> </div>
</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 --> <!-- Ecash -->
<div v-if="receiveMethod === 'ecash'"> <div v-if="receiveMethod === 'ecash'">
<div class="mb-3"> <div class="mb-3">
@@ -57,7 +70,7 @@
<div class="flex gap-3"> <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="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"> <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> </button>
</div> </div>
</BaseModal> </BaseModal>
@@ -75,15 +88,17 @@ const { t } = useI18n()
defineProps<{ show: boolean }>() defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; received: [] }>() 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 invoiceAmount = ref<number>(0)
const invoiceMemo = ref('') const invoiceMemo = ref('')
const invoiceResult = ref('') const invoiceResult = ref('')
const onchainAddress = ref('') const onchainAddress = ref('')
const arkAddress = ref('')
const ecashToken = ref('') const ecashToken = ref('')
const ecashResult = ref('') const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null) const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null) const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false) const processing = ref(false)
const error = ref('') const error = ref('')
@@ -102,6 +117,7 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
function close() { function close() {
invoiceResult.value = '' invoiceResult.value = ''
onchainAddress.value = '' onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = '' ecashToken.value = ''
ecashResult.value = '' ecashResult.value = ''
error.value = '' error.value = ''
@@ -131,6 +147,11 @@ async function receive() {
} }
onchainAddress.value = res.address onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:')) 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 { } else {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return } if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
// The backend auto-detects the token type: a Cashu token (cashuA/B) is // The backend auto-detects the token type: a Cashu token (cashuA/B) is
+21 -6
View File
@@ -3,12 +3,12 @@
<!-- Method tabs --> <!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)" v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
:key="m" :key="m"
@click="sendMethod = m" @click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors" 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'" :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>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg"> <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"> <div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1"> <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> </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>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg"> <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"> <div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p> <p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div> </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> <div v-if="error" class="mb-3 alert-error">{{ error }}</div>
@@ -62,13 +65,14 @@ const { t } = useI18n()
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>() 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 amount = ref<number>(0)
const dest = ref('') const dest = ref('')
const processing = ref(false) const processing = ref(false)
const error = ref('') const error = ref('')
const resultTxid = ref('') const resultTxid = ref('')
const resultHash = ref('') const resultHash = ref('')
const resultArk = ref('')
const ecashToken = ref('') const ecashToken = ref('')
const effectiveMethod = computed(() => { const effectiveMethod = computed(() => {
@@ -84,6 +88,7 @@ function close() {
error.value = '' error.value = ''
resultTxid.value = '' resultTxid.value = ''
resultHash.value = '' resultHash.value = ''
resultArk.value = ''
ecashToken.value = '' ecashToken.value = ''
emit('close') emit('close')
} }
@@ -99,10 +104,20 @@ async function send() {
ecashToken.value = '' ecashToken.value = ''
resultTxid.value = '' resultTxid.value = ''
resultHash.value = '' resultHash.value = ''
resultArk.value = ''
const method = effectiveMethod.value const method = effectiveMethod.value
try { 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 }>({ const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send', method: 'wallet.ecash-send',
params: { amount_sats: amount.value }, params: { amount_sats: amount.value },
@@ -1,5 +1,7 @@
<template> <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 <!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. --> 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"> <div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
+458 -72
View File
@@ -1,10 +1,163 @@
<template> <template>
<div class="pb-6"> <div class="apps-view pb-6">
<!-- Content Type Cards --> <!-- Nav header tabs + categories + search, matching the Apps layout -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <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 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: pill strips (NOT the fixed top tabs those stay page-level
navigation) + search below. .mobile-category-strip also opts these
rows out of the dashboard's swipe-to-switch-page gesture. -->
<div class="app-header-mobile mb-4">
<div class="mobile-category-strip mb-2" aria-label="Cloud tabs">
<button
v-for="tab in TABS"
:key="tab.id"
@click="activeTab = tab.id"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': activeTab === tab.id }"
type="button"
>{{ tab.name }}</button>
</div>
<div 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="space-y-2">
<button
v-for="r in filteredSearchResults"
:key="r.key"
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
@click="openSearchResult(r)"
>
<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 shrink-0"
:class="r.source === 'mine' ? 'bg-blue-500/15 text-blue-400' : 'bg-purple-500/15 text-purple-400'"
>{{ r.source === 'mine' ? 'My Files' : r.peerName }}</span>
</button>
</div>
</template>
</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>
<!-- All Files / My Files section (+ peer) cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div <div
v-for="section in contentSections" v-for="section in visibleSections"
:key="section.id" :key="section.id"
data-controller-container data-controller-container
tabindex="0" tabindex="0"
@@ -48,90 +201,93 @@
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span> <span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
</div> </div>
</div> </div>
<!-- Individual Peer Cards -->
<div <!-- Individual Peer Cards (All Files tab only) -->
v-for="peer in peerNodes" <template v-if="activeTab === 'all'">
:key="peer.did" <div
data-controller-container v-for="peer in peerNodes"
tabindex="0" :key="peer.did"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10" data-controller-container
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })" tabindex="0"
> class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
<div class="flex items-center gap-4 mb-4"> @click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15"> >
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex items-center gap-4 mb-4">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /> <div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate" :title="peer.did">{{ peer.name || peerDisplayName(peer.did) }}</h3>
<p class="text-xs text-white/40 truncate">{{ peer.name ? peer.did.slice(0, 20) + '...' : 'Peer node' }}</p>
</div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex items-center gap-2 text-xs">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate" :title="peer.did">{{ peer.name || peerDisplayName(peer.did) }}</h3> <span
<p class="text-xs text-white/40 truncate">{{ peer.name ? peer.did.slice(0, 20) + '...' : 'Peer node' }}</p> class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
{{ peer.trust_level }}
</span>
<span class="text-white/30">Peer Node</span>
</div> </div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<div
v-if="peersLoading && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
>
<svg class="animate-spin h-3.5 w-3.5" 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> </svg>
Refreshing peer nodes...
</div> </div>
<div class="flex items-center gap-2 text-xs">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
{{ peer.trust_level }}
</span>
<span class="text-white/30">Peer Node</span>
</div>
</div>
<div <!-- No Peers placeholder (only if no peers found) -->
v-if="peersLoading && peerNodes.length > 0" <div
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2" v-if="!peersLoading && peerNodes.length === 0 && selectedCategory === 'all'"
> data-controller-container
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24"> tabindex="0"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
<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> @click="router.push('/dashboard/server/federation')"
</svg> >
Refreshing peer nodes... <div class="flex items-center gap-4 mb-4">
</div> <div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- No Peers placeholder (only if no peers found) --> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
<div </svg>
v-if="!peersLoading && peerNodes.length === 0" </div>
data-controller-container <div class="flex-1 min-w-0">
tabindex="0" <h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10" <p class="text-xs text-white/50">Set up federation to share files with peers</p>
@click="router.push('/dashboard/server/federation')" </div>
> <svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="flex items-center gap-4 mb-4"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex items-center gap-2 text-xs">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3> <span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
<p class="text-xs text-white/50">Set up federation to share files with peers</p> <span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
No peers yet
</span>
</div> </div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div> </div>
<div class="flex items-center gap-2 text-xs"> </template>
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
No peers yet
</span>
</div>
</div>
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-if="loadError" class="alert-error mb-4"> <div v-if="loadError" class="alert-error mt-4">
{{ loadError }} {{ loadError }}
</div> </div>
<!-- Not Installed Hint --> <!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center"> <div v-if="!fileBrowserRunning && !searchActive && activeTab !== 'peers'" 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> <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"> <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 Open App Store
@@ -141,7 +297,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { fileBrowserClient } from '@/api/filebrowser-client' import { fileBrowserClient } from '@/api/filebrowser-client'
@@ -152,6 +308,27 @@ const store = useAppStore()
const sectionCounts = ref<Record<string, number>>({}) const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false) const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'all' | 'mine' | 'peers'
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
const TABS: Array<{ id: TabId; name: string }> = [
{ id: 'all', name: 'All Files' },
{ 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>('all')
const selectedCategory = ref<CategoryId>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0)
interface PeerNode { interface PeerNode {
did: string did: string
pubkey: string pubkey: string
@@ -237,6 +414,13 @@ const contentSections: ContentSection[] = [
}, },
] ]
/** Section cards for the current tab, narrowed by the category pills. The
* browse-everything "All Files" card only makes sense unfiltered. */
const visibleSections = computed(() => {
if (selectedCategory.value === 'all') return contentSections
return contentSections.filter(s => s.id === selectedCategory.value)
})
const SECTION_PATHS: Record<string, string> = { const SECTION_PATHS: Record<string, string> = {
photos: '/Photos', photos: '/Photos',
music: '/Music', music: '/Music',
@@ -244,6 +428,208 @@ const SECTION_PATHS: Record<string, string> = {
files: '/', 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`
}
// 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),
)
// Fetch the aggregated list lazily, the first time the tab (or a search) needs it.
watch(activeTab, (tab) => { if (tab === 'peers') void loadPeerFiles() })
// Search (own files + all peer files)
interface SearchResult {
key: string
name: string
detail: string
category: Exclude<CategoryId, 'all'>
source: 'mine' | 'peer'
peerName?: string
peerOnion?: string
sectionId?: 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)
})
/** Depth-limited walk of the own-file sections via the FileBrowser API. */
async function searchOwnFiles(query: string): Promise<SearchResult[]> {
if (!fileBrowserRunning.value) return []
const q = query.toLowerCase()
const out: SearchResult[] = []
try {
const ok = await fileBrowserClient.login()
if (!ok) return []
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 < 100) {
const { path, depth } = queue.shift()!
let items
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 if (item.name.toLowerCase().includes(q)) {
out.push({
key: `mine:${itemPath}`,
name: item.name,
detail: itemPath,
category: categoryOf(item.name),
source: 'mine',
sectionId,
})
}
}
}
}
} catch { /* own-file search is best-effort; peer results still render */ }
return out
}
async function runSearch() {
const query = searchQuery.value.trim()
const seq = ++searchSeq
searching.value = true
try {
// Peer catalog piggybacks on the aggregated list (cached after first load).
const [mine] = await Promise.all([searchOwnFiles(query), loadPeerFiles()])
if (seq !== searchSeq) return // a newer query superseded this run
const q = query.toLowerCase()
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,
source: 'peer' as const,
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),
)
function openSearchResult(r: SearchResult) {
if (r.source === 'peer' && r.peerOnion) {
router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })
} else if (r.sectionId) {
router.push({ name: 'cloud-folder', params: { folderId: r.sectionId } })
}
}
// Existing counts / peers loading
async function loadCounts() { async function loadCounts() {
if (!fileBrowserRunning.value) return if (!fileBrowserRunning.value) return
countsLoading.value = true countsLoading.value = true
+15 -12
View File
@@ -441,16 +441,16 @@
switch to the other if it has enough balance. --> switch to the other if it has enough balance. -->
<div class="space-y-2"> <div class="space-y-2">
<button <button
v-for="b in (['cashu', 'fedimint'] as const)" v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
:key="b" :key="b"
@click="ecashPlan.chosen = b" @click="ecashPlan.chosen = b"
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)" :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="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'" :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="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 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> </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"> <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", // 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 // 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). // 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<{ const ecashPlan = ref<{
cashu: number cashu: number
fedimint: number fedimint: number
ark: number
total: number total: number
chosen: EcashBackend | null chosen: EcashBackend | null
} | null>(null) } | null>(null)
@@ -1135,7 +1136,7 @@ async function pollOnchain(address: string) {
/** Spendable balance for a given ecash backend in the current plan. */ /** Spendable balance for a given ecash backend in the current plan. */
function ecashBalanceOf(b: EcashBackend): number { function ecashBalanceOf(b: EcashBackend): number {
if (!ecashPlan.value) return 0 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 { try {
let cashu = 0 let cashu = 0
let fedimint = 0 let fedimint = 0
let ark = 0
try { 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', method: 'wallet.ecash-balance',
}) })
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0 cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
fedimint = res?.fedimint_sats ?? 0 fedimint = res?.fedimint_sats ?? 0
ark = res?.ark_sats ?? 0
} catch { } catch {
// Couldn't read balances let the user try anyway (auto backend). // Couldn't read balances let the user try anyway (auto backend).
} }
const total = cashu + fedimint const total = cashu + fedimint + ark
// Prefer Cashu when it covers the price, else Fedimint, else leave null // Prefer Cashu when it covers the price, else Fedimint, else Ark, else
// (insufficient shown in the confirm screen, Confirm disabled). // leave null (insufficient shown in the confirm screen, Confirm disabled).
const chosen: EcashBackend | null = const chosen: EcashBackend | null =
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
ecashPlan.value = { cashu, fedimint, total, chosen } ecashPlan.value = { cashu, fedimint, ark, total, chosen }
if (!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' payMode.value = 'ecash-confirm'
} finally { } finally {
+5 -1
View File
@@ -630,7 +630,11 @@ async function applyDnsConfig(customServers: string) {
const params: { provider: DnsProviderValue; servers?: string[] } = { provider } const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) } if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
const res = await rpcClient.configureDns(params) 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 showDnsModal.value = false
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false } } catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
} }
@@ -94,6 +94,7 @@
</Teleport> </Teleport>
<!-- WiFi Scan Modal --> <!-- 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 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="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -161,8 +162,10 @@
</div> </div>
</div> </div>
</div> </div>
</Teleport>
<!-- DNS Configuration Modal --> <!-- 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 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="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -223,6 +226,7 @@
</div> </div>
</div> </div>
</div> </div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">