Compare commits
69
Commits
@@ -741,7 +741,16 @@ private fun buildAutoLoginScript(password: String): String {
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(el, pw);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
// Let Vue re-render before submitting: a synchronous Enter arrives
|
||||
// while the login button is still disabled, and the web UI's
|
||||
// controller-nav "Enter in input clicks the next enabled button"
|
||||
// pattern then hits Replay Intro instead — restarting the intro
|
||||
// cinematic on every connect (two frames = value flush + render).
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
});
|
||||
});
|
||||
}, 1500);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.108-alpha (2026-07-20)
|
||||
|
||||
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
|
||||
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
|
||||
- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.
|
||||
- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).
|
||||
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
|
||||
|
||||
## v1.7.106-alpha (2026-07-20)
|
||||
|
||||
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||
- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.
|
||||
- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.
|
||||
- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.
|
||||
- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.
|
||||
|
||||
## v1.7.105-alpha (2026-07-20)
|
||||
|
||||
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
|
||||
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
|
||||
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
|
||||
- The companion phone app no longer suggests installing the companion app from inside itself.
|
||||
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
|
||||
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
|
||||
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
|
||||
|
||||
## v1.7.104-alpha (2026-07-19)
|
||||
|
||||
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
|
||||
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
|
||||
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
|
||||
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
|
||||
|
||||
## v1.7.103-alpha (2026-07-18)
|
||||
|
||||
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
|
||||
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
|
||||
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
|
||||
|
||||
## v1.7.102-alpha (2026-07-17)
|
||||
|
||||
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
|
||||
- Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.
|
||||
- Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.
|
||||
- First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.
|
||||
- The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.
|
||||
- The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.
|
||||
- Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.
|
||||
- Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.
|
||||
- Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -10,9 +10,16 @@ app:
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
|
||||
# on which version the node runs (multi-version switch) — probe which name
|
||||
# resolves on archy-net instead of hardcoding knots, which left electrumx
|
||||
# permanently disconnected (block index 0) on core nodes.
|
||||
custom_args:
|
||||
- >-
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||
for h in bitcoin-knots bitcoin-core; do
|
||||
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
|
||||
done;
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
|
||||
exec electrumx_server
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.101-alpha"
|
||||
version = "1.7.108-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.101-alpha"
|
||||
version = "1.7.108-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -207,7 +207,11 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
|
||||
let file = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("backups")
|
||||
.join(format!("{id}.bak"));
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
|
||||
@@ -147,6 +147,15 @@ impl RpcHandler {
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
tracing::info!("[onboarding] user setup complete");
|
||||
|
||||
// The install-time password must also become the OS login for the
|
||||
// archipelago user — otherwise the console/SSH keeps the image default
|
||||
// ("archipelago") after the user has picked a real password (#97).
|
||||
// Best-effort: a failure here must not break onboarding.
|
||||
match crate::auth::change_ssh_password(password).await {
|
||||
Ok(()) => tracing::info!("[onboarding] system login password synced"),
|
||||
Err(e) => tracing::warn!("[onboarding] system login password sync failed: {e}"),
|
||||
}
|
||||
|
||||
// Persist the pending onboarding seed as the encrypted backup now that
|
||||
// a passphrase (the login password) finally exists — otherwise "Reveal
|
||||
// recovery phrase" has nothing to decrypt on this node, ever.
|
||||
|
||||
@@ -95,33 +95,54 @@ impl RpcHandler {
|
||||
.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Amount must be at least 546 sats (dust limit)"
|
||||
));
|
||||
}
|
||||
if amount > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
// send_all sweeps the entire confirmed on-chain balance (LND computes
|
||||
// the amount after fees); amount is required otherwise.
|
||||
let send_all = params
|
||||
.get("send_all")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let amount = if send_all {
|
||||
None
|
||||
} else {
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Amount must be at least 546 sats (dust limit)"
|
||||
));
|
||||
}
|
||||
if amount > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
Some(amount)
|
||||
};
|
||||
|
||||
// Validate Bitcoin address format (basic: length and allowed chars)
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
||||
info!(
|
||||
addr = addr,
|
||||
amount = amount,
|
||||
send_all = send_all,
|
||||
"Sending on-chain Bitcoin"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
});
|
||||
let send_body = match amount {
|
||||
Some(amount) => serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"addr": addr,
|
||||
"send_all": true,
|
||||
}),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
|
||||
@@ -438,7 +438,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("RPC error on {}: {}", rpc_req.method, e);
|
||||
// `{:#}` renders the whole anyhow context chain. Logging only the
|
||||
// outermost context threw away the actual cause: a peer-files
|
||||
// failure logged just "Failed to connect to peer", with the real
|
||||
// error (Tor SOCKS failure, FIPS resolve, timeout) discarded — so
|
||||
// the logs couldn't distinguish a dead peer from a slow circuit.
|
||||
// The client-facing message below stays `{}` so internals aren't leaked.
|
||||
error!("RPC error on {}: {:#}", rpc_req.method, e);
|
||||
let user_message = sanitize_error_message(&e.to_string());
|
||||
RpcResponse {
|
||||
result: None,
|
||||
|
||||
@@ -394,9 +394,9 @@ where
|
||||
// under any name variant AND no install in flight — waiting cannot
|
||||
// satisfy it.
|
||||
let some_dep_not_installed = missing.iter().any(|dep| {
|
||||
!dep.containers.iter().any(|c| {
|
||||
existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c)
|
||||
})
|
||||
!dep.containers
|
||||
.iter()
|
||||
.any(|c| existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c))
|
||||
});
|
||||
if some_dep_not_installed {
|
||||
let msg = match check_install_deps(package_id, &running) {
|
||||
|
||||
@@ -1140,8 +1140,7 @@ impl RpcHandler {
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -700,9 +700,7 @@ async fn install_stack_via_orchestrator(
|
||||
// Truthful end-of-install signal, mirroring the legacy stack installers:
|
||||
// the real readiness gate is the scanner's next sweep, this just settles
|
||||
// the bar at 95→100→done instead of leaving it mid-band.
|
||||
handler
|
||||
.set_install_progress(stack_name, total, total)
|
||||
.await;
|
||||
handler.set_install_progress(stack_name, total, total).await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PostInstall)
|
||||
.await;
|
||||
|
||||
@@ -402,6 +402,16 @@ async fn sync_hostname_side_effects(hostname: &str) {
|
||||
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
|
||||
}
|
||||
|
||||
// The kiosk Chromium's profile lock is a symlink encoding <hostname>-<pid>;
|
||||
// after a rename the stale lock reads as "another computer" holding the
|
||||
// profile, Chromium refuses to start (--noerrdialogs hides the dialog), and
|
||||
// the kiosk black-screens on the next boot (#98). Clear it here — Chromium
|
||||
// recreates the files on launch, and the kiosk launcher pkills any running
|
||||
// instance before starting a new one.
|
||||
for f in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
|
||||
let _ = tokio::fs::remove_file(format!("/var/lib/archipelago/chromium-kiosk/{f}")).await;
|
||||
}
|
||||
|
||||
let republished = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
|
||||
.output()
|
||||
|
||||
@@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
/// Services for known-but-uninstalled apps are hidden (issue #79).
|
||||
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let services = list_services(&config_dir).await?;
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut apps = AppInstallState {
|
||||
known: Default::default(),
|
||||
installed: Default::default(),
|
||||
};
|
||||
for (id, pkg) in &data.package_data {
|
||||
apps.known.insert(id.clone());
|
||||
if pkg.installed.is_some() {
|
||||
apps.installed.insert(id.clone());
|
||||
}
|
||||
}
|
||||
let services = list_services(&config_dir, Some(&apps)).await?;
|
||||
let tor_running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||
}
|
||||
|
||||
@@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
|
||||
|
||||
// ─── Service Listing ─────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
||||
/// Which packages the node knows about and which are installed — used to
|
||||
/// hide hidden services for apps that aren't installed. ISO first-boot used
|
||||
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
|
||||
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
|
||||
/// never installed (issue #79).
|
||||
pub(super) struct AppInstallState {
|
||||
pub known: std::collections::HashSet<String>,
|
||||
pub installed: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
/// Package ids a Tor service name may correspond to. Service names predate
|
||||
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
|
||||
/// can map to several package ids.
|
||||
fn service_alias_candidates(name: &str) -> Vec<&str> {
|
||||
match name {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
|
||||
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
|
||||
}
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx", "electrs", "mempool-electrs"]
|
||||
}
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => {
|
||||
vec!["btcpay", "btcpay-server", "btcpayserver"]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
|
||||
other => vec![other],
|
||||
}
|
||||
}
|
||||
|
||||
impl AppInstallState {
|
||||
/// A service is listed unless it names a known-but-uninstalled app.
|
||||
/// The node's own service, the content relay, and custom user-created
|
||||
/// services (names matching no catalog package) always show.
|
||||
fn service_visible(&self, name: &str) -> bool {
|
||||
if name == "archipelago" || name == "relay" {
|
||||
return true;
|
||||
}
|
||||
let candidates = service_alias_candidates(name);
|
||||
if !candidates.iter().any(|c| self.known.contains(*c)) {
|
||||
return true; // not an app — custom hidden service
|
||||
}
|
||||
candidates.iter().any(|c| self.installed.contains(*c))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn list_services(
|
||||
config_dir: &std::path::Path,
|
||||
apps: Option<&AppInstallState>,
|
||||
) -> Result<Vec<TorService>> {
|
||||
let base = detect_hidden_service_base();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
|
||||
|
||||
for entry in &config.services {
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
seen.insert(entry.name.clone());
|
||||
if !visible(&entry.name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
@@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(service_name.clone());
|
||||
if !visible(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name).await;
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
let is_proto = is_protocol_service(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
|
||||
@@ -437,6 +437,23 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "added": true, "npub": npub }))
|
||||
}
|
||||
|
||||
/// The host address a WireGuard peer should dial — prefer the configured
|
||||
/// host IP, then public-IP lookup, then first local address.
|
||||
async fn current_wg_endpoint_host(&self) -> String {
|
||||
if self.config.host_ip != "127.0.0.1" {
|
||||
return self.config.host_ip.clone();
|
||||
}
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
}
|
||||
|
||||
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||
pub(super) async fn handle_vpn_create_peer(
|
||||
&self,
|
||||
@@ -501,22 +518,7 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
|
||||
};
|
||||
|
||||
// Detect host IP — prefer config, then nvpn, then system detection
|
||||
let host_ip = if self.config.host_ip != "127.0.0.1" {
|
||||
self.config.host_ip.clone()
|
||||
} else {
|
||||
// Fallback: get public IP via external service
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
};
|
||||
let endpoint = format!("{}:51820", host_ip);
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
|
||||
// Allocate a peer IP (simple: hash the peer name)
|
||||
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
|
||||
@@ -667,15 +669,41 @@ impl RpcHandler {
|
||||
let content = tokio::fs::read_to_string(&peer_file)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
|
||||
let peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No config stored for peer '{}' — recreate the device to get a new QR code",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
// The stored Endpoint is the node's address at creation time; after
|
||||
// the node moves networks it points at a dead IP and the QR produces
|
||||
// a tunnel that can never connect. Refresh it to the current address.
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
let config: String = stored
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim_start().starts_with("Endpoint") {
|
||||
format!("Endpoint = {}", endpoint)
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if config != stored {
|
||||
if let Some(obj) = peer.as_object_mut() {
|
||||
obj.insert("config".to_string(), config.clone().into());
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(&peer) {
|
||||
if tokio::fs::write(&peer_file, json).await.is_ok() {
|
||||
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let qr = qrcode::QrCode::new(config.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr
|
||||
|
||||
@@ -360,7 +360,7 @@ fn validate_password_strength(password: &str) -> Result<()> {
|
||||
/// Change the archipelago user's SSH/login password.
|
||||
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
||||
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
||||
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
pub(crate) async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
let ssh_user =
|
||||
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
|
||||
|
||||
@@ -763,7 +763,9 @@ mod tests {
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "s3cret");
|
||||
@@ -784,7 +786,9 @@ mod tests {
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
|
||||
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "keep-me");
|
||||
|
||||
@@ -137,6 +137,13 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_polkit_networkmanager_repair().await {
|
||||
Ok(true) => info!(
|
||||
"Installed NetworkManager polkit rule for the archipelago user — Wi-Fi setup enabled"
|
||||
),
|
||||
Ok(false) => debug!("NetworkManager polkit rule already present"),
|
||||
Err(e) => warn!("polkit NetworkManager repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_journald_dropin().await {
|
||||
Ok(true) => info!("Installed journald log-volume policy drop-in"),
|
||||
Ok(false) => debug!("journald log-volume policy already in place"),
|
||||
@@ -442,6 +449,68 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-heal Wi-Fi setup on nodes that predate the polkit fix (issue #99).
|
||||
///
|
||||
/// Archipelago drives NetworkManager from a system-level systemd service
|
||||
/// (`User=archipelago`, no logind seat), so the stock NM polkit rule — which
|
||||
/// only authorizes `subject.local && subject.active` sessions — denies it, and
|
||||
/// "connect to Wi-Fi" fails with "Insufficient privileges". Fresh ISO installs
|
||||
/// since 2026-05 ship the rule below, but nodes that reached this build over
|
||||
/// OTA never got it (OTA replaces the binary + web UI, not host system config).
|
||||
///
|
||||
/// Install the scoped rule if it is missing, and best-effort ensure `polkitd`
|
||||
/// itself is present (without the daemon the rule is inert). Both are wrapped
|
||||
/// so an offline/locked apt or a missing package can never fail startup — the
|
||||
/// rule is still written so it takes effect once polkitd arrives (e.g. after an
|
||||
/// ISO reflash). Idempotent: keyed on the rule's unique `subject.user` marker.
|
||||
async fn run_polkit_networkmanager_repair() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -u
|
||||
RULE=/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
MARKER='subject.user == "archipelago"'
|
||||
# Rule already installed — nothing to do.
|
||||
if grep -qF "$MARKER" "$RULE" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
# The rule is inert without the polkit daemon. Older nodes (the ones that hit
|
||||
# issue #99) shipped without it. Try to install it, but never let apt failure
|
||||
# (offline node, locked dpkg, package unavailable) abort the heal — the rule is
|
||||
# written regardless so it activates whenever polkitd lands.
|
||||
if [ ! -d /usr/share/polkit-1 ] && ! command -v pkaction >/dev/null 2>&1; then
|
||||
timeout 240 apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1 \
|
||||
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1' \
|
||||
|| true
|
||||
fi
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > "$RULE" <<'RULEEOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
RULEEOF
|
||||
chmod 644 "$RULE"
|
||||
# Pick up the new rule. polkitd re-reads rules.d on reload; restart as a
|
||||
# fallback. Non-fatal if the unit name differs or the daemon is absent.
|
||||
systemctl reload polkit 2>/dev/null \
|
||||
|| systemctl restart polkit 2>/dev/null \
|
||||
|| systemctl restart polkit.service 2>/dev/null \
|
||||
|| true
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("install NetworkManager polkit rule")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("polkit NetworkManager repair helper exited with {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
|
||||
@@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
|
||||
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
|
||||
/// write-probes are `podman exec`s into EVERY running container; running them
|
||||
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
|
||||
/// tick forever — a permanent conmon "Failed to create container" storm on
|
||||
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
|
||||
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
|
||||
/// steady-state one: sweep each container on the first pass after it appears,
|
||||
/// then at most once per hour.
|
||||
fn ownership_sweep_due(name: &str) -> bool {
|
||||
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
static LAST: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
let Ok(mut map) = map.lock() else {
|
||||
return true;
|
||||
};
|
||||
let now = std::time::Instant::now();
|
||||
match map.get(name) {
|
||||
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
|
||||
_ => {
|
||||
map.insert(name.to_string(), now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
|
||||
/// container.
|
||||
///
|
||||
@@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator {
|
||||
if crate::app_ops::lifecycle_op_in_flight(&c.name) {
|
||||
continue;
|
||||
}
|
||||
// Throttled: first pass after the container appears, then
|
||||
// hourly — not on every 30s tick (see ownership_sweep_due).
|
||||
if !ownership_sweep_due(&c.name) {
|
||||
continue;
|
||||
}
|
||||
if ensure_running_container_ownership(&c.name).await {
|
||||
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
|
||||
@@ -372,6 +372,28 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
||||
/// Recover containers that were running before a crash.
|
||||
/// Attempts to start each container, logging success/failure.
|
||||
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
|
||||
// Snapshot entries can outlive their containers (removed while we were
|
||||
// down, or podman storage partially reset by an unclean poweroff).
|
||||
// `podman start` on those fails permanently, and recovery runs BEFORE the
|
||||
// server binds its port and notifies systemd ready — burning retries on
|
||||
// them pushed recovery past TimeoutStartSec and brick-looped the node
|
||||
// (killed mid-recovery → next boot sees a crash again, forever).
|
||||
let containers: Vec<&RunningContainerRecord> = match existing_container_names().await {
|
||||
Some(existing) => {
|
||||
let (present, missing): (Vec<_>, Vec<_>) =
|
||||
containers.iter().partition(|r| existing.contains(&r.name));
|
||||
if !missing.is_empty() {
|
||||
warn!(
|
||||
"Skipping {} snapshot container(s) that no longer exist: {:?}",
|
||||
missing.len(),
|
||||
missing.iter().map(|r| r.name.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
present
|
||||
}
|
||||
None => containers.iter().collect(),
|
||||
};
|
||||
|
||||
let mut report = RecoveryReport {
|
||||
total: containers.len(),
|
||||
recovered: 0,
|
||||
@@ -386,6 +408,15 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
record.name, record.image
|
||||
);
|
||||
|
||||
// Recovery counts against systemd's start timeout; a heavy node
|
||||
// legitimately needs several minutes for dozens of containers. Push
|
||||
// the deadline out ahead of each container so systemd only kills us
|
||||
// if we stop making progress (360s covers one full attempt chain).
|
||||
let _ = sd_notify::notify(
|
||||
false,
|
||||
&[sd_notify::NotifyState::ExtendTimeoutUsec(360_000_000)],
|
||||
);
|
||||
|
||||
// Rate-limit container starts to avoid overwhelming podman on low-resource systems
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
@@ -427,6 +458,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
attempt + 1,
|
||||
stderr.trim()
|
||||
);
|
||||
// The container is gone (raced past the pre-filter, or the
|
||||
// filter query failed) — retrying can never succeed.
|
||||
if stderr.contains("no such container") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -448,6 +484,26 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
report
|
||||
}
|
||||
|
||||
/// All container names podman knows about (running or not). `None` if the
|
||||
/// query fails — callers fail open and attempt every snapshot entry.
|
||||
async fn existing_container_names() -> Option<std::collections::HashSet<String>> {
|
||||
let output = podman_output(
|
||||
&["ps", "-a", "--format", "{{.Names}}"],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RecoveryReport {
|
||||
pub total: usize,
|
||||
|
||||
@@ -49,9 +49,7 @@ pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
|
||||
/// implicitly by `load()` on nodes that have never edited their anchor
|
||||
/// list, so every node dials it without operator action.
|
||||
/// The upstream public anchor as a ready-to-apply `SeedAnchor`.
|
||||
pub fn default_public_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
|
||||
@@ -61,6 +59,38 @@ pub fn default_public_anchor() -> SeedAnchor {
|
||||
}
|
||||
}
|
||||
|
||||
// Archipelago-operated anchor on vps2 (the OTA/registry host, 146.59.87.168).
|
||||
// Every node already reaches this host for updates, so it is reachable from
|
||||
// networks that the upstream anchor is not — which is most of them (the
|
||||
// upstream anchor answers on one IPv4 that many home/office networks can't
|
||||
// reach, and its DNS resolves IPv6-first while the daemon is IPv4-only).
|
||||
// TCP because that traverses NAT/firewalls best; 8444 because 8443 on that
|
||||
// host is already taken by a container.
|
||||
pub const ARCHY_ANCHOR_NPUB: &str =
|
||||
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak";
|
||||
pub const ARCHY_ANCHOR_ADDR: &str = "146.59.87.168:8444";
|
||||
pub const ARCHY_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The Archipelago-operated anchor as a ready-to-apply `SeedAnchor`.
|
||||
pub fn archy_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: ARCHY_ANCHOR_NPUB.to_string(),
|
||||
address: ARCHY_ANCHOR_ADDR.to_string(),
|
||||
transport: ARCHY_ANCHOR_TRANSPORT.to_string(),
|
||||
label: "Archipelago anchor (vps2)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The default anchor set carried implicitly by `load()` on nodes that have
|
||||
/// never edited their anchor list, so every node dials them without operator
|
||||
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
|
||||
/// node: `fipsctl connect` is attempted for each, and whichever the node's
|
||||
/// network can reach wins. The Archipelago-operated anchor is listed first
|
||||
/// because it is reachable from the widest set of networks.
|
||||
pub fn default_public_anchors() -> Vec<SeedAnchor> {
|
||||
vec![archy_anchor(), default_public_anchor()]
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
|
||||
@@ -94,7 +124,7 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(vec![default_public_anchor()]);
|
||||
return Ok(default_public_anchors());
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
@@ -268,28 +298,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_seeds_default_public_anchor() {
|
||||
// A node that has never edited its anchor list should still get
|
||||
// the public anchor so it can bootstrap the mesh out of the box.
|
||||
async fn load_missing_seeds_default_public_anchors() {
|
||||
// A node that has never edited its anchor list should still get the
|
||||
// full default anchor set so it can bootstrap the mesh out of the box.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![default_public_anchor()]);
|
||||
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
|
||||
assert_eq!(got[0].transport, "tcp");
|
||||
assert!(got[0].address.ends_with(":8443"));
|
||||
assert_eq!(got, default_public_anchors());
|
||||
// The Archipelago-operated anchor must come first (widest reachability)
|
||||
// and the upstream anchor must remain present as a fallback.
|
||||
assert_eq!(got[0], archy_anchor());
|
||||
assert!(got.contains(&default_public_anchor()));
|
||||
// Every default must be a TCP form (traverses NAT/firewalls), never the
|
||||
// dead udp:8668 the upstream anchor never answers on.
|
||||
assert!(got.iter().all(|a| a.transport == "tcp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_default_persists_as_empty() {
|
||||
// Once the operator removes the default, a file exists and is
|
||||
// authoritative — we must not silently re-seed it on next load.
|
||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||
// Editing the anchor list (here removing one default) makes the file
|
||||
// authoritative: the removed anchor must not be silently re-seeded on
|
||||
// next load, and the remaining default must stay.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
|
||||
assert!(!list.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
|
||||
assert!(list.contains(&default_public_anchor()));
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, list, "edited list is authoritative; no re-seed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_all_defaults_persists_as_empty() {
|
||||
// Removing every default leaves an empty authoritative list that must
|
||||
// not be re-seeded on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
assert!(got.is_empty(), "defaults must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! whitelists `install` into `/etc/fips/`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -17,47 +18,145 @@ use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
};
|
||||
|
||||
/// Write the FIPS daemon config based on the local npub and default
|
||||
/// transports. Overwrites any existing file — callers are expected to
|
||||
/// Header prepended to the generated YAML. serde doesn't emit comments, so
|
||||
/// this is concatenated onto the serialised body.
|
||||
const CONFIG_HEADER: &str = "# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n";
|
||||
|
||||
/// Typed mirror of the subset of upstream `fips.yaml` that archipelago owns.
|
||||
///
|
||||
/// This was previously built by `format!`-ing a string literal. Upstream's
|
||||
/// config structs are `#[serde(deny_unknown_fields)]`, so a key we get wrong
|
||||
/// doesn't degrade gracefully — the daemon refuses to start and the node drops
|
||||
/// off the mesh. Serialising from typed structs lets the compiler and the
|
||||
/// tests below catch drift, instead of a node discovering it at boot after an
|
||||
/// upgrade.
|
||||
///
|
||||
/// Schema verified field-by-field against jmcorgan/fips **v0.4.1** (2026-07-20).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct FipsConfig {
|
||||
pub node: NodeSection,
|
||||
pub tun: TunSection,
|
||||
pub dns: DnsSection,
|
||||
pub transports: TransportsSection,
|
||||
/// Static peers. Always empty: archipelago feeds peers dynamically via the
|
||||
/// seed-anchors apply loop and federation-invite hooks.
|
||||
pub peers: Vec<PeerEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct NodeSection {
|
||||
pub identity: IdentitySection,
|
||||
pub discovery: DiscoverySection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct IdentitySection {
|
||||
/// With `persistent: true` the daemon reuses the key file at
|
||||
/// config-dir/fips.key (= `DAEMON_KEY_PATH`) instead of generating an
|
||||
/// ephemeral identity on every start.
|
||||
pub persistent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DiscoverySection {
|
||||
pub lan: LanDiscoverySection,
|
||||
}
|
||||
|
||||
/// mDNS / DNS-SD discovery on the local link (`node.discovery.lan.*`), added
|
||||
/// upstream in v0.4.0 and opt-in there (upstream default is `false`).
|
||||
///
|
||||
/// We enable it so co-located nodes peer directly instead of depending on the
|
||||
/// public anchor being reachable — an anchor blackhole on one network segment
|
||||
/// otherwise islands a node completely.
|
||||
///
|
||||
/// Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
|
||||
/// has no `lan` field *and* no `deny_unknown_fields`, so a v0.3.0 daemon ignores
|
||||
/// this key harmlessly (verified against the v0.3.0 source). It therefore starts
|
||||
/// working on its own when a node upgrades, with no second config migration.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct LanDiscoverySection {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TunSection {
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
pub mtu: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DnsSection {
|
||||
pub enabled: bool,
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
/// Both UDP and TCP are enabled: the public anchor answers on TCP/8443 only,
|
||||
/// and networks that block outbound UDP can still bootstrap over TCP.
|
||||
/// Upstream dropped the `tor:` transport variant — archipelago's own Tor
|
||||
/// fallback handles that layer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TransportsSection {
|
||||
pub udp: TransportBind,
|
||||
pub tcp: TransportBind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TransportBind {
|
||||
/// Upstream takes `bind_addr` ("host:port"), not `enabled` + `port`.
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
/// A static peer entry. Never constructed today (see `FipsConfig::peers`), but
|
||||
/// typed so the shape is checked if static peering is ever needed.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct PeerEntry {
|
||||
pub npub: String,
|
||||
pub address: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
impl Default for FipsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
node: NodeSection {
|
||||
identity: IdentitySection { persistent: true },
|
||||
discovery: DiscoverySection {
|
||||
lan: LanDiscoverySection { enabled: true },
|
||||
},
|
||||
},
|
||||
tun: TunSection {
|
||||
enabled: true,
|
||||
name: "fips0".to_string(),
|
||||
mtu: 1280,
|
||||
},
|
||||
dns: DnsSection {
|
||||
enabled: true,
|
||||
bind_addr: "127.0.0.1".to_string(),
|
||||
},
|
||||
transports: TransportsSection {
|
||||
udp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
|
||||
},
|
||||
tcp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
|
||||
},
|
||||
},
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the FIPS daemon config. Overwrites any existing file — callers
|
||||
/// re-run this whenever the key or daemon version changes.
|
||||
///
|
||||
/// Schema is intentionally minimal: node identity comes from the key
|
||||
/// file on disk (the daemon handles it), transports enable UDP + TCP
|
||||
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
|
||||
/// Static peer list is empty — archipelago feeds peers dynamically via
|
||||
/// the seed-anchors apply loop and federation-invite hooks.
|
||||
/// Node identity comes from the key file on disk; the static peer list stays
|
||||
/// empty because peers are fed dynamically at runtime.
|
||||
pub fn render_config_yaml() -> String {
|
||||
// Schema matches upstream jmcorgan/fips as of 2026-04. With
|
||||
// `node.identity.persistent: true` the daemon reuses the key file at
|
||||
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
|
||||
// rather than `enabled: true / port: N`. Both UDP and TCP are
|
||||
// enabled by default because the public anchor (fips.v0l.io)
|
||||
// currently answers on TCP/8443 only, and networks that block UDP
|
||||
// outbound can still bootstrap via TCP. Upstream fips no longer
|
||||
// has a `tor:` transport variant — archipelago's own Tor fallback
|
||||
// handles that layer.
|
||||
format!(
|
||||
"# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n\
|
||||
node:\n \
|
||||
identity:\n \
|
||||
persistent: true\n\
|
||||
tun:\n \
|
||||
enabled: true\n \
|
||||
name: fips0\n \
|
||||
mtu: 1280\n\
|
||||
dns:\n \
|
||||
enabled: true\n \
|
||||
bind_addr: \"127.0.0.1\"\n\
|
||||
transports:\n \
|
||||
udp:\n \
|
||||
bind_addr: \"0.0.0.0:{udp}\"\n \
|
||||
tcp:\n \
|
||||
bind_addr: \"0.0.0.0:{tcp}\"\n\
|
||||
peers: []\n",
|
||||
udp = DEFAULT_UDP_PORT,
|
||||
tcp = DEFAULT_TCP_PORT,
|
||||
)
|
||||
let body = serde_yaml::to_string(&FipsConfig::default())
|
||||
.expect("FipsConfig is a plain struct tree and cannot fail to serialise");
|
||||
format!("{CONFIG_HEADER}{body}")
|
||||
}
|
||||
|
||||
/// Install the local FIPS key + rendered config into `/etc/fips/`.
|
||||
@@ -205,6 +304,60 @@ mod tests {
|
||||
assert!(!yaml.contains("tor:"));
|
||||
}
|
||||
|
||||
/// Exact-output snapshot. Upstream's config structs are
|
||||
/// `deny_unknown_fields`, so an accidental key rename/addition means the
|
||||
/// daemon won't start. Pinning the full rendering makes any such change
|
||||
/// fail here — where it's cheap — instead of on a node after an upgrade.
|
||||
/// If this fails, re-verify against the upstream schema before updating it.
|
||||
#[test]
|
||||
fn test_rendered_yaml_exact_snapshot() {
|
||||
let expected = "\
|
||||
# Generated by archipelago — do not edit by hand.
|
||||
# Regenerated on every key change and daemon upgrade.
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
discovery:
|
||||
lan:
|
||||
enabled: true
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: 127.0.0.1
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: 0.0.0.0:8668
|
||||
tcp:
|
||||
bind_addr: 0.0.0.0:8443
|
||||
peers: []
|
||||
";
|
||||
assert_eq!(render_config_yaml(), expected);
|
||||
}
|
||||
|
||||
/// The rendered config must parse as YAML and carry the mDNS opt-in at the
|
||||
/// exact path upstream reads (`node.discovery.lan.enabled`) — a typo there
|
||||
/// would silently leave LAN discovery off rather than erroring.
|
||||
#[test]
|
||||
fn test_lan_discovery_enabled_at_upstream_path() {
|
||||
let yaml = render_config_yaml();
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("renders valid YAML");
|
||||
assert_eq!(
|
||||
parsed["node"]["discovery"]["lan"]["enabled"],
|
||||
serde_yaml::Value::Bool(true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rendering is deterministic: the startup drift check in server.rs compares
|
||||
/// the freshly rendered config against what's on disk, so any instability
|
||||
/// here would cause an endless reinstall+restart loop of the daemon.
|
||||
#[test]
|
||||
fn test_render_is_deterministic() {
|
||||
assert_eq!(render_config_yaml(), render_config_yaml());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_refuses_when_key_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -98,6 +98,40 @@ async fn main() -> Result<()> {
|
||||
return ceremony::run();
|
||||
}
|
||||
|
||||
// Plain CLI flags must never boot the daemon (a stray `--version` used to
|
||||
// start a second instance next to the systemd one). Handled before any
|
||||
// tracing/state init so stdout stays clean.
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
Some("--version") | Some("-V") => {
|
||||
println!(
|
||||
"archipelago {}-{}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
option_env!("GIT_HASH").unwrap_or("dev")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Some("--help") | Some("-h") => {
|
||||
println!("Archipelago Bitcoin Node OS");
|
||||
println!();
|
||||
println!("Usage: archipelago [COMMAND]");
|
||||
println!();
|
||||
println!("Running with no arguments starts the node daemon.");
|
||||
println!();
|
||||
println!("Commands:");
|
||||
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
|
||||
println!();
|
||||
println!("Options:");
|
||||
println!(" -V, --version Print version and exit");
|
||||
println!(" -h, --help Print this help and exit");
|
||||
return Ok(());
|
||||
}
|
||||
Some(other) if other.starts_with('-') => {
|
||||
eprintln!("archipelago: unknown option '{other}' (see --help)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let startup_start = std::time::Instant::now();
|
||||
crash_recovery::init_start_time();
|
||||
|
||||
|
||||
@@ -688,12 +688,27 @@ impl Server {
|
||||
let fips_peer_registry = fips_peer_registry.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
// Steady cadence, but retry fast right after a daemon restart:
|
||||
// regenerating fips.yaml (this build does, once, on first boot
|
||||
// after the OTA) restarts the fips daemon, and for a few seconds
|
||||
// `/run/fips/control.sock` is gone so every `fipsctl connect`
|
||||
// fails and the node islands until the next tick. Detect that
|
||||
// exact failure and retry in 15s instead of 5 min — bounded, so a
|
||||
// node with no fips daemon falls back to the steady cadence
|
||||
// rather than busy-looping.
|
||||
const STEADY: Duration = Duration::from_secs(300);
|
||||
const FAST: Duration = Duration::from_secs(15);
|
||||
const MAX_FAST_RETRIES: u32 = 8; // ≤2 min of fast retries/episode
|
||||
let mut fast_retries: u32 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut daemon_restarting = false;
|
||||
match crate::fips::anchors::load(&data_dir).await {
|
||||
Ok(list) if !list.is_empty() => {
|
||||
let _ = crate::fips::anchors::apply(&list).await;
|
||||
let results = crate::fips::anchors::apply(&list).await;
|
||||
daemon_restarting = !results.is_empty()
|
||||
&& results
|
||||
.iter()
|
||||
.all(|r| !r.ok && r.message.contains("control.sock"));
|
||||
}
|
||||
Ok(_) => { /* no seed anchors configured yet */ }
|
||||
Err(e) => {
|
||||
@@ -717,6 +732,15 @@ impl Server {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
}
|
||||
}
|
||||
|
||||
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
|
||||
fast_retries += 1;
|
||||
FAST
|
||||
} else {
|
||||
fast_retries = 0;
|
||||
STEADY
|
||||
};
|
||||
tokio::time::sleep(next).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+226
-38
@@ -24,6 +24,16 @@ pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
/// confidence than "looks stuck at 0%".
|
||||
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Serializes the mutating update operations (download, apply, and the
|
||||
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
|
||||
/// exactly this race: two concurrent `update.download` RPCs shared one
|
||||
/// staging file, a cancel wiped staging mid-flight, a third download began
|
||||
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
|
||||
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
|
||||
/// via `try_lock` so a concurrent caller gets an explicit "already running"
|
||||
/// error instead of silently interleaving.
|
||||
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
@@ -976,6 +986,9 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// verified over the complete file at the end of each component, so a
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
state = check_for_updates(data_dir).await?;
|
||||
@@ -1133,7 +1146,6 @@ async fn download_component_resumable(
|
||||
dest: &Path,
|
||||
prior_total: u64,
|
||||
) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
const MAX_ATTEMPTS: u32 = 6;
|
||||
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
|
||||
@@ -1145,8 +1157,19 @@ async fn download_component_resumable(
|
||||
Err(_) => 0,
|
||||
};
|
||||
if existing_len >= component.size_bytes {
|
||||
// File is already complete — break out and go verify.
|
||||
break;
|
||||
// File is already complete (a resumed run finished it, or a
|
||||
// leftover from an earlier attempt) — verify it instead of
|
||||
// trusting it. The old code `break`d here, which skipped
|
||||
// verification entirely AND landed on the error return below
|
||||
// ("download failed without a captured error").
|
||||
match verify_component_on_disk(component, dest).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if attempt > 1 {
|
||||
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
|
||||
@@ -1294,44 +1317,86 @@ async fn download_component_resumable(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Full file — verify hash.
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash == component.sha256 {
|
||||
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
|
||||
// match too. SHA-256 stays the mandatory gate during migration;
|
||||
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
|
||||
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
|
||||
// — treat it like a SHA mismatch and re-download.
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// Full file — verify hashes. On mismatch the file on disk is
|
||||
// garbage: nuke it and start over from scratch on the next attempt.
|
||||
match verify_component_on_disk(component, dest).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// SHA mismatch — the file on disk is garbage. Nuke it and
|
||||
// start over from scratch on the next attempt.
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
}
|
||||
|
||||
/// Verify a fully-downloaded component file on disk: SHA-256 is the
|
||||
/// mandatory gate; when the manifest also pins a BLAKE3 digest it must
|
||||
/// match too (BLAKE3 is the hash the iroh swarm fetches/verifies by, so
|
||||
/// a present-but-wrong BLAKE3 means the bytes aren't swarm-consistent —
|
||||
/// treated exactly like a SHA mismatch). Err = mismatch; the caller
|
||||
/// decides whether to remove the file and retry.
|
||||
async fn verify_component_on_disk(component: &ComponentUpdate, dest: &Path) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash != component.sha256 {
|
||||
anyhow::bail!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
component.sha256,
|
||||
hash
|
||||
));
|
||||
);
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
anyhow::bail!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-verify every manifest component against the bytes actually sitting
|
||||
/// in staging, immediately before install. The download path verifies as
|
||||
/// it goes, but staging can change between download and apply — on .198
|
||||
/// (v1.7.103, 2026-07-18) a concurrent download was re-filling a wiped
|
||||
/// staging dir when apply ran, and a 17MB partial of the 49MB binary got
|
||||
/// installed. This apply-time gate is the one that must never be skipped.
|
||||
async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest) -> Result<()> {
|
||||
for component in &manifest.components {
|
||||
let dest = staging_dir.join(&component.name);
|
||||
let len = tokio::fs::metadata(&dest)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
if len != component.size_bytes {
|
||||
anyhow::bail!(
|
||||
"staged component {} is {} bytes but the manifest says {} — \
|
||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
||||
component.name,
|
||||
len,
|
||||
component.size_bytes
|
||||
);
|
||||
}
|
||||
verify_component_on_disk(component, &dest)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"staged component {} failed verification — refusing to apply",
|
||||
component.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel an in-flight download. Sets the cancellation flag so the
|
||||
@@ -1343,11 +1408,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
// Only wipe staging when no download/apply holds the op lock. Wiping
|
||||
// under a live operation is how .198 ended up applying a re-filling
|
||||
// staging dir; with the lock held elsewhere we just set the cancel
|
||||
// flag and let the in-flight loop bail at its next chunk boundary
|
||||
// (partials are size+hash revalidated on the next resume anyway).
|
||||
let staging = data_dir.join("update-staging");
|
||||
let wiped = if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
let wiped = match UPDATE_OP_LOCK.try_lock() {
|
||||
Ok(_op) => {
|
||||
if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
// Clear the "downloaded, ready to apply" marker too — a canceled
|
||||
// download is not a staged update.
|
||||
@@ -1398,11 +1473,34 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
if !staging_dir.exists() {
|
||||
anyhow::bail!("No staged update found. Download first.");
|
||||
}
|
||||
|
||||
// Gate 1: the completion marker is written only after EVERY component
|
||||
// downloaded and hash-verified. A staging dir without it is a partial
|
||||
// or in-flight download — exactly what got installed on .198.
|
||||
if !has_staged_update(data_dir).await {
|
||||
anyhow::bail!(
|
||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
||||
);
|
||||
}
|
||||
|
||||
// Gate 2: re-verify the actual staged bytes against the manifest.
|
||||
let manifest = load_state(data_dir)
|
||||
.await?
|
||||
.available_update
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no update manifest in state to verify staged files against — re-download the update"
|
||||
)
|
||||
})?;
|
||||
verify_staged_components(&staging_dir, &manifest).await?;
|
||||
|
||||
let backup_dir = data_dir.join("update-backup");
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.await
|
||||
@@ -1690,6 +1788,30 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Install the OTA crash-loop guard as a drop-in on existing
|
||||
// nodes (fresh ISOs carry it in the unit file itself). The
|
||||
// guard restores the update-backup binary when a freshly
|
||||
// applied binary SEGVs before it can run its own post-OTA
|
||||
// verification — the .198 v1.7.103 truncated-binary loop.
|
||||
// Best-effort: `+-` in the drop-in means a missing script can
|
||||
// never block the service, and a failed install here must not
|
||||
// abort the apply.
|
||||
if Path::new("/opt/archipelago/scripts/ota-crash-guard.sh").exists() {
|
||||
let dropin_dir = "/etc/systemd/system/archipelago.service.d";
|
||||
let _ = host_sudo(&["mkdir", "-p", dropin_dir]).await;
|
||||
let _ = host_sudo(&[
|
||||
"bash",
|
||||
"-c",
|
||||
&format!(
|
||||
"printf '%s\\n' '[Service]' \
|
||||
'ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh' \
|
||||
> {}/ota-crash-guard.conf",
|
||||
dropin_dir
|
||||
),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
let _ =
|
||||
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
|
||||
@@ -2443,6 +2565,72 @@ mod tests {
|
||||
assert!(!persisted.update_in_progress);
|
||||
}
|
||||
|
||||
/// apply_update takes the global single-flight UPDATE_OP_LOCK, so tests
|
||||
/// that call it must not run concurrently — one would see the other's
|
||||
/// lock and fail with "another update operation is already running".
|
||||
static APPLY_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_refuses_unmarked_staging() {
|
||||
let _serial = APPLY_TEST_SERIAL.lock().await;
|
||||
// Regression: .198 v1.7.103 bricking — apply ran against a staging
|
||||
// dir that a concurrent download was still filling. Without the
|
||||
// .download-complete marker, apply must refuse before touching
|
||||
// anything.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"partial")
|
||||
.await
|
||||
.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("completion marker"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_refuses_staged_bytes_that_mismatch_manifest() {
|
||||
let _serial = APPLY_TEST_SERIAL.lock().await;
|
||||
// Marker present (a complete download once existed) but the staged
|
||||
// bytes no longer match the manifest — apply must re-verify and
|
||||
// refuse rather than install whatever is on disk.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"truncated-garbage")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = UpdateState {
|
||||
available_update: Some(UpdateManifest {
|
||||
version: "999.0.0".to_string(),
|
||||
release_date: "2026-07-18".to_string(),
|
||||
changelog: vec![],
|
||||
components: vec![ComponentUpdate {
|
||||
name: "archipelago".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "999.0.0".to_string(),
|
||||
download_url: "http://example.invalid/archipelago".to_string(),
|
||||
sha256: "0".repeat(64),
|
||||
size_bytes: 49_949_048,
|
||||
blake3: None,
|
||||
}],
|
||||
}),
|
||||
update_in_progress: true,
|
||||
..UpdateState::default()
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("refusing to apply"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dismiss_update_clears_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Handoff — 2026-07-20 — peer-files diagnosis, FIPS 0.4.1, mobile transport pill
|
||||
|
||||
Written for a fresh session that will **cut the OTA release and build the ISO**.
|
||||
Everything below is already committed and pushed to `gitea-ai/main`. Last release
|
||||
was `v1.7.105-alpha` (`e2f83c01`); the next one should be **`v1.7.106-alpha`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this release carries (3 commits on top of v1.7.105-alpha)
|
||||
|
||||
| Commit | What | User-visible? |
|
||||
|---|---|---|
|
||||
| `9e3ac9ba` | Show the FIPS/Tor transport pill on **mobile** peer files | Yes |
|
||||
| `3ab7fb52` | Log the full anyhow error chain on RPC failures | No (diagnostics) |
|
||||
| `5fd0d6c3` | Generate `fips.yaml` from typed structs + enable **mDNS LAN discovery** | Indirectly |
|
||||
|
||||
### `9e3ac9ba` — mobile transport pill
|
||||
`PeerFiles.vue:15` wraps the peer title in `hidden md:block` (the global header
|
||||
carries the name on mobile), and the transport pill was nested inside it — so it
|
||||
vanished below 768px. Added a separate `md:hidden` pill next to the peer icon.
|
||||
Frontend was rebuilt and the class verified present in the emitted bundle.
|
||||
|
||||
Caveats worth knowing (pre-existing, not introduced here):
|
||||
- On this code path the backend only ever emits `fips` or `tor`, so the `mesh`
|
||||
and `lan` branches in `transportPill` (`PeerFiles.vue:609-627`) are dead.
|
||||
- For **received** mesh messages, `mesh/mod.rs:1519-1533` falls back to a
|
||||
hardcoded `"tor"` when the transport is unknown — that pill can genuinely lie.
|
||||
The peer-files pill does not.
|
||||
|
||||
### `3ab7fb52` — full error chain in logs
|
||||
`api/rpc/mod.rs:441` logged only the outermost anyhow context, so every
|
||||
peer-files failure read exactly `RPC error on content.browse-peer: Failed to
|
||||
connect to peer` with the real cause discarded. Now `{:#}`. The client-facing
|
||||
message still goes through `sanitize_error_message(&e.to_string())` (`{}`), so
|
||||
no internal detail leaks. **This fix applies to every RPC method, not just
|
||||
browse-peer.**
|
||||
|
||||
### `5fd0d6c3` — typed FIPS config + mDNS
|
||||
`fips/config.rs` built `/etc/fips/fips.yaml` by `format!`-ing a string literal.
|
||||
Upstream's config structs are `#[serde(deny_unknown_fields)]`, so a wrong key
|
||||
does not degrade — **the daemon refuses to start and the node leaves the mesh**.
|
||||
Now a typed serde struct tree, verified field-by-field against jmcorgan/fips
|
||||
**v0.4.1**, with 4 tests: exact-output snapshot, determinism, mDNS key path, and
|
||||
the pre-existing schema test. All pass.
|
||||
|
||||
Also enables `node.discovery.lan.enabled` (mDNS/DNS-SD, new upstream in v0.4.0)
|
||||
so co-located nodes peer directly instead of depending on the public anchor.
|
||||
|
||||
> ⚠️ **Expected one-time behaviour on first boot after this lands:** the startup
|
||||
> drift check at `server.rs:864` compares the freshly rendered config against
|
||||
> what's on disk. The render differs now, so it reinstalls the config and
|
||||
> restarts the FIPS daemon **once**. This is the intended self-healing path and
|
||||
> settles immediately. Do not mistake it for a regression.
|
||||
|
||||
Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
|
||||
has no `lan` field **and** no `deny_unknown_fields`, so v0.3.0 daemons ignore it
|
||||
harmlessly (verified against the v0.3.0 source). It self-activates on upgrade.
|
||||
|
||||
---
|
||||
|
||||
## 2. FIPS 0.4.1 — validated, but the fleet is NOT rolled
|
||||
|
||||
Fleet was on FIPS **0.3.0 / 0.3.0-dev** (2026-05-11). Upstream is **v0.4.1**
|
||||
(2026-07-19). Verified before touching anything:
|
||||
|
||||
- **Wire-compatible** 0.3.0 → 0.4.0 → 0.4.1. Rolling upgrade, any order, no flag day.
|
||||
- **Config forward-compatible** — every key we emit exists in 0.4.1.
|
||||
- **Asset names match** what `fips/update.rs` expects (`fips_<ver>_<arch>.deb` +
|
||||
`checksums-linux.txt`), so the in-product updater should work.
|
||||
|
||||
### Upgraded so far (2 of N)
|
||||
| Node | Before | After | Result |
|
||||
|---|---|---|---|
|
||||
| OptiPlex `.198` / `100.114.134.21` | `0.3.0-dev-1` | **0.4.1** | ✅ anchor connected, `is_parent: true`, tree `depth: 4` |
|
||||
| thinkpad (this machine) | `0.3.0` | **0.4.1** | ✅ service active, but still islanded (see §4) |
|
||||
|
||||
The OptiPlex was still running the **old string-rendered config** and 0.4.1
|
||||
accepted it — empirical confirmation of the compat analysis, not just desk work.
|
||||
|
||||
### Upgrade recipe (nodes cannot reach GitHub — sideload)
|
||||
```bash
|
||||
# 1. On a host with GitHub access:
|
||||
curl -sL -o fips_0.4.1_amd64.deb \
|
||||
https://github.com/jmcorgan/fips/releases/download/v0.4.1/fips_0.4.1_amd64.deb
|
||||
curl -sL -o checksums-linux.txt \
|
||||
https://github.com/jmcorgan/fips/releases/download/v0.4.1/checksums-linux.txt
|
||||
sha256sum fips_0.4.1_amd64.deb # must match checksums-linux.txt
|
||||
# expected: 9befcc0990c7e08742b5a88f75d753a1088134b20525156688d559a317334ded
|
||||
|
||||
# 2. Sideload:
|
||||
scp fips_0.4.1_amd64.deb archipelago@<node>:/tmp/
|
||||
|
||||
# 3. On the node — the same command update.rs uses:
|
||||
sudo -n systemd-run --collect --wait --quiet --pipe -- \
|
||||
env DEBIAN_FRONTEND=noninteractive dpkg --force-confold --force-downgrade -i \
|
||||
/tmp/fips_0.4.1_amd64.deb
|
||||
|
||||
# 4. Restart the ACTIVE unit — it is archipelago-fips.service,
|
||||
# NOT fips.service (which is inactive on these nodes):
|
||||
sudo -n systemctl restart archipelago-fips.service
|
||||
|
||||
# 5. Verify:
|
||||
fipsctl --version
|
||||
sudo -n fipsctl show links # expect anchor 185.18.221.160:8443 connected
|
||||
sudo -n fipsctl show tree # expect is_root: false, depth > 0
|
||||
```
|
||||
|
||||
### ISO implication (important)
|
||||
`image-recipe/build/auto-installer/Dockerfile.rootfs:23` builds FIPS from
|
||||
**unpinned upstream main** (`git clone --depth 1`, no rev/tag/checksum, amd64
|
||||
only). So a freshly built ISO will pick up whatever main is that day — probably
|
||||
≥0.4.1, but it is not deterministic. Pinning is an open item in
|
||||
`docs/1.8.0-RELEASE-HARDENING-PLAN.md:319-322`. **Consider pinning to v0.4.1
|
||||
before building the release ISO** so the shipped version is knowable.
|
||||
|
||||
---
|
||||
|
||||
## 3. The original bug — peer cloud files not loading
|
||||
|
||||
**Status: root-caused for the thinkpad; NOT fully explained.** Being explicit
|
||||
because it would be easy to read this as closed.
|
||||
|
||||
What is established:
|
||||
- FIPS was fully down on the thinkpad: `fipsctl show peers` → `[]`, `show links`
|
||||
→ `[]`, `show tree` → `is_root: true, depth 0`. An island.
|
||||
- Cause is **network egress**, not FIPS config: the thinkpad cannot reach the
|
||||
public anchor `185.18.221.160` (`fips.v0l.io`) **at all** — 100% packet loss on
|
||||
ICMP, 443/8443/8668 all time out. `show transports` showed
|
||||
`packets_sent: 760, packets_recv: 0` on both UDP and TCP.
|
||||
- Local firewall is **not** the cause (nft/iptables policy `accept`; only stock
|
||||
Tailscale anti-spoof DROPs).
|
||||
- The OptiPlex, on the same `/24`, reaches the anchor fine → it's the thinkpad's
|
||||
WiFi segment (`wlp3s0`), which also blocks L2 to `.198` (`ip neigh` → `FAILED`).
|
||||
- With no FIPS tree, everything falls back to Tor. Every peer in
|
||||
`federation/nodes.json` reads `last_transport: "tor"`, never `"fips"`.
|
||||
- **Tor itself is healthy**: fetched the OptiPlex's `/content` over Tor 3×,
|
||||
HTTP 200 in 4.1–8.5s — well inside the 30s budget at `content.rs:349`.
|
||||
|
||||
What is **not** established: why three specific `content.browse-peer` calls
|
||||
failed today (05:25, 16:37, 16:43 UTC). Tor tested healthy and was never
|
||||
reproduced. Two hypotheses were tested and **disproved**: the Tor fallback logic
|
||||
is correct (FIPS-unreachable returns `None` and falls through in Auto mode), and
|
||||
the legs get independent timeouts (Tor gets a fresh 30s). Best remaining guess is
|
||||
cold-circuit timeouts on first fetch after idle — **a guess, not a finding.**
|
||||
`3ab7fb52` means the next occurrence will log the actual cause.
|
||||
|
||||
### Corrections to earlier claims in this session
|
||||
- "Point FIPS at the Tailscale IP" was **wrong**. FIPS routes by npub; the
|
||||
`ip:port` in `fipsctl connect` is only an underlay endpoint hint.
|
||||
- "The public anchor may be dead fleet-wide" was **wrong**. Its peer is healthy
|
||||
(`delivery_ratio` 1.0 both directions, bloom filter syncing). The
|
||||
`bytes_recv: 0` link counters are simply uninstrumented in 0.3.0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open items — decisions NOT taken
|
||||
|
||||
1. **Second FIPS anchor (user asked for this; not built).** Needs a host running
|
||||
FIPS that is reachable from the restricted WiFi. Candidate found: OVH
|
||||
**`146.59.87.168`** — pings fine from the thinkpad and general egress works
|
||||
(github 200), while the upstream anchor fails even ICMP there. But it does not
|
||||
run FIPS yet, so this means **installing FIPS on the box that hosts Gitea** —
|
||||
a production change, deliberately not made unprompted. Code side is easy after:
|
||||
`fips/anchors.rs:47-50` is a single hardcoded anchor that should become a list
|
||||
(`default_public_anchor()` → `default_public_anchors() -> Vec<SeedAnchor>`).
|
||||
2. **Fleet rollout of FIPS 0.4.1** — only 2 nodes done. `.228`
|
||||
(`100.64.204.114`) has been **offline ~20h** and could not be included.
|
||||
3. **Deploying the archipelago binary** carrying `5fd0d6c3` — no node has it yet,
|
||||
so mDNS is not actually live anywhere. That is what this OTA is for.
|
||||
4. **mDNS caveat:** on the thinkpad's WiFi, multicast may also be blocked, so
|
||||
mDNS may not rescue that particular node even after the OTA. It will help
|
||||
co-located nodes on sane networks.
|
||||
5. **Pin FIPS in the ISO build** (see §2) — recommended before the release ISO.
|
||||
|
||||
---
|
||||
|
||||
## 5. Release ritual (from prior sessions — follow exactly)
|
||||
|
||||
Working tree at handoff had pre-existing unrelated dirt: `core/Cargo.lock`,
|
||||
`release-manifest.json`, `releases/manifest.json` modified, and an untracked
|
||||
`neode-ui/vite.preview.config.mts`. **Stage explicitly by path** — another
|
||||
agent may share this tree; never `git add -A`.
|
||||
|
||||
```bash
|
||||
V=1.7.106-alpha
|
||||
|
||||
# Frontend build — MUST verify dist actually changed (build can silently no-op)
|
||||
cd neode-ui && npm run build # → web/dist/neode-ui/
|
||||
grep -r "md:hidden" ../web/dist/neode-ui/assets/PeerFiles-*.js # sanity
|
||||
|
||||
# Backend
|
||||
cd core && cargo build --release -p archipelago
|
||||
# If you hit `rust-lld: undefined hidden symbol`, it's incremental-cache
|
||||
# corruption — rebuild with CARGO_INCREMENTAL=0
|
||||
|
||||
# Tarball MUST be flat (files at root, no neode-ui/ wrapper) or every fleet UI 403s
|
||||
tar -czf releases/v$V/archipelago-frontend-$V.tar.gz -C web/dist/neode-ui .
|
||||
tar -tzf releases/v$V/archipelago-frontend-$V.tar.gz | head -3 # ./ then ./index.html
|
||||
# Exclude the ~17MB companion APK from tarballs.
|
||||
|
||||
# Ship
|
||||
scripts/create-release.sh $V
|
||||
scripts/publish-release-assets.sh $V gitea-vps2
|
||||
git push origin main && git push origin --tags # tag or the Releases page stays empty
|
||||
git push gitea-ai main # main is protected; use the `ai` account
|
||||
|
||||
# Verify the live manifest
|
||||
curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
|
||||
```
|
||||
|
||||
Notes: vps2 (`146.59.87.168`) is the **primary** OTA manifest host. Signing is
|
||||
done at the **user's TTY** — do not attempt it unattended. Clean `/tmp` first
|
||||
(past releases hit ENOSPC). Changelogs must be **layman-readable**, leading with
|
||||
user benefit.
|
||||
|
||||
### ISO
|
||||
```bash
|
||||
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh
|
||||
```
|
||||
ISO builds are **always unbundled** — the default env silently builds the wrong
|
||||
full-bundle variant. Only filebrowser + fmcd are baked in. Verify the output
|
||||
filename contains `unbundled` and is ≈2.4G. The ISO's frontend source is
|
||||
`/opt/archipelago/web-ui` — rsync dist there first and verify **inside** the ISO.
|
||||
|
||||
---
|
||||
|
||||
## 6. Node access quick reference
|
||||
|
||||
- **thinkpad (`.116`) is the local machine** — do not SSH to it; read
|
||||
`journalctl -u archipelago` and `/var/lib/archipelago/**` directly.
|
||||
- **OptiPlex `.198`** = Tailscale `archipelago-5` / `100.114.134.21`, user
|
||||
`archipelago`. Its LAN IP is unreachable from the thinkpad — use Tailscale.
|
||||
- `.228` = `archipelago-2` / `100.64.204.114` — **offline as of 2026-07-20**, and
|
||||
it is in real use; don't touch uninvited.
|
||||
- `archipelago-1` (`100.82.34.38`) is a Ryzen AI Max desktop, **not** the OptiPlex.
|
||||
- Nodes have no `sqlite3` — use `sudo -n python3` to read the JSON stores.
|
||||
- `fipsctl` needs `sudo -n` (socket is `root:fips` 0660).
|
||||
- **Never run `archipelago --version` on fleet nodes** (deployed binaries predate #74).
|
||||
@@ -0,0 +1,82 @@
|
||||
# Add an existing Nostr identity to the node — UX & implementation plan
|
||||
|
||||
**Status:** plan only (2026-07-16), no code. Companion research: `docs/nostr-signer-login-research.md`.
|
||||
|
||||
## Where it lives
|
||||
|
||||
The **Nostr Identities** screen (`Web5Identities.vue`, backed by `identity.list` /
|
||||
`identity.create`). Today every identity is **seed-derived** (`identity_manager.rs`
|
||||
derives ed25519 + nostr keys from the BIP-39 master seed at an index). "Add existing"
|
||||
introduces a second class of identity: one whose key material comes from *outside* the
|
||||
seed.
|
||||
|
||||
## Two import kinds (both needed, different guarantees)
|
||||
|
||||
1. **Full import (nsec)** — the node holds the secret key. The identity behaves exactly
|
||||
like a seed-derived one (can sign in embedded apps, publish, encrypt). NOT covered by
|
||||
seed backup — flag it visibly and include it in the encrypted node backup.
|
||||
2. **Linked signer (npub only)** — the node stores just the public key; signing is
|
||||
delegated to the user's own signer (browser extension NIP-07, or a NIP-46 remote
|
||||
signer later). Zero key custody; some features (background publishing) unavailable —
|
||||
the UI should badge what works.
|
||||
|
||||
## The UX (matching the house style)
|
||||
|
||||
**Entry point:** next to "Create identity" on Nostr Identities, an **"Add existing"**
|
||||
glass-button. Opens a modal with three tabs (same tab pattern as the send/receive
|
||||
modals):
|
||||
|
||||
1. **Browser extension** (default when `window.nostr` exists)
|
||||
- One button: "Connect with extension". Flow: `getPublicKey()` → show the npub +
|
||||
resolved profile (kind-0 fetched via the node's relays: avatar, name — instant
|
||||
recognition) → "Add this identity".
|
||||
- Creates a **linked signer** identity. A challenge signature
|
||||
(`signEvent` on a throwaway event) proves key possession before adding — never add
|
||||
an unverified npub as "yours".
|
||||
2. **Secret key (nsec)**
|
||||
- Paste field (masked, `nsec1…` or hex), inline validation + derived npub preview
|
||||
with the same kind-0 profile card before confirming.
|
||||
- Scary-clear copy: "Your key will be stored on this node, encrypted at rest. It is
|
||||
NOT part of your seed backup — back it up separately." Confirm step requires the
|
||||
profile card to load or an explicit "add anyway".
|
||||
- Creates a **full** identity.
|
||||
3. **Public key (npub)** — watch-only
|
||||
- Paste an npub for a linked identity without any signer attached yet (useful to
|
||||
reserve the profile, upgrade to extension/NIP-46 signing later).
|
||||
|
||||
**After adding:** the identity appears in the same grid with a small origin badge —
|
||||
`seed` / `imported` / `linked` — and the imported profile picture/name pulled from
|
||||
relays. Everything else (picker in apps, rename, avatar) behaves uniformly.
|
||||
|
||||
**Removal:** existing delete flow; for `imported` identities the confirm dialog warns
|
||||
the key is destroyed unless exported first (offer "Export nsec" in the identity's detail
|
||||
sheet, gated behind password re-entry).
|
||||
|
||||
## Backend work
|
||||
|
||||
- `identity_manager.rs`: identity records gain `origin: Seed { index } | Imported |
|
||||
Linked`, optional `nostr_secret_hex` absent for Linked. Storage: reuse the existing
|
||||
encrypted identity file; imported secrets included in node backup.
|
||||
- New RPCs:
|
||||
- `identity.import-nostr` `{ nsec | npub, name?, verify_sig? }` → validates, derives
|
||||
npub, rejects duplicates (same pubkey as any existing identity), returns the new
|
||||
identity.
|
||||
- `identity.fetch-profile` `{ pubkey }` → kind-0 lookup via `nostr_relays.rs` for the
|
||||
preview card (frontend could also do this, but the node already has relay plumbing
|
||||
and avoids CORS).
|
||||
- `identity.nostr-sign` (used by the iframe NIP-07 bridge): for `Linked` identities
|
||||
return a typed error the bridge translates into "ask the user's extension instead" —
|
||||
phase 2; phase 1 simply hides linked identities from the in-app signer picker.
|
||||
|
||||
## Demo mode
|
||||
|
||||
Mock `identity.import-nostr` + `identity.fetch-profile` in mock-backend.js (canned
|
||||
profile: picture + name for any pasted npub) so the whole add-existing flow is
|
||||
demoable without real relays.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Phase 1 (small):** nsec + npub tabs, origin badges, backup inclusion, mock.
|
||||
2. **Phase 2:** extension tab with possession-proof + kind-0 preview cards everywhere.
|
||||
3. **Phase 3:** NIP-46 remote-signer identities + login integration (shares the QR
|
||||
plumbing from the signer-login work).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Sign in to the node with a Nostr signer — research & recommendation
|
||||
|
||||
**Status:** research only (2026-07-16), no code. Companion plan: `docs/nostr-identity-import-plan.md`.
|
||||
|
||||
## What's already in the tree (and what it isn't)
|
||||
|
||||
The IndeeHub "sign in with signer" work is the *inverse* of this feature: the node acts
|
||||
as a NIP-07 **provider** for embedded iframe apps, signing with node-held keys
|
||||
(`useNostrBridge.ts` postMessage bridge → `identity.nostr-sign` etc., picker UI in
|
||||
`NostrIdentityPicker.vue`). It never verifies an external signer — but the UI patterns
|
||||
(picker modal, QR rendering) and the backend crypto are reusable:
|
||||
|
||||
- **`nostr-sdk 0.44` is already a core dependency** (`nostr_handshake.rs` runs a real
|
||||
relay client) — schnorr event verification and NIP-46 client support are essentially
|
||||
free on the Rust side.
|
||||
- Auth today is single-password + optional TOTP, and TOTP already uses a **two-step
|
||||
login** (`auth.login` → `auth.login.totp`) — the exact slot where a parallel
|
||||
`auth.login.nostr.*` path fits.
|
||||
- The node can host its own relay (strfry app), and the frontend already bundles `qrcode`.
|
||||
|
||||
## Candidate flows, ranked by friction
|
||||
|
||||
### A. Browser extension (NIP-07) — lowest friction on desktop (2 clicks)
|
||||
Login page shows "Sign in with extension" when `window.nostr` exists. Server issues a
|
||||
random challenge → extension signs a **kind 22242** auth event carrying the challenge →
|
||||
server verifies signature + challenge + `created_at` freshness + that the pubkey is
|
||||
enrolled → normal session cookie. ~50 lines of frontend, ~80 lines of Rust. No relay
|
||||
involved at all.
|
||||
|
||||
### B. QR scan with a mobile signer (NIP-46 `nostrconnect://`) — the headline UX (scan + 1 tap)
|
||||
1. Backend generates an ephemeral client keypair and renders a
|
||||
`nostrconnect://<pubkey>?relay=<url>&secret=<rand>&perms=sign_event:22242&name=Archipelago` QR.
|
||||
2. User scans with **Amber** (Android reference signer; Aegis/Nowser also scan;
|
||||
nsec.app is paste-based; Alby is *not* a NIP-46 signer).
|
||||
3. Phone connects to the relay, acks the secret; backend requests one
|
||||
`sign_event:22242` over the encrypted NIP-46 channel, verifies, issues the session.
|
||||
|
||||
**Key architectural choice:** make the **Rust backend the NIP-46 client** (rust-nostr's
|
||||
`nostr-connect` crate), talking to the relay over localhost — the browser only polls our
|
||||
own RPC for "signer connected". No websocket/mixed-content issues in the Vue app.
|
||||
|
||||
**Relay topology:** no public relay is required by the spec — and public relays often
|
||||
rate-limit ephemeral NIP-46 traffic. The node's own strfry is the ideal relay (private,
|
||||
LAN-fast); the QR should carry a relay URL derived from the Host the browser used
|
||||
(LAN IP / Tailscale IP — not `.local`, which Android often can't resolve).
|
||||
**One empirical blocker to test first: does Amber accept plain `ws://` LAN relays?**
|
||||
(Self-signed `wss://` will likely fail cert validation.) If not, route `wss://` through
|
||||
the existing nginx/HTTPS cert story.
|
||||
|
||||
### C. Remembered NIP-46 session (persisted bunker pointer) — zero-tap repeat logins
|
||||
Same as B but persists the pairing so future logins auto-approve. Adds state,
|
||||
revocation surface, and "bunker offline = silent hang" failure modes. **Defer** — B
|
||||
re-scans in ~5 seconds anyway.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Ship **A + B behind one "Sign in with Nostr" button**; skip C for now. Password (+TOTP)
|
||||
stays the permanent fallback — exactly as the user proposed, the signer is enrolled in a
|
||||
step *after* password creation, never instead of it. The verification core is one shared
|
||||
Rust function (sig + challenge + freshness + enrolled-pubkey → session).
|
||||
|
||||
- **Onboarding:** after the password (and seed) steps, an optional "Connect a signer"
|
||||
card: QR (nostrconnect) + "Use browser extension" + Skip. Success enrolls the npub as
|
||||
a login key.
|
||||
- **Settings (next to TOTP):** list enrolled npubs (added date + method), "Add npub"
|
||||
(paste, becomes usable after a challenge-verify), "Connect another signer" (same
|
||||
QR/extension modal), "Remove" (requires password confirm; removing the last npub never
|
||||
locks the account — password always works).
|
||||
- **Libraries:** hand-roll the 22242 event for NIP-07 (window.nostr is a browser global);
|
||||
rust-nostr `nostr-connect` for NIP-46. Avoid the 2.4 MB `nostr-login` JS bundle —
|
||||
wrong fit for a self-hosted box (defaults to public bunkers); it's UX prior art only.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Only pubkeys enrolled **while authenticated** (or during onboarding) may log in —
|
||||
a simple `login_npubs` list next to the TOTP data in `auth.rs`.
|
||||
- Challenge: 32-byte random, single-use, 2–5 min TTL, `created_at` ±60 s, deleted on
|
||||
first verify attempt; pin an origin/host tag. Rate-limit like password attempts.
|
||||
- The `secret` in the nostrconnect URI is a bearer token — one QR per attempt, expires
|
||||
with the challenge.
|
||||
- Policy call: signer approval should count as the second factor for TOTP accounts
|
||||
(possession of phone/extension key), so nostr login doesn't silently bypass TOTP.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Amber + `ws://` LAN relay — needs a 10-minute on-device test before committing.
|
||||
2. Which relay URL to embed (LAN vs Tailscale vs onion) — derive from browser Host.
|
||||
3. NIP-46 encryption: spec says NIP-44, some signers still NIP-04 — rust-nostr handles
|
||||
both; verify against current Amber.
|
||||
4. Track draft **NIP-97 "Login with Nostr"** (matches this UX exactly, unmerged) —
|
||||
align, don't depend.
|
||||
|
||||
**Prior art:** no mainstream self-hosted node OS (Umbrel, Start9, Alby Hub) ships Nostr
|
||||
QR login for its own UI — this would be genuinely differentiating, and every building
|
||||
block is already in the tree.
|
||||
@@ -254,17 +254,29 @@ container_pull() {
|
||||
echo "📦 Step 1: Building root filesystem..."
|
||||
|
||||
ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar"
|
||||
ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
|
||||
# The cached rootfs must be invalidated when its recipe changes: a stale
|
||||
# archipelago-rootfs.tar on the build machine shipped ISOs with NO
|
||||
# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages
|
||||
# were added to the Dockerfile below — the cache condition never looked at
|
||||
# the recipe. Hash the rootfs-defining region of this script; any edit to it
|
||||
# forces a rebuild. `--rebuild` still forces one unconditionally.
|
||||
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
|
||||
echo " Using Docker to create Debian root filesystem..."
|
||||
|
||||
# Create a Dockerfile for building the rootfs
|
||||
cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE
|
||||
# ─── Stage 1: Build the FIPS mesh daemon .deb from upstream main ─────────
|
||||
# ─── Stage 1: Build the FIPS mesh daemon .deb at a pinned tag ────────────
|
||||
#
|
||||
# FIPS (github.com/jmcorgan/fips) is a fast Nostr-keyed mesh routing
|
||||
# protocol archipelago uses as its preferred non-Tor transport. We track
|
||||
# upstream main per project decision (2026-04) — v0.2.0 isn't stable yet.
|
||||
# protocol archipelago uses as its preferred non-Tor transport.
|
||||
# Pinned so the shipped version is knowable: an unpinned --depth 1 clone of
|
||||
# main made every ISO carry whatever upstream happened to be that day.
|
||||
# v0.4.1 is the version fips/config.rs renders its typed config against and
|
||||
# the one validated in the field. Bump the two together.
|
||||
# The .deb is rebuilt every ISO build; Docker layer caching keeps the
|
||||
# incremental cost low. Failure here fails the ISO build on purpose:
|
||||
# we don't want to ship an ISO that silently skips FIPS.
|
||||
@@ -282,7 +294,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
clang libclang-dev libnftnl-dev libmnl-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo install --locked cargo-deb
|
||||
RUN git clone --depth 1 https://github.com/jmcorgan/fips.git /src/fips
|
||||
ARG FIPS_VERSION=v0.4.1
|
||||
RUN git clone --depth 1 --branch "\$FIPS_VERSION" \\
|
||||
https://github.com/jmcorgan/fips.git /src/fips
|
||||
WORKDIR /src/fips
|
||||
# fips-gateway is gated behind the `gateway` Cargo feature (depends on
|
||||
# `rustables`). Without the feature, cargo doesn't build it, and
|
||||
@@ -694,6 +708,7 @@ SYSTEMDSERVICE
|
||||
$CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR"
|
||||
$CONTAINER_CMD rm archipelago-rootfs-tmp
|
||||
|
||||
echo "$RECIPE_HASH" > "$ROOTFS_STAMP"
|
||||
echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)"
|
||||
else
|
||||
echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)"
|
||||
@@ -1168,39 +1183,13 @@ BACKENDFILE
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract NostrVPN binary from container image (native system service, not a container app)
|
||||
# NOTE: The container image must be built against Debian 13's GLIBC (2.40).
|
||||
# If built against a newer GLIBC, the binary will fail at runtime.
|
||||
# Rebuild with: FROM debian:13 AS builder
|
||||
echo " Extracting NostrVPN binary..."
|
||||
_NVPN_IMG="${NOSTR_VPN_IMAGE:-146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7}"
|
||||
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
|
||||
if [ -z "$NVPN_IMAGE_ID" ]; then
|
||||
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
|
||||
fi
|
||||
NVPN_CONTAINER=$($CONTAINER_CMD create "$_NVPN_IMG" 2>/dev/null) || true
|
||||
if [ -n "$NVPN_CONTAINER" ]; then
|
||||
$CONTAINER_CMD cp "$NVPN_CONTAINER:/usr/local/bin/nvpn" "$ARCH_DIR/bin/nvpn" 2>/dev/null && \
|
||||
chmod +x "$ARCH_DIR/bin/nvpn" && \
|
||||
echo " ✅ NostrVPN binary extracted ($(du -h "$ARCH_DIR/bin/nvpn" | cut -f1))"
|
||||
$CONTAINER_CMD rm "$NVPN_CONTAINER" 2>/dev/null || true
|
||||
# Check GLIBC compatibility — Debian 13 (Trixie) has GLIBC 2.40
|
||||
if [ -f "$ARCH_DIR/bin/nvpn" ]; then
|
||||
NVPN_GLIBC=$(objdump -T "$ARCH_DIR/bin/nvpn" 2>/dev/null | grep -oP 'GLIBC_\K[0-9.]+' | sort -V | tail -1)
|
||||
if [ -n "$NVPN_GLIBC" ]; then
|
||||
# Compare: if required GLIBC > 2.40, warn
|
||||
if printf '%s\n' "2.40" "$NVPN_GLIBC" | sort -V | tail -1 | grep -qv "^2\.40$"; then
|
||||
echo " ⚠ WARNING: nvpn binary requires GLIBC $NVPN_GLIBC but Debian 13 has 2.40"
|
||||
echo " ⚠ The nvpn daemon will fail at runtime. Rebuild the container against Debian 13."
|
||||
echo " ⚠ VPN invite/status will still work via Rust backend config.toml fallback."
|
||||
else
|
||||
echo " ✅ nvpn GLIBC compatibility OK (requires $NVPN_GLIBC, target has 2.40)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " ⚠ NostrVPN image not available — nvpn binary will be missing"
|
||||
fi
|
||||
# NostrVPN (the native `nvpn` mesh-VPN daemon) has been removed from the
|
||||
# product — the active VPN path is WireGuard/Tailscale (see core vpn.rs). Its
|
||||
# service is already masked below (ln -sf /dev/null nostr-vpn.service) and the
|
||||
# binary is never spawned at runtime, so we no longer extract it. The
|
||||
# nostr-vpn image was deleted from the registry, which is why hard-requiring
|
||||
# it here bricked the build. Intentionally left out; do not re-add without
|
||||
# restoring the daemon.
|
||||
|
||||
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
|
||||
echo " Extracting nostr-rs-relay binary..."
|
||||
@@ -1210,7 +1199,11 @@ if [ -z "$RELAY_IMAGE" ]; then
|
||||
fi
|
||||
RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
|
||||
if [ -n "$RELAY_CONTAINER" ]; then
|
||||
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
|
||||
# The relay image builds to its WORKDIR /usr/src/app and execs
|
||||
# ./nostr-rs-relay from there (not /usr/local/bin — that path was from an
|
||||
# older image build and silently broke extraction once the image was
|
||||
# rebuilt to the standard layout).
|
||||
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/src/app/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
|
||||
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
|
||||
echo " ✅ nostr-rs-relay binary extracted ($(du -h "$ARCH_DIR/bin/nostr-rs-relay" | cut -f1))"
|
||||
$CONTAINER_CMD rm "$RELAY_CONTAINER" 2>/dev/null || true
|
||||
@@ -1218,6 +1211,23 @@ else
|
||||
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
|
||||
fi
|
||||
|
||||
# A missing nostr-rs-relay used to be a warning, and the resulting ISO shipped
|
||||
# an enabled nostr-relay unit that crash-looped on every install. Refuse to
|
||||
# produce that ISO unless explicitly overridden. (nvpn is intentionally no
|
||||
# longer required — NostrVPN was removed; see the note above.)
|
||||
MISSING_VPN_BINARIES=""
|
||||
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
|
||||
if [ -n "$MISSING_VPN_BINARIES" ]; then
|
||||
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
|
||||
echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)"
|
||||
else
|
||||
echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES"
|
||||
echo " The registry (146.59.87.168:3000) must be reachable and hold the images,"
|
||||
echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy WireGuard helper script
|
||||
if [ -f "$WORK_DIR/archipelago-wg" ]; then
|
||||
cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg"
|
||||
@@ -1353,9 +1363,11 @@ if [ "$UNBUNDLED" = "1" ]; then
|
||||
# unbundled mode — their images must ride on the ISO so a fresh install
|
||||
# works with no internet: FileBrowser (Cloud file manager) and fmcd
|
||||
# (fedimint-clientd, ecash/sats out of the box).
|
||||
# Shipped zstd-compressed: podman load auto-detects compression, and an
|
||||
# uncompressed fmcd.tar alone added ~220MB to the ISO (RC9 size regression).
|
||||
CORE_BUNDLE="
|
||||
${FILEBROWSER_IMAGE} filebrowser.tar
|
||||
${FMCD_IMAGE} fmcd.tar
|
||||
${FILEBROWSER_IMAGE} filebrowser.tar.zst
|
||||
${FMCD_IMAGE} fmcd.tar.zst
|
||||
"
|
||||
echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do
|
||||
[ -n "$CORE_IMAGE" ] || continue
|
||||
@@ -1364,9 +1376,14 @@ ${FMCD_IMAGE} fmcd.tar
|
||||
else
|
||||
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
|
||||
if container_pull "$CORE_IMAGE"; then
|
||||
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
|
||||
RAW_TAR="$IMAGES_DIR/${CORE_FILE%.zst}"
|
||||
if $CONTAINER_CMD save "$CORE_IMAGE" -o "$RAW_TAR" 2>/dev/null && \
|
||||
zstd -q -T0 -15 --rm "$RAW_TAR" -o "$IMAGES_DIR/$CORE_FILE"; then
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))"
|
||||
else
|
||||
rm -f "$RAW_TAR" "$IMAGES_DIR/$CORE_FILE"
|
||||
echo " ⚠️ Failed to save $CORE_IMAGE"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline"
|
||||
fi
|
||||
@@ -1509,7 +1526,7 @@ done
|
||||
PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman"
|
||||
$PODMAN system migrate >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
for tarfile in "$IMAGES_DIR"/*.tar; do
|
||||
for tarfile in "$IMAGES_DIR"/*.tar "$IMAGES_DIR"/*.tar.zst; do
|
||||
if [ -f "$tarfile" ]; then
|
||||
echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE"
|
||||
$PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
|
||||
@@ -1651,17 +1668,17 @@ LOG="/var/log/archipelago-tor.log"
|
||||
|
||||
mkdir -p "$ARCHY_TOR_DIR" "$TOR_CONFIG_DIR"
|
||||
|
||||
# Write services.json for the backend to read
|
||||
# First boot only: seed services.json + torrc. The unit runs on EVERY boot
|
||||
# (oneshot, multi-user.target), and rewriting these unconditionally clobbered
|
||||
# hidden services the backend added after app installs. Only the node's own
|
||||
# service is pre-baked — apps get their hidden service created on install
|
||||
# (auto_add_tor_service / tor.create-service), never pre-created for apps
|
||||
# that may never be installed (issue #79).
|
||||
if [ ! -f "$TOR_CONFIG_DIR/services.json" ]; then
|
||||
cat > "$ARCHY_TOR_DIR/services.json" <<TORJSON
|
||||
{
|
||||
"services": [
|
||||
{"name": "archipelago", "local_port": 80, "enabled": true},
|
||||
{"name": "bitcoin", "local_port": 8333, "enabled": true},
|
||||
{"name": "electrumx", "local_port": 50001, "enabled": true},
|
||||
{"name": "lnd", "local_port": 9735, "enabled": true},
|
||||
{"name": "btcpay", "local_port": 23000, "enabled": true},
|
||||
{"name": "mempool", "local_port": 4080, "enabled": true},
|
||||
{"name": "fedimint", "local_port": 8175, "enabled": true}
|
||||
{"name": "archipelago", "local_port": 80, "enabled": true}
|
||||
]
|
||||
}
|
||||
TORJSON
|
||||
@@ -1681,33 +1698,16 @@ SocksPolicy reject *
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_archipelago
|
||||
HiddenServicePort 80 127.0.0.1:80
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
|
||||
HiddenServicePort 8333 127.0.0.1:8333
|
||||
HiddenServicePort 8332 127.0.0.1:8332
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
|
||||
HiddenServicePort 50001 127.0.0.1:50001
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_lnd
|
||||
HiddenServicePort 9735 127.0.0.1:9735
|
||||
HiddenServicePort 8080 127.0.0.1:8080
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
|
||||
HiddenServicePort 23000 127.0.0.1:23000
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_mempool
|
||||
HiddenServicePort 4080 127.0.0.1:4080
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_fedimint
|
||||
HiddenServicePort 8175 127.0.0.1:8175
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_relay
|
||||
HiddenServicePort 7777 127.0.0.1:7777
|
||||
TORRC
|
||||
else
|
||||
echo "$(date): tor already initialized — leaving services.json/torrc alone" >> "$LOG"
|
||||
fi
|
||||
|
||||
# Create hidden service dirs with correct ownership and permissions (700, not 750)
|
||||
# Tor refuses to start if permissions are too permissive
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
|
||||
for svc in archipelago relay; do
|
||||
mkdir -p "$TOR_DIR/hidden_service_$svc"
|
||||
chown debian-tor:debian-tor "$TOR_DIR/hidden_service_$svc"
|
||||
chmod 700 "$TOR_DIR/hidden_service_$svc"
|
||||
@@ -1752,7 +1752,7 @@ done
|
||||
# Sync hostnames to backend-readable directory
|
||||
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
|
||||
mkdir -p "$HOSTNAMES_DIR"
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
|
||||
for svc in archipelago relay; do
|
||||
if [ -f "$TOR_DIR/hidden_service_${svc}/hostname" ]; then
|
||||
cp "$TOR_DIR/hidden_service_${svc}/hostname" "$HOSTNAMES_DIR/$svc"
|
||||
echo "$(date): Synced hostname: $svc" >> "$LOG"
|
||||
@@ -2490,6 +2490,17 @@ HandleLidSwitchExternalPower=ignore
|
||||
HandleLidSwitchDocked=ignore
|
||||
LIDCONF
|
||||
|
||||
# Kiosk appliance: a short press or accidental brush of the power button used
|
||||
# to shut the node down instantly (confirmed on a Framework node — "Power key
|
||||
# pressed short -> Powering off"). Ignore the short press; keep a deliberate
|
||||
# long press (hold ~2s) as the intentional power-off, so the node isn't taken
|
||||
# down by accident but can still be shut down on purpose without a keyboard.
|
||||
cat > /mnt/target/etc/systemd/logind.conf.d/power-key.conf <<'PWRCONF'
|
||||
[Login]
|
||||
HandlePowerKey=ignore
|
||||
HandlePowerKeyLongPress=poweroff
|
||||
PWRCONF
|
||||
|
||||
# Copy Archipelago binaries and files
|
||||
if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then
|
||||
cp -r "$BOOT_MEDIA/archipelago/bin/"* /mnt/target/usr/local/bin/ 2>/dev/null || true
|
||||
@@ -2520,7 +2531,7 @@ fi
|
||||
if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then
|
||||
echo " Copying container images (this may take a moment)..."
|
||||
mkdir -p /mnt/target/opt/archipelago/container-images
|
||||
cp -r "$BOOT_MEDIA/archipelago/container-images/"*.tar /mnt/target/opt/archipelago/container-images/ 2>/dev/null || true
|
||||
cp -r "$BOOT_MEDIA/archipelago/container-images/"*.tar* /mnt/target/opt/archipelago/container-images/ 2>/dev/null || true
|
||||
|
||||
# Copy first-boot loader script and service
|
||||
mkdir -p /mnt/target/opt/archipelago/scripts
|
||||
@@ -2649,6 +2660,11 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
|
||||
W='\033[1;37m'
|
||||
N='\033[0m'
|
||||
|
||||
# The logo uses UTF-8 block-drawing glyphs. Switching back from the kiosk
|
||||
# (Xorg on vt1) can leave the console VT out of UTF-8 mode, which renders
|
||||
# them as garbage bytes ("sometimes corrupt"). ESC % G forces the VT into
|
||||
# UTF-8 mode so the logo always draws correctly.
|
||||
printf '\033%%G' 2>/dev/null || true
|
||||
clear
|
||||
echo -e " ${O}▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█${N}"
|
||||
echo -e " ${O}█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █${N}"
|
||||
@@ -2664,10 +2680,14 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
|
||||
if [ -b /dev/mapper/archipelago-data ] || [ -b /dev/mapper/archipelago_crypt ]; then
|
||||
echo -e " ${OD}storage LUKS2 encrypted${N}"
|
||||
fi
|
||||
# The kiosk's Xorg runs on vt1 (see archipelago-kiosk-launcher: "Xorg :0
|
||||
# vt1"), so Ctrl+Alt+F1 IS the kiosk and a terminal is on another VT (F2,
|
||||
# where systemd auto-spawns a getty). The old hints had these backwards —
|
||||
# they sent you to an empty black VT7 and there was no way back to the kiosk.
|
||||
if systemctl is-active archipelago-kiosk.service >/dev/null 2>&1; then
|
||||
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F1 for terminal)${N}"
|
||||
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F2 for terminal)${N}"
|
||||
else
|
||||
echo -e " ${OD}display Console (Ctrl+Alt+F7 for kiosk)${N}"
|
||||
echo -e " ${OD}display Console (Ctrl+Alt+F1 for kiosk)${N}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
@@ -3338,6 +3358,11 @@ echo ""
|
||||
echo "=== Done ==="
|
||||
DIAGSCRIPT
|
||||
chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh
|
||||
# v1.7.104 shipped installs where this script was missing while its unit was
|
||||
# enabled (203/EXEC forever). Verify the write actually landed, loudly.
|
||||
if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then
|
||||
echo "ERROR: first-boot-diag.sh was not written to the target" >&2
|
||||
fi
|
||||
|
||||
# Systemd oneshot service for first-boot diagnostics
|
||||
cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
|
||||
@@ -3345,6 +3370,8 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
|
||||
Description=Archipelago First Boot Diagnostics
|
||||
After=multi-user.target archipelago.service nginx.service
|
||||
ConditionPathExists=!/var/log/archipelago-first-boot-diag.log
|
||||
# Skip cleanly (instead of failing 203/EXEC) if the script is missing.
|
||||
ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -82,6 +82,24 @@ configure_display() {
|
||||
|
||||
configure_display
|
||||
|
||||
# --- Kiosk UI scaling for large / high-res displays -----------------------
|
||||
# REVERT: set env ARCHIPELAGO_KIOSK_SCALE=1 (per-node, no rebuild), or restore
|
||||
# the hardcoded --force-device-scale-factor=1 below to disable entirely.
|
||||
#
|
||||
# A big TV reports its full native resolution as the CSS viewport, so a 4K
|
||||
# panel becomes a 3840px-wide viewport and the UI renders tiny — and a
|
||||
# keyboard-less kiosk can't zoom. Derive Chromium's device-scale-factor from
|
||||
# the detected panel width so the *effective* CSS viewport lands near a
|
||||
# comfortable across-the-room target, scaling the whole UI up on big screens:
|
||||
# 1920x1080 -> 1.50 | 2560x1440 -> 2.00 | 3840x2160 -> 3.00 (clamped 1.0-3.0)
|
||||
KIOSK_TARGET_CSS_WIDTH=${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-1280}
|
||||
if [ -n "${ARCHIPELAGO_KIOSK_SCALE:-}" ]; then
|
||||
KIOSK_SCALE=$ARCHIPELAGO_KIOSK_SCALE
|
||||
else
|
||||
KIOSK_SCALE=$(awk -v w="$KIOSK_MODE_W" -v t="$KIOSK_TARGET_CSS_WIDTH" \
|
||||
'BEGIN{if(t<=0)t=1280; s=w/t; s=int(s*4+0.5)/4; if(s<1)s=1; if(s>3)s=3; printf "%.2f", s}')
|
||||
fi
|
||||
|
||||
xhost +SI:localuser:archipelago 2>/dev/null || true
|
||||
xsetroot -solid black 2>/dev/null || true
|
||||
xset s off 2>/dev/null || true
|
||||
@@ -106,12 +124,27 @@ fi
|
||||
ARCHIPELAGO_UID=$(id -u archipelago)
|
||||
|
||||
while true; do
|
||||
# A profile lock left by a previous boot encodes <hostname>-<pid>; after a
|
||||
# hostname change (node rename) Chromium reads it as another computer
|
||||
# holding the profile and refuses to start — with --noerrdialogs that is an
|
||||
# invisible failure and the kiosk black-screens forever. Any Chromium that
|
||||
# owned the lock is dead by now (pkill above / previous loop iteration).
|
||||
rm -f /var/lib/archipelago/chromium-kiosk/Singleton{Lock,Cookie,Socket}
|
||||
# XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio
|
||||
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
|
||||
# falls back to raw ALSA "default", fails to connect, and produces no audio
|
||||
# at all with no visible error (--noerrdialogs suppresses it).
|
||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
|
||||
# Force a DARK color-scheme preference. The main UI hardcodes its dark
|
||||
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
|
||||
# and defaults to its LIGHT variant (white panels) when the browser reports
|
||||
# no preference — which a minimal kiosk X session does. Two independent dark
|
||||
# signals so this survives a Chromium enum change: GTK_THEME is version-
|
||||
# independent and can never force light (worst case: no effect), and the
|
||||
# blink-settings flag (0 = kDark in modern Chromium) reinforces it. Neither
|
||||
# can regress the always-dark main UI.
|
||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago GTK_THEME=Adwaita:dark XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
|
||||
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
|
||||
--blink-settings=preferredColorScheme=0 \
|
||||
--noerrdialogs \
|
||||
--disable-infobars \
|
||||
--disable-translate \
|
||||
@@ -127,7 +160,7 @@ while true; do
|
||||
--window-size=${KIOSK_MODE_W},${KIOSK_MODE_H} \
|
||||
--window-position=0,0 \
|
||||
--start-fullscreen \
|
||||
--force-device-scale-factor=1 \
|
||||
--force-device-scale-factor=${KIOSK_SCALE} \
|
||||
--disable-background-networking \
|
||||
--disable-background-timer-throttling \
|
||||
--disable-backgrounding-occluded-windows \
|
||||
|
||||
@@ -25,6 +25,11 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown
|
||||
# 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'
|
||||
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
|
||||
# in-binary post-OTA probe never runs — this restores the update-backup binary
|
||||
# after 5 failed start attempts while the pending-verify marker exists.
|
||||
# "-" so a missing/failed guard can never block the service itself.
|
||||
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -3,6 +3,9 @@ Description=Archipelago Private Nostr Relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=nostr-vpn.service
|
||||
# An ISO built without the relay binary (registry unreachable at build time)
|
||||
# must not crash-loop every 3s forever — skip cleanly instead.
|
||||
ConditionPathExists=/usr/local/bin/nostr-rs-relay
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -4,6 +4,9 @@ After=network-online.target tor.service archipelago.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
# An ISO built without the nvpn binary (registry unreachable at build time)
|
||||
# must not restart-loop — skip cleanly instead.
|
||||
ConditionPathExists=/usr/local/bin/nvpn
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -103,27 +103,10 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# 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/ {
|
||||
proxy_pass https://indee.tx1138.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host indee.tx1138.com;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
sub_filter_types text/html text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/indeedhub/';
|
||||
sub_filter 'src="/' 'src="/app/indeedhub/';
|
||||
sub_filter "href='/" "href='/app/indeedhub/";
|
||||
sub_filter "src='/" "src='/app/indeedhub/";
|
||||
sub_filter 'from"/' 'from"/app/indeedhub/';
|
||||
sub_filter 'url(/' 'url(/app/indeedhub/';
|
||||
}
|
||||
# IndeeHub is no longer proxied same-origin — the sub_filter rewrite
|
||||
# approach broke the SPA's runtime-built asset URLs. The demo now opens
|
||||
# the real site (https://indee.tx1138.com/) externally instead, via
|
||||
# DEMO_EXTERNAL_URLS in useDemoIntro.ts.
|
||||
|
||||
# Mempool is NOT proxied upstream anymore — the mock backend serves a
|
||||
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
|
||||
|
||||
+69
-11
@@ -2382,6 +2382,23 @@ app.post('/rpc/v1', (req, res) => {
|
||||
case 'streaming.list-services': {
|
||||
return res.json({ result: { services: mockState.streamingServices || [] } })
|
||||
}
|
||||
case 'streaming.list-sessions': {
|
||||
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
|
||||
return res.json({ result: {
|
||||
sessions: [
|
||||
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
|
||||
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
|
||||
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
|
||||
],
|
||||
total_active: 3,
|
||||
total_revenue_sats: 770_000,
|
||||
revenue_by_service: {
|
||||
'content-download': 512_400,
|
||||
'nostr-relay': 201_600,
|
||||
'api-access': 56_000,
|
||||
},
|
||||
} })
|
||||
}
|
||||
case 'streaming.configure-service': {
|
||||
const p = params || {}
|
||||
const list = mockState.streamingServices || []
|
||||
@@ -3394,14 +3411,19 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'lnd.listchannels': {
|
||||
// Shape matches the real backend: status + channel_point are required
|
||||
// by the channels panel; totals feed the liquidity summary tiles.
|
||||
const channels = [
|
||||
{ chan_id: '840921088114688', remote_pubkey: '031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581', capacity: 1500000, local_balance: 950000, remote_balance: 550000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Olympus by ZEUS' },
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
||||
]
|
||||
return res.json({
|
||||
result: {
|
||||
channels: [
|
||||
{ chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: true, peer_alias: 'Kraken' },
|
||||
],
|
||||
channels,
|
||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3445,7 +3467,10 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'lnd.sendcoins': {
|
||||
const amt = params?.amount || params?.amt || 50000
|
||||
// send_all sweeps the entire on-chain balance (minus a mock fee)
|
||||
const amt = params?.send_all
|
||||
? Math.max(0, walletState.onchain_sats - 250)
|
||||
: (params?.amount || params?.amt || 50000)
|
||||
walletState.onchain_sats = Math.max(0, walletState.onchain_sats - amt)
|
||||
const txid = randomHex(32)
|
||||
walletState.transactions.unshift({
|
||||
@@ -3566,13 +3591,32 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'wallet.networking-profits': {
|
||||
// Deterministic-but-varied week of profit events for the dashboard
|
||||
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
|
||||
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
|
||||
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
|
||||
const recent = []
|
||||
const nowMs = Date.now()
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const daysAgo = (i * 3) % 7
|
||||
const hour = (i * 5) % 24
|
||||
recent.push({
|
||||
source: profitSources[i % 3],
|
||||
amount_sats: 800 + ((i * 7919) % 14000),
|
||||
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
|
||||
description: profitNotes[i % 3],
|
||||
})
|
||||
}
|
||||
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
return res.json({
|
||||
result: {
|
||||
total_sats: 5_231_978,
|
||||
content_sales_sats: 3_180_000,
|
||||
routing_fees_sats: 1_281_978,
|
||||
relay_sats: 770_000,
|
||||
streaming_revenue_sats: 770_000,
|
||||
recent,
|
||||
// legacy aliases kept for older UI builds
|
||||
relay_sats: 770_000,
|
||||
total_earned_sats: 5_231_978,
|
||||
total_forwarded_sats: 1_281_978,
|
||||
forward_count: 1284,
|
||||
@@ -3607,15 +3651,29 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'bitcoin.getinfo': {
|
||||
// Demo IBD simulation: the first call of a session arms a ~90s ramp
|
||||
// from 98.2% → 100% so the setup wizard can demo the live sync timer
|
||||
// and the "finish setup" toast that fires when IBD completes.
|
||||
// (The real backend returns { block_height, sync_progress } — a 0–1
|
||||
// fraction — which is what the frontend reads; the bitcoin-core-style
|
||||
// fields are kept for any legacy consumers.)
|
||||
if (!walletState.ibd_started_at) walletState.ibd_started_at = Date.now()
|
||||
const IBD_RAMP_MS = 90_000
|
||||
const elapsed = Date.now() - walletState.ibd_started_at
|
||||
const syncProgress = Math.min(1, 0.982 + 0.018 * (elapsed / IBD_RAMP_MS))
|
||||
const tipHeight = 892451
|
||||
const height = Math.round(tipHeight * syncProgress)
|
||||
return res.json({
|
||||
result: {
|
||||
chain: 'signet',
|
||||
blocks: 892451,
|
||||
headers: 892451,
|
||||
block_height: height,
|
||||
sync_progress: syncProgress,
|
||||
blocks: height,
|
||||
headers: tipHeight,
|
||||
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
|
||||
difficulty: 0.001126515290698186,
|
||||
mediantime: Math.floor(Date.now() / 1000) - 300,
|
||||
verificationprogress: 1.0,
|
||||
verificationprogress: syncProgress,
|
||||
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
|
||||
size_on_disk: 210_000_000,
|
||||
pruned: false,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.101-alpha",
|
||||
"version": "1.7.108-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.101-alpha",
|
||||
"version": "1.7.108-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.101-alpha",
|
||||
"version": "1.7.108-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
+15
-4
@@ -424,10 +424,14 @@ onMounted(async () => {
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO && bootPath === '/') replayRequested = true
|
||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
const splashCandidate = !seenIntro
|
||||
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||
// Root boots always ask the backend — even when this browser thinks it has
|
||||
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
|
||||
// are per-origin browser state: after a reinstall (or another node coming
|
||||
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
|
||||
// a fresh install's intro / misroute it to login.
|
||||
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
|
||||
|
||||
if (splashCandidate && onboardingComplete !== true) {
|
||||
if (splashCandidate) {
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
// Bound the pre-splash status check: its retry ladder can spend ~30s
|
||||
@@ -437,10 +441,17 @@ onMounted(async () => {
|
||||
// the splash play (a fresh install IS the slow-backend case; onboarded
|
||||
// nodes answer in milliseconds, so their suppression path is intact).
|
||||
// handleSplashComplete re-checks with full retries after the intro.
|
||||
onboardingComplete = await Promise.race([
|
||||
const live = await Promise.race([
|
||||
checkOnboardingStatus(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
|
||||
])
|
||||
if (live !== null) onboardingComplete = live
|
||||
if (live === false && seenIntro) {
|
||||
// Backend-confirmed fresh node behind a browser with a stale flag —
|
||||
// drop it so this boot (and every later one) plays the intro.
|
||||
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
|
||||
seenIntro = false
|
||||
}
|
||||
} catch {
|
||||
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
}
|
||||
|
||||
@@ -161,6 +161,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
|
||||
<button
|
||||
v-if="wgError && !wgLoading"
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
|
||||
@click="retryWgPeer"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
|
||||
<!-- Same-device path: a phone can't scan its own screen, so offer
|
||||
the config as a file WireGuard can import. -->
|
||||
@@ -318,7 +326,15 @@ const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Running inside the companion app's own WebView (it injects this JS bridge).
|
||||
// The "get the companion app" pitch is nonsense there — the user is already in
|
||||
// it — and the WG steps mid-pairing race the phone's changing network (the
|
||||
// "Failed to fetch" dead-end QR). Server/tunnel management for connected
|
||||
// companions lives in the NESMenu instead.
|
||||
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
|
||||
|
||||
onMounted(() => {
|
||||
if (IN_COMPANION_APP) return
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
||||
setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
@@ -471,6 +487,19 @@ function backFromPair() {
|
||||
}
|
||||
}
|
||||
|
||||
// Network-class failures: the node itself was unreachable (as opposed to the
|
||||
// backend answering with an RPC error). Includes the client's own timeout.
|
||||
const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i
|
||||
|
||||
// Auto-retry ladder for network-class failures. On a first install this step
|
||||
// is often reached while the backend is still settling (services starting,
|
||||
// backend restarting during container orchestration) — a single failed fetch
|
||||
// left a permanently blank QR unless the user spotted the retry button.
|
||||
const WG_RETRY_DELAYS_MS = [2000, 4000, 8000]
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const stillOnWgStep = () => visible.value && step.value === 'wgqr'
|
||||
|
||||
// Create (or fetch) the phone's VPN peer and render its config as a QR.
|
||||
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
|
||||
// looked up first so reopening the modal never duplicates it.
|
||||
@@ -478,30 +507,71 @@ async function loadWgPeer() {
|
||||
if (wgQrDataUrl.value || wgLoading.value) return
|
||||
wgLoading.value = true
|
||||
wgError.value = ''
|
||||
try {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => ({ peers: [] as { name: string }[] }))
|
||||
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
|
||||
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
|
||||
params: { name: WG_PEER_NAME },
|
||||
})
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
|
||||
} finally {
|
||||
wgLoading.value = false
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await provisionWgPeer()
|
||||
break
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : ''
|
||||
const isNetworkErr = WG_NETWORK_ERR.test(raw)
|
||||
const retryDelay = WG_RETRY_DELAYS_MS[attempt]
|
||||
if (isNetworkErr && retryDelay !== undefined && stillOnWgStep()) {
|
||||
await sleep(retryDelay)
|
||||
if (stillOnWgStep()) continue
|
||||
}
|
||||
// fetch()'s raw "Failed to fetch" means the node itself was unreachable —
|
||||
// after the retry ladder that's usually the phone's network mid-change
|
||||
// (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16).
|
||||
// Say so, and leave a Retry path instead of a dead end.
|
||||
wgError.value = isNetworkErr
|
||||
? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again."
|
||||
: raw || 'Failed to generate the tunnel config'
|
||||
break
|
||||
}
|
||||
}
|
||||
wgLoading.value = false
|
||||
}
|
||||
|
||||
async function provisionWgPeer() {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => null)
|
||||
// list-peers unreachable → don't guess "doesn't exist": create-peer on an
|
||||
// existing name would fail. Try create first, fall back to peer-config.
|
||||
const exists = listed === null
|
||||
? null
|
||||
: (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
let res: { config: string; peer_ip: string }
|
||||
if (exists === true) {
|
||||
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
|
||||
} else {
|
||||
try {
|
||||
res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } })
|
||||
} catch (e) {
|
||||
// Peer already provisioned on a previous visit (list failed or raced).
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
if (/exist|duplicate/i.test(msg)) {
|
||||
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function retryWgPeer() {
|
||||
wgError.value = ''
|
||||
void loadWgPeer()
|
||||
}
|
||||
|
||||
// Same-device path: hand the config to the WireGuard app as an importable
|
||||
|
||||
@@ -16,6 +16,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zeus channel suggestion -->
|
||||
<div class="glass-card p-4 mb-4 border border-orange-500/25">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<img
|
||||
src="/assets/img/app-icons/zeus.webp"
|
||||
alt="Zeus"
|
||||
class="w-12 h-12 rounded-xl shrink-0 border border-white/10"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
|
||||
<p class="text-white/55 text-xs leading-relaxed">
|
||||
Pair your node with the Zeus mobile wallet — open a channel to their Olympus node and
|
||||
start sending and receiving Lightning payments from your phone.
|
||||
Minimum 150,000 · maximum 1,500,000 sats.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex sm:flex-col items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click="openZeusChannel"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
Open Channel
|
||||
</button>
|
||||
<a
|
||||
href="https://zeusln.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-orange-400/80 hover:text-orange-300 whitespace-nowrap"
|
||||
>Get Zeus →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Channel Button -->
|
||||
<div class="flex justify-end mb-4">
|
||||
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
@@ -74,15 +107,15 @@
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-400': ch.status === 'active',
|
||||
'bg-yellow-400': ch.status === 'pending_open',
|
||||
'bg-red-400': ch.status === 'inactive',
|
||||
'bg-green-400': channelStatus(ch) === 'active',
|
||||
'bg-yellow-400': channelStatus(ch) === 'pending_open',
|
||||
'bg-red-400': channelStatus(ch) === 'inactive',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="ch.status !== 'pending_open'"
|
||||
v-if="channelStatus(ch) !== 'pending_open'"
|
||||
@click="confirmClose(ch)"
|
||||
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
|
||||
>
|
||||
@@ -270,8 +303,13 @@ interface Channel {
|
||||
local_balance: number
|
||||
remote_balance: number
|
||||
active: boolean
|
||||
status: string
|
||||
channel_point: string
|
||||
status?: string
|
||||
channel_point?: string
|
||||
}
|
||||
|
||||
/** Status with a fallback derived from `active` for backends that omit it */
|
||||
function channelStatus(ch: Channel): string {
|
||||
return ch.status ?? (ch.active ? 'active' : 'inactive')
|
||||
}
|
||||
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
@@ -288,6 +326,11 @@ const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||
|
||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||
// Channel limits: min 150,000 / max 1,500,000 sats.
|
||||
const OLYMPUS_PEER_URI =
|
||||
'031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735'
|
||||
|
||||
const showOpenModal = ref(false)
|
||||
const defaultOpenForm = () => ({
|
||||
peerUri: '',
|
||||
@@ -297,6 +340,19 @@ const defaultOpenForm = () => ({
|
||||
customConfTarget: null as number | null,
|
||||
customSatPerVbyte: null as number | null,
|
||||
})
|
||||
|
||||
/** Prefill the open-channel modal for a Zeus (Olympus) channel */
|
||||
function openZeusChannel() {
|
||||
openForm.value = {
|
||||
...defaultOpenForm(),
|
||||
peerUri: OLYMPUS_PEER_URI,
|
||||
amount: 150000,
|
||||
// Olympus only accepts unannounced channels
|
||||
private: true,
|
||||
}
|
||||
openError.value = null
|
||||
showOpenModal.value = true
|
||||
}
|
||||
const openForm = ref(defaultOpenForm())
|
||||
const openingChannel = ref(false)
|
||||
const openError = ref<string | null>(null)
|
||||
@@ -313,7 +369,7 @@ function formatSats(sats: number): string {
|
||||
}
|
||||
|
||||
function fundingTxid(ch: Channel): string {
|
||||
const txid = ch.channel_point.split(':')[0] || ''
|
||||
const txid = ch.channel_point?.split(':')[0] || ''
|
||||
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
@@ -53,6 +54,12 @@ function reloadAfterCinematic() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The public demo has no version to update to — the prompt is noise, and
|
||||
// both accept-paths end in a reload that replays the demo intro ("the site
|
||||
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
|
||||
// waiting worker is safe: this page keeps its complete old cache, and the
|
||||
// new build activates on the next visit.
|
||||
if (IS_DEMO) return
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
// On the very first visit the page loads with no controlling SW; the
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
|
||||
<!-- On-chain -->
|
||||
<div v-if="receiveMethod === 'onchain'">
|
||||
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
|
||||
{{ note }}
|
||||
</div>
|
||||
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
|
||||
@@ -77,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -85,9 +88,21 @@ import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
|
||||
note?: string
|
||||
/** Generate an on-chain address immediately when the modal opens */
|
||||
autoGenerate?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; received: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
void receive()
|
||||
}
|
||||
})
|
||||
|
||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
|
||||
const invoiceAmount = ref<number>(0)
|
||||
const invoiceMemo = ref('')
|
||||
|
||||
@@ -16,8 +16,30 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<button
|
||||
v-if="sendMethod === 'onchain'"
|
||||
@click="toggleSendAll"
|
||||
class="text-xs px-2 py-0.5 rounded border transition-colors"
|
||||
:class="sendAll
|
||||
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
|
||||
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
|
||||
>
|
||||
Send all funds
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="amount"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="sendAll ? '' : '1000'"
|
||||
:disabled="sendAll"
|
||||
class="w-full input-glass disabled:opacity-50"
|
||||
/>
|
||||
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
|
||||
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||
@@ -47,7 +69,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="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('common.sending') : t('common.send') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -55,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -75,6 +97,23 @@ const resultHash = ref('')
|
||||
const resultArk = ref('')
|
||||
const ecashToken = ref('')
|
||||
|
||||
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
|
||||
const sendAll = ref(false)
|
||||
const onchainBalance = ref<number | null>(null)
|
||||
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
|
||||
|
||||
function toggleSendAll() {
|
||||
sendAll.value = !sendAll.value
|
||||
if (sendAll.value && onchainBalance.value === null) {
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
|
||||
const effectiveMethod = computed(() => {
|
||||
if (sendMethod.value !== 'auto') return sendMethod.value
|
||||
const amt = amount.value || 0
|
||||
@@ -98,7 +137,8 @@ function copyText(text: string) {
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!amount.value || processing.value) return
|
||||
if (processing.value) return
|
||||
if (!amount.value && !isSweep.value) return
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
ecashToken.value = ''
|
||||
@@ -134,7 +174,9 @@ async function send() {
|
||||
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr: dest.value.trim(), amount: amount.value },
|
||||
params: isSweep.value
|
||||
? { addr: dest.value.trim(), send_all: true }
|
||||
: { addr: dest.value.trim(), amount: amount.value },
|
||||
})
|
||||
resultTxid.value = res.txid
|
||||
}
|
||||
|
||||
@@ -23,7 +23,14 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-white/90">{{ toast.message }}</span>
|
||||
<button
|
||||
v-if="toast.action"
|
||||
@click.stop="runAction(toast)"
|
||||
class="block mt-1 text-sm font-semibold text-orange-400 hover:text-orange-300 transition-colors"
|
||||
>{{ toast.action.label }} →</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -32,10 +39,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { ToastVariant } from '@/composables/useToast'
|
||||
import type { ToastItem, ToastVariant } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
function runAction(toast: ToastItem | Readonly<ToastItem>) {
|
||||
toast.action?.onClick()
|
||||
dismiss(toast.id)
|
||||
}
|
||||
|
||||
function variantClass(variant: ToastVariant): string {
|
||||
switch (variant) {
|
||||
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
|
||||
|
||||
@@ -216,7 +216,8 @@ async function connect() {
|
||||
device_kind: form.value.deviceKind,
|
||||
})
|
||||
mesh.dismissDetectedDevice(path)
|
||||
void router.push('/mesh')
|
||||
// The Mesh view lives under the dashboard shell — a bare /mesh 404s.
|
||||
void router.push('/dashboard/mesh')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
/**
|
||||
* Shared bitcoin sync (IBD) tracker with a live time-remaining estimate.
|
||||
*
|
||||
* Polls `bitcoin.getinfo` while at least one consumer holds an acquire()
|
||||
* lease, samples the sync rate, and exposes a ticking countdown so setup
|
||||
* screens can show "~2h 14m remaining" that visibly counts down between
|
||||
* polls. Module-level singleton — every consumer sees the same state.
|
||||
*/
|
||||
|
||||
/** Sync fraction (as percent) at which we consider IBD done, matching the Home tile */
|
||||
export const IBD_SYNCED_AT = 99.9
|
||||
|
||||
const POLL_MS = 15_000
|
||||
const TICK_MS = 1_000
|
||||
/** Ignore rate samples older than this when estimating */
|
||||
const SAMPLE_WINDOW_MS = 10 * 60_000
|
||||
|
||||
export const bitcoinSyncPercent = ref(0)
|
||||
export const bitcoinBlockHeight = ref(0)
|
||||
export const bitcoinSyncAvailable = ref(false)
|
||||
export const bitcoinSyncLoaded = ref(false)
|
||||
export const bitcoinSynced = computed(() => bitcoinSyncLoaded.value && bitcoinSyncPercent.value >= IBD_SYNCED_AT)
|
||||
|
||||
const etaSeconds = ref<number | null>(null)
|
||||
|
||||
/** Human countdown like "2h 14m" / "5m 12s" / "less than a minute", or '' while estimating */
|
||||
export const bitcoinSyncEtaText = computed(() => {
|
||||
const s = etaSeconds.value
|
||||
if (s === null) return ''
|
||||
if (s < 60) return 'less than a minute'
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}m ${sec}s`
|
||||
})
|
||||
|
||||
let samples: { t: number; p: number }[] = []
|
||||
let etaBase: { at: number; secs: number } | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let leases = 0
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 8000,
|
||||
})
|
||||
const pct = (btc.sync_progress ?? 0) * 100
|
||||
bitcoinSyncPercent.value = pct
|
||||
bitcoinBlockHeight.value = btc.block_height ?? 0
|
||||
bitcoinSyncAvailable.value = true
|
||||
bitcoinSyncLoaded.value = true
|
||||
|
||||
const now = Date.now()
|
||||
samples.push({ t: now, p: pct })
|
||||
samples = samples.filter((s) => now - s.t <= SAMPLE_WINDOW_MS).slice(-50)
|
||||
|
||||
if (pct >= IBD_SYNCED_AT) {
|
||||
etaBase = null
|
||||
etaSeconds.value = 0
|
||||
return
|
||||
}
|
||||
const first = samples[0]
|
||||
if (first && now - first.t >= 10_000 && pct > first.p) {
|
||||
const ratePerSec = (pct - first.p) / ((now - first.t) / 1000)
|
||||
etaBase = { at: now, secs: (IBD_SYNCED_AT - pct) / ratePerSec }
|
||||
}
|
||||
} catch {
|
||||
bitcoinSyncAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!etaBase) {
|
||||
if (!bitcoinSynced.value) etaSeconds.value = null
|
||||
return
|
||||
}
|
||||
etaSeconds.value = Math.max(0, etaBase.secs - (Date.now() - etaBase.at) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a polling lease. Returns a release function — call it on unmount.
|
||||
* Polling only runs while at least one lease is held.
|
||||
*/
|
||||
export function acquireBitcoinSync(): () => void {
|
||||
leases++
|
||||
if (leases === 1) {
|
||||
void poll()
|
||||
pollTimer = setInterval(() => void poll(), POLL_MS)
|
||||
tickTimer = setInterval(tick, TICK_MS)
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
leases = Math.max(0, leases - 1)
|
||||
if (leases === 0) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
pollTimer = null
|
||||
tickTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,17 @@ export function clearDemoIntroSeen(): void {
|
||||
// Only these apps actually do something in the demo (a mock UI or a real
|
||||
// external site). Everything else shows "No demo" on a disabled install button
|
||||
// and is not launchable.
|
||||
const DEMO_EXTERNAL_URLS: Record<string, string> = {}
|
||||
// IndeeHub's real site sends X-Frame-Options: SAMEORIGIN, and the old
|
||||
// same-origin nginx sub_filter proxy broke its runtime-built asset URLs —
|
||||
// so the demo opens the real site directly instead.
|
||||
const DEMO_EXTERNAL_URLS: Record<string, string> = {
|
||||
indeedhub: 'https://indee.tx1138.com/',
|
||||
}
|
||||
|
||||
// Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool
|
||||
// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths
|
||||
// rewritten) so the frame-busting real sites can be embedded.
|
||||
const DEMO_MOCK_UI: Record<string, string> = {
|
||||
indeedhub: '/app/indeedhub/',
|
||||
mempool: '/app/mempool/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'bitcoin-knots': '/app/bitcoin-knots/',
|
||||
@@ -61,11 +65,11 @@ const DEMO_MOCK_UI: Record<string, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a demo app opens in a new tab. Nothing does — IndeeHub and Mempool
|
||||
* both load their real site directly in the in-app iframe.
|
||||
* Whether a demo app opens externally (new tab / in-app browser) because its
|
||||
* real site blocks iframing (X-Frame-Options).
|
||||
*/
|
||||
export function isDemoExternal(_appId: string): boolean {
|
||||
return false
|
||||
export function isDemoExternal(appId: string): boolean {
|
||||
return appId in DEMO_EXTERNAL_URLS
|
||||
}
|
||||
|
||||
/** Can this app be launched/installed in the demo? */
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
// Session-level guard: the "finish setup" toast fires at most once per page load.
|
||||
let firedThisSession = false
|
||||
|
||||
/**
|
||||
* Watches for Bitcoin IBD completing while a Lightning setup goal is mid-flight
|
||||
* and pops a "Finish setup" toast linking back to that goal's wizard (which is
|
||||
* sitting on the fund-wallet / open-channel steps). Mount once in the
|
||||
* dashboard layout.
|
||||
*/
|
||||
export function useIbdFinishWatcher() {
|
||||
const goalStore = useGoalStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
// A goal qualifies while it's in progress and its manual fund/channel steps
|
||||
// aren't done yet. If several qualify, the first wins — finishing the shared
|
||||
// fund + channel steps completes the lightning part of any of them.
|
||||
const pendingLightningGoalId = computed<string | null>(() => {
|
||||
if (firedThisSession) return null
|
||||
for (const goal of GOALS) {
|
||||
const hasFundStep = goal.steps.some((s) => s.action === 'fund')
|
||||
if (!hasFundStep) continue
|
||||
if (goalStore.getGoalStatus(goal.id) !== 'in-progress') continue
|
||||
const done = goalStore.progress[goal.id]?.completedSteps ?? []
|
||||
const manualPending = goal.steps.some(
|
||||
(s) => s.action !== 'install' && !done.includes(s.id),
|
||||
)
|
||||
if (manualPending) return goal.id
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Only poll the chain while there's actually a goal waiting on it.
|
||||
let release: (() => void) | null = null
|
||||
watchEffect(() => {
|
||||
const shouldWatch = pendingLightningGoalId.value !== null && !bitcoinSynced.value
|
||||
if (shouldWatch && !release) {
|
||||
release = acquireBitcoinSync()
|
||||
} else if (!shouldWatch && release) {
|
||||
// Goal finished/reset or the chain synced — stop polling.
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
|
||||
// Fire only on a REAL transition: we must have observed the chain unsynced
|
||||
// at least once this session, so a node that's already synced at page load
|
||||
// doesn't toast.
|
||||
let sawUnsynced = false
|
||||
watch([bitcoinSynced, bitcoinSyncLoaded], ([synced, loaded]) => {
|
||||
if (!loaded) return
|
||||
if (!synced) {
|
||||
sawUnsynced = true
|
||||
return
|
||||
}
|
||||
if (!sawUnsynced || firedThisSession) return
|
||||
const goalId = pendingLightningGoalId.value
|
||||
if (!goalId) return
|
||||
firedThisSession = true
|
||||
toast.action(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
{
|
||||
label: 'Finish setup',
|
||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||
},
|
||||
)
|
||||
if (release) {
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (release) {
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,19 +2,25 @@ import { ref, readonly } from 'vue'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
dismissing: boolean
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
let nextId = 0
|
||||
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, variant, dismissing: false })
|
||||
toasts.value.push({ id, message, variant, dismissing: false, action })
|
||||
|
||||
// Auto-dismiss
|
||||
if (duration > 0) {
|
||||
@@ -42,6 +48,9 @@ export function useToast() {
|
||||
success: (msg: string) => addToast(msg, 'success'),
|
||||
error: (msg: string) => addToast(msg, 'error'),
|
||||
info: (msg: string) => addToast(msg, 'info'),
|
||||
/** Toast with an action link (e.g. "Finish setup"). Sticks around longer. */
|
||||
action: (msg: string, action: ToastAction, opts?: { variant?: ToastVariant; duration?: number }) =>
|
||||
addToast(msg, opts?.variant ?? 'success', opts?.duration ?? 15000, action),
|
||||
dismiss: dismissToast,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
import type { GoalDefinition } from '@/types/goals'
|
||||
import type { GoalDefinition, GoalStep } from '@/types/goals'
|
||||
|
||||
/** Zeus (Olympus LSP) channel size limits, in sats */
|
||||
export const ZEUS_CHANNEL_MIN_SATS = 150_000
|
||||
export const ZEUS_CHANNEL_MAX_SATS = 1_500_000
|
||||
|
||||
export const ZEUS_ICON = '/assets/img/app-icons/zeus.webp'
|
||||
|
||||
/**
|
||||
* Shared "fund the bitcoin wallet" step used by every Lightning goal. Gated on
|
||||
* the blockchain being fully synced (IBD) — the wizard shows a live sync timer
|
||||
* until then, and a "Fund Wallet" receive flow after.
|
||||
*/
|
||||
const FUND_WALLET_STEP: GoalStep = {
|
||||
id: 'fund-wallet',
|
||||
title: 'Fund Your Bitcoin Wallet',
|
||||
description:
|
||||
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels need between 150,000 and 1,500,000 sats. Funding unlocks once your node finishes syncing the blockchain.",
|
||||
action: 'fund',
|
||||
isAutomatic: false,
|
||||
icon: '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
}
|
||||
|
||||
export const GOALS: GoalDefinition[] = [
|
||||
{
|
||||
@@ -25,6 +46,17 @@ export const GOALS: GoalDefinition[] = [
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{ ...FUND_WALLET_STEP },
|
||||
{
|
||||
id: 'open-zeus-channel',
|
||||
title: 'Open a Channel with Zeus',
|
||||
description:
|
||||
'Open a Lightning channel to Zeus, the mobile wallet that pairs perfectly with your node. Fund it with 150,000–1,500,000 sats and your shop can accept instant Lightning payments.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
ctaLabel: 'Open a channel',
|
||||
},
|
||||
{
|
||||
id: 'install-btcpay',
|
||||
title: 'Install BTCPay Server',
|
||||
@@ -69,13 +101,16 @@ export const GOALS: GoalDefinition[] = [
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{ ...FUND_WALLET_STEP },
|
||||
{
|
||||
id: 'open-channel',
|
||||
title: 'Open a Lightning Channel',
|
||||
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
|
||||
appId: 'lnd',
|
||||
title: 'Open a Channel with Zeus',
|
||||
description:
|
||||
'Open your first payment channel to Zeus, the mobile wallet built for nodes like yours (150,000–1,500,000 sats). You can then send and receive Lightning payments from your phone.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
ctaLabel: 'Open a channel',
|
||||
},
|
||||
],
|
||||
estimatedTime: '~30 min + sync time',
|
||||
@@ -168,13 +203,16 @@ export const GOALS: GoalDefinition[] = [
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{ ...FUND_WALLET_STEP },
|
||||
{
|
||||
id: 'open-channels',
|
||||
title: 'Open Payment Channels',
|
||||
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
||||
appId: 'lnd',
|
||||
description:
|
||||
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus (150,000–1,500,000 sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
ctaLabel: 'Open a channel',
|
||||
},
|
||||
{
|
||||
id: 'verify-routing',
|
||||
|
||||
@@ -169,11 +169,16 @@ describe('useGoalStore', () => {
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns completed when all required apps are running', () => {
|
||||
it('returns completed when all required apps run AND manual steps are done', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
// Running apps alone no longer finish a goal — manual steps must be walked
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
|
||||
|
||||
store.startGoal('accept-payments')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('completed')
|
||||
})
|
||||
|
||||
@@ -198,6 +203,8 @@ describe('useGoalStore', () => {
|
||||
mockPackages['immich-server'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.startGoal('store-photos')
|
||||
store.completeStep('store-photos', 'configure-immich')
|
||||
expect(store.getGoalStatus('store-photos')).toBe('completed')
|
||||
})
|
||||
|
||||
@@ -218,6 +225,8 @@ describe('useGoalStore', () => {
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
const statuses = store.goalStatuses
|
||||
|
||||
expect(statuses['accept-payments']).toBe('completed')
|
||||
|
||||
@@ -88,12 +88,19 @@ export const useGoalStore = defineStore('goals', () => {
|
||||
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
|
||||
),
|
||||
)
|
||||
if (allRunning) return 'completed'
|
||||
|
||||
// Manual steps (fund the wallet, open a channel, configure the store…)
|
||||
// must be walked through too — running apps alone don't finish a goal.
|
||||
const done = progress.value[goalId]?.completedSteps ?? []
|
||||
const allManualDone = goal.steps
|
||||
.filter((s) => s.action !== 'install')
|
||||
.every((s) => done.includes(s.id))
|
||||
if (allRunning && allManualDone) return 'completed'
|
||||
|
||||
const anyInstalled = goal.requiredApps.some((appId) =>
|
||||
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
|
||||
)
|
||||
if (anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
if (allRunning || anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
|
||||
return 'not-started'
|
||||
}
|
||||
|
||||
+3
-15
@@ -79,7 +79,9 @@ select:focus-visible {
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
/* Card action placement: keep compact header buttons for genuinely wide layouts. */
|
||||
/* Card action placement: actions always live at the bottom of the card as
|
||||
full-width buttons — same layout on every screen size. The compact header
|
||||
variants are permanently retired for consistency (desktop == mobile). */
|
||||
.responsive-card-actions-top,
|
||||
.web5-card-actions-top {
|
||||
display: none;
|
||||
@@ -109,20 +111,6 @@ select:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.responsive-card-actions-top,
|
||||
.web5-card-actions-top {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.responsive-card-actions-bottom,
|
||||
.responsive-card-actions-bottom-grid,
|
||||
.web5-card-actions-bottom,
|
||||
.web5-card-actions-bottom-grid {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
||||
@media (max-width: 767px) {
|
||||
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
|
||||
|
||||
@@ -17,8 +17,16 @@ export interface GoalStep {
|
||||
title: string
|
||||
description: string
|
||||
appId?: string
|
||||
action: 'install' | 'configure' | 'verify' | 'info'
|
||||
/**
|
||||
* 'fund' renders the bitcoin-wallet funding UI: gated on IBD completion
|
||||
* (with a live sync timer), then a "Fund Wallet" receive flow.
|
||||
*/
|
||||
action: 'install' | 'configure' | 'verify' | 'info' | 'fund'
|
||||
isAutomatic: boolean
|
||||
/** Custom step icon (e.g. the Zeus logo) — overrides the appId-derived icon */
|
||||
icon?: string
|
||||
/** Custom label for the step's CTA button (configure steps) */
|
||||
ctaLabel?: string
|
||||
}
|
||||
|
||||
export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error'
|
||||
|
||||
@@ -38,4 +38,50 @@ describe('shouldShowIntroSplash', () => {
|
||||
replayRequested: true,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/login',
|
||||
fromBoot: true,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: null,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('fresh node on a deep route without boot handoff stays suppressed', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/onboarding/seed',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('boot dev mode never root-boots into the intro', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
devMode: 'boot',
|
||||
onboardingComplete: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,10 +10,19 @@ export interface IntroSplashDecisionInput {
|
||||
|
||||
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
||||
if (input.replayRequested) return true
|
||||
|
||||
const isDirectRoute = input.routePath !== '/'
|
||||
// A node the backend CONFIRMS has never completed onboarding always gets
|
||||
// the full intro on a root boot. `seenIntro` is per-origin browser state —
|
||||
// after a reinstall (or a DHCP-recycled IP), the browser still carries the
|
||||
// previous node's flag at the same origin, which silently muted the intro
|
||||
// on genuinely fresh installs.
|
||||
if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) {
|
||||
return true
|
||||
}
|
||||
if (input.seenIntro) return false
|
||||
if (input.onboardingComplete === true) return false
|
||||
|
||||
const isDirectRoute = input.routePath !== '/'
|
||||
if (input.fromBoot) return true
|
||||
if (input.devMode === 'boot') return false
|
||||
return !isDirectRoute
|
||||
|
||||
@@ -52,9 +52,11 @@
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Background overlay - uniform 0.2 opacity -->
|
||||
<!-- Background overlay. The web5/server backdrop is a much lighter image
|
||||
than the others, so it gets a heavier scrim for text contrast. -->
|
||||
<div
|
||||
class="fixed inset-0 pointer-events-none bg-black/20"
|
||||
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500"
|
||||
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
|
||||
style="z-index: -5;"
|
||||
/>
|
||||
|
||||
@@ -155,8 +157,12 @@ import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
|
||||
useIbdFinishWatcher()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
@@ -190,6 +196,10 @@ const backgroundImage = computed(() => {
|
||||
return 'bg-home.webp'
|
||||
})
|
||||
|
||||
// bg-web5.jpg (web5 + server sections) is bright — the scrim overlay deepens
|
||||
// while it's showing so light text keeps its contrast.
|
||||
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
|
||||
|
||||
const isDarkRoute = computed(() => {
|
||||
const p = route.path
|
||||
return p.includes('/dashboard/web5') ||
|
||||
|
||||
@@ -98,12 +98,59 @@
|
||||
>
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</button>
|
||||
|
||||
<!-- Fund the bitcoin wallet: IBD-gated, with live sync timer -->
|
||||
<div v-else-if="step.action === 'fund'" class="space-y-3">
|
||||
<div v-if="!bitcoinSynced" class="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||
<span class="text-xs text-white/75">Bitcoin is syncing — funding unlocks when it finishes</span>
|
||||
<span class="text-xs font-mono text-orange-300 shrink-0">{{ bitcoinSyncLoaded ? bitcoinSyncPercent.toFixed(1) + '%' : '…' }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-white/10 rounded-full overflow-hidden mb-1.5">
|
||||
<div class="h-full bg-orange-400 rounded-full transition-all duration-700" :style="{ width: `${Math.min(100, bitcoinSyncPercent)}%` }" />
|
||||
</div>
|
||||
<p class="text-xs text-white/50">
|
||||
<span v-if="bitcoinSyncEtaText" class="text-white/70 font-medium">~{{ bitcoinSyncEtaText }} remaining</span>
|
||||
<span v-else>Estimating time remaining…</span>
|
||||
<span v-if="bitcoinBlockHeight"> · Block {{ bitcoinBlockHeight.toLocaleString() }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-white/45 mt-1.5">We'll pop a notification here the moment it's done.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-white/60">On-chain wallet balance</span>
|
||||
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
|
||||
{{ walletOnchainSats.toLocaleString() }} sats
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/45 mt-1">Zeus channels need 150,000–1,500,000 sats.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="showFundModal = true"
|
||||
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Fund Wallet
|
||||
</button>
|
||||
<button
|
||||
@click="completeFundStep(step)"
|
||||
:disabled="walletOnchainSats <= 0"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'verify'"
|
||||
@@ -142,12 +189,27 @@
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
|
||||
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
|
||||
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
<button
|
||||
v-if="completionCta"
|
||||
@click="openCompletionTarget"
|
||||
class="glass-button rounded-lg px-6 py-3 font-medium"
|
||||
>
|
||||
{{ completionCta.label }}
|
||||
</button>
|
||||
<RouterLink v-else to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
{{ t('goalDetail.viewMyServices') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
|
||||
<ReceiveBitcoinModal
|
||||
:show="showFundModal"
|
||||
note="Fund your Lightning channel: Zeus channels need a minimum of 150,000 and a maximum of 1,500,000 sats."
|
||||
auto-generate
|
||||
@close="showFundModal = false"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
@@ -161,15 +223,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||
import { goalStepRouteOverride } from './goals/goalStepActions'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
bitcoinSyncPercent,
|
||||
bitcoinBlockHeight,
|
||||
bitcoinSyncEtaText,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
@@ -185,10 +258,27 @@ const APP_ICON_MAP: Record<string, string> = {
|
||||
}
|
||||
|
||||
function stepIconUrl(step: GoalStep): string | undefined {
|
||||
if (step.icon) return step.icon
|
||||
if (!step.appId) return undefined
|
||||
return APP_ICON_MAP[step.appId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the completion card sends the user: the app they just set up, not the
|
||||
* generic services list. `launchAppId` opens via the app launcher (iframe apps
|
||||
* overlay on top of the current screen; X-Frame-Options apps open a tab).
|
||||
*/
|
||||
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
|
||||
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
|
||||
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
|
||||
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
|
||||
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
|
||||
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
|
||||
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
|
||||
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
|
||||
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -214,7 +304,9 @@ const completedSteps = computed(() => {
|
||||
if (!goal.value) return new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
for (const step of goal.value.steps) {
|
||||
if (step.appId && isAppInstalled(step.appId)) {
|
||||
// Only install steps auto-tick from package state — manual steps (fund the
|
||||
// wallet, open a channel, configure) must be walked through.
|
||||
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
|
||||
@@ -306,9 +398,16 @@ async function installApp(step: GoalStep) {
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
const targetPath = goalStepTargetPath(step)
|
||||
if (targetPath) {
|
||||
router.push(targetPath)
|
||||
const override = goalStepRouteOverride(step)
|
||||
if (override) {
|
||||
// Internal screens (channels, web5, settings) — tag where we came from so
|
||||
// their back button returns to this wizard.
|
||||
router.push({ path: override, query: { from: 'goal', goal: goalId.value } })
|
||||
} else if (step.appId) {
|
||||
// Launch the app itself: iframe apps overlay on top of the wizard,
|
||||
// tab-only apps open a tab (mobile: the in-app browser) — the app
|
||||
// launcher handles every case.
|
||||
useAppLauncherStore().openSession(step.appId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +428,71 @@ function ensureGoalStarted() {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/dashboard')
|
||||
// The goal cards live on Home's Setup tab — return there, not the dashboard.
|
||||
router.push({ path: '/dashboard', query: { tab: 'setup' } })
|
||||
}
|
||||
|
||||
// ── Fund-wallet step: live sync status + on-chain balance ───────────────────
|
||||
|
||||
const showFundModal = ref(false)
|
||||
const walletOnchainSats = ref(0)
|
||||
|
||||
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
|
||||
const fundStepActive = computed(() => {
|
||||
if (!goal.value || !hasFundStep.value) return false
|
||||
const active = goal.value.steps[activeStepIndex.value]
|
||||
return active?.action === 'fund' && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
let releaseSync: (() => void) | null = null
|
||||
let balanceTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshWalletBalance() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 8000 })
|
||||
walletOnchainSats.value = res.balance_sats || 0
|
||||
} catch { /* LND not up yet — balance stays at last known value */ }
|
||||
}
|
||||
|
||||
watch(fundStepActive, (active) => {
|
||||
if (active) {
|
||||
if (!releaseSync) releaseSync = acquireBitcoinSync()
|
||||
void refreshWalletBalance()
|
||||
if (!balanceTimer) balanceTimer = setInterval(() => void refreshWalletBalance(), 15000)
|
||||
} else {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Refresh the balance right after the receive modal closes — the user may
|
||||
// have just sent funds.
|
||||
watch(showFundModal, (open) => { if (!open) void refreshWalletBalance() })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
})
|
||||
|
||||
function completeFundStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
// ── Completion CTA: go to the app you just set up ────────────────────────────
|
||||
|
||||
const completionCta = computed(() => (goal.value ? GOAL_COMPLETION_CTA[goal.value.id] : undefined))
|
||||
|
||||
function openCompletionTarget() {
|
||||
const cta = completionCta.value
|
||||
if (!cta) return
|
||||
if (cta.launchAppId) {
|
||||
// Iframe apps overlay on top of the current screen; X-Frame-Options apps
|
||||
// (BTCPay, Nextcloud…) open in a new tab.
|
||||
useAppLauncherStore().openSession(cta.launchAppId)
|
||||
} else if (cta.route) {
|
||||
router.push(cta.route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
@@ -315,9 +315,15 @@ import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const uiMode = useUIModeStore()
|
||||
const isDev = import.meta.env.DEV
|
||||
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
|
||||
// ?tab=setup lands on the Setup tab (e.g. "Back to Goals" from a goal wizard)
|
||||
const homeTab = ref<'dashboard' | 'setup'>(route.query.tab === 'setup' ? 'setup' : 'dashboard')
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'setup') homeTab.value = 'setup'
|
||||
else if (tab === 'dashboard') homeTab.value = 'dashboard'
|
||||
})
|
||||
const topGoals = GOALS.slice(0, 3)
|
||||
|
||||
const QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))]
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keydown.enter="confirmPasswordInputRef?.focus()"
|
||||
@@ -83,6 +84,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keydown.enter="handleSetupWithSound"
|
||||
@@ -127,6 +129,7 @@
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
data-controller-no-submit
|
||||
:aria-label="t('login.totpLabel')"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@@ -165,6 +168,12 @@
|
||||
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All auth inputs opt out of controller-nav's Enter→click-next-button
|
||||
pattern (data-controller-no-submit): they submit via their own Enter
|
||||
handlers, and while the submit button is still disabled the "next
|
||||
focusable" is Replay Intro — the companion's auto-login injects
|
||||
Enter before Vue re-enables the button, which replayed the intro
|
||||
in a loop on every app connect. -->
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
@@ -175,6 +184,7 @@
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keydown.enter="handleLoginWithSound"
|
||||
|
||||
@@ -691,20 +691,24 @@ video.bg-layer {
|
||||
why the kiosk login/onboarding background still went black. Keep 2D
|
||||
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
|
||||
glitch overlays. */
|
||||
:global(html.kiosk-mode) .bg-perspective-container,
|
||||
:global(html.kiosk-mode) .perspective-container {
|
||||
/* The full selector must live inside :global() — with `:global(html.kiosk-mode)
|
||||
.bg-layer` the SFC compiler drops the descendant part, emitting bare
|
||||
`html.kiosk-mode { display: none !important }` rules that blank the whole
|
||||
document on kiosk (the v1.7.104 white-screen). */
|
||||
:global(html.kiosk-mode .bg-perspective-container),
|
||||
:global(html.kiosk-mode .perspective-container) {
|
||||
perspective: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode) .bg-layer,
|
||||
:global(html.kiosk-mode) .view-wrapper {
|
||||
:global(html.kiosk-mode .bg-layer),
|
||||
:global(html.kiosk-mode .view-wrapper) {
|
||||
transform: none !important;
|
||||
transform-style: flat !important;
|
||||
backface-visibility: visible !important;
|
||||
will-change: auto !important;
|
||||
filter: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode) .login-glitch-layer,
|
||||
:global(html.kiosk-mode) .login-glitch-scan {
|
||||
:global(html.kiosk-mode .login-glitch-layer),
|
||||
:global(html.kiosk-mode .login-glitch-scan) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
|
||||
<p v-else class="text-sm text-white/50">Peer files</p>
|
||||
</div>
|
||||
<!-- Mobile: the title block above is hidden (the global header carries the
|
||||
peer name), so the transport pill would vanish with it. Render it on
|
||||
its own here so mobile also sees whether this peer is FIPS or Tor. -->
|
||||
<span
|
||||
v-if="transportPill"
|
||||
:class="transportPill.cls"
|
||||
:title="transportPill.title"
|
||||
class="md:hidden text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
>{{ transportPill.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
|
||||
<span class="text-white/80 text-sm">F*ck IPs Mesh</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
|
||||
</div>
|
||||
@@ -207,9 +207,13 @@
|
||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
||||
</div>
|
||||
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Device
|
||||
</button>
|
||||
<!-- mt-auto pins the action to the card bottom so buttons align across
|
||||
equal-height grid cards -->
|
||||
<div class="responsive-card-actions-bottom mt-auto pt-4">
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces (second column on desktop) -->
|
||||
@@ -273,13 +277,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@click="showWifiModal = true"
|
||||
class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
<div v-if="wifiAvailable" class="responsive-card-actions-bottom mt-auto pt-4">
|
||||
<button
|
||||
@click="showWifiModal = true"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- close VPN+Network 2-col grid -->
|
||||
|
||||
@@ -18,10 +18,8 @@
|
||||
let the app's own UI load instead of a loader stuck on top (B7). -->
|
||||
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
|
||||
</svg>
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
|
||||
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
@@ -119,6 +117,7 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-4">
|
||||
<!-- Back Button -->
|
||||
<button @click="router.replace('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to LND
|
||||
</button>
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
|
||||
|
||||
@@ -15,8 +9,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// When a setup wizard sent us here (?from=goal&goal=<id>), back returns to it.
|
||||
const fromGoalId = computed(() =>
|
||||
route.query.from === 'goal' && typeof route.query.goal === 'string' ? route.query.goal : null,
|
||||
)
|
||||
const backLabel = computed(() => (fromGoalId.value ? 'Back to Setup' : 'Back to LND'))
|
||||
|
||||
function goBack() {
|
||||
if (fromGoalId.value) {
|
||||
router.push(`/dashboard/goals/${fromGoalId.value}`)
|
||||
} else {
|
||||
router.replace('/dashboard/apps/lnd')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/bg-intro-4.webp"
|
||||
src="/assets/img/companion-banner-bg.webp"
|
||||
alt=""
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { goalStepTargetPath } from '../goalStepActions'
|
||||
import { goalStepRouteOverride } from '../goalStepActions'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
describe('goalStepActions', () => {
|
||||
it('routes app-backed steps to their app details page', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
|
||||
it('app-backed configure steps have no route override — they launch the app itself', () => {
|
||||
expect(goalStepRouteOverride(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBeNull()
|
||||
expect(goalStepRouteOverride(step({ id: 'configure-store', appId: 'btcpay-server' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('routes built-in identity and backup steps to their owning screens', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
|
||||
expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
|
||||
expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
|
||||
expect(goalStepRouteOverride(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
|
||||
expect(goalStepRouteOverride(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
|
||||
expect(goalStepRouteOverride(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
|
||||
})
|
||||
|
||||
it('routes channel steps to the Lightning channels screen', () => {
|
||||
expect(goalStepRouteOverride(step({ id: 'open-channel' }))).toBe('/dashboard/apps/lnd/channels')
|
||||
expect(goalStepRouteOverride(step({ id: 'open-channels' }))).toBe('/dashboard/apps/lnd/channels')
|
||||
expect(goalStepRouteOverride(step({ id: 'open-zeus-channel' }))).toBe('/dashboard/apps/lnd/channels')
|
||||
})
|
||||
|
||||
it('keeps passive info steps without a target route', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
|
||||
expect(goalStepRouteOverride(step({ id: 'sync-setup' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('gives every configure step in the shipped goals a destination', () => {
|
||||
it('gives every shipped configure step a destination — a route override or an app to launch', () => {
|
||||
const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
|
||||
|
||||
expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
|
||||
configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
|
||||
)
|
||||
for (const candidate of configureSteps) {
|
||||
const destination = goalStepRouteOverride(candidate) ?? candidate.appId ?? null
|
||||
expect(destination, `configure step ${candidate.id} has no destination`).not.toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
// Steps that land on an internal screen rather than launching an app UI.
|
||||
const STEP_ROUTE_OVERRIDES: Record<string, string> = {
|
||||
'setup-nostr': '/dashboard/web5',
|
||||
'export-identity': '/dashboard/web5/credentials',
|
||||
'create-passphrase': '/dashboard/settings',
|
||||
'create-backup': '/dashboard/settings',
|
||||
'save-backup': '/dashboard/settings',
|
||||
// Channel steps land directly on the Lightning channels screen (which
|
||||
// carries the "open a channel with Zeus" suggestion).
|
||||
'open-channel': '/dashboard/apps/lnd/channels',
|
||||
'open-channels': '/dashboard/apps/lnd/channels',
|
||||
'open-zeus-channel': '/dashboard/apps/lnd/channels',
|
||||
}
|
||||
|
||||
export function goalStepTargetPath(step: GoalStep): string | null {
|
||||
if (step.appId) return `/dashboard/apps/${step.appId}`
|
||||
/**
|
||||
* Internal route a step navigates to, or null when the step should launch its
|
||||
* app instead (via the app launcher — iframe apps overlay on top, tab-only
|
||||
* apps open a tab / the mobile in-app browser).
|
||||
*/
|
||||
export function goalStepRouteOverride(step: GoalStep): string | null {
|
||||
return STEP_ROUTE_OVERRIDES[step.id] ?? null
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
|
||||
<h2 class="text-xl font-semibold text-white">F*ck IPs Mesh</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
|
||||
@@ -30,44 +30,74 @@
|
||||
</svg>
|
||||
Refreshing Tor services...
|
||||
</div>
|
||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
||||
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
|
||||
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
|
||||
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
|
||||
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
|
||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
||||
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
|
||||
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
|
||||
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
|
||||
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
|
||||
</div>
|
||||
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
|
||||
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
|
||||
<p v-else class="text-white/30 text-xs">Disabled</p>
|
||||
</div>
|
||||
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
|
||||
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
|
||||
<p v-else class="text-white/30 text-xs">Disabled</p>
|
||||
<!-- Desktop: compact inline actions next to the toggle -->
|
||||
<div class="hidden md:flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="svc.name !== 'archipelago'"
|
||||
@click="$emit('deleteService', svc.name)"
|
||||
:disabled="torDeleting === svc.name"
|
||||
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
|
||||
:title="'Delete ' + svc.name + ' hidden service'"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
<ToggleSwitch class="shrink-0 md:hidden" :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
<!-- Mobile: actions in their own 50/50 row — Delete on the LEFT, far
|
||||
from the toggle above, so a rushed thumb can't hit the wrong control. -->
|
||||
<div
|
||||
v-if="svc.name !== 'archipelago' || (svc.onion_address && svc.enabled)"
|
||||
class="grid md:hidden grid-cols-2 gap-2 mt-3"
|
||||
>
|
||||
<button
|
||||
v-if="svc.name !== 'archipelago'"
|
||||
@click="$emit('deleteService', svc.name)"
|
||||
:disabled="torDeleting === svc.name"
|
||||
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
|
||||
class="glass-button py-2 rounded-lg text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
:class="{ 'col-span-2': !(svc.onion_address && svc.enabled) }"
|
||||
:title="'Delete ' + svc.name + ' hidden service'"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ torDeleting === svc.name ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button py-2 rounded-lg text-xs disabled:opacity-50"
|
||||
:class="{ 'col-span-2': svc.name === 'archipelago' }"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="responsive-card-actions-bottom-grid mt-4 grid-cols-2 gap-3">
|
||||
<div class="responsive-card-actions-bottom-grid mt-auto pt-4 grid-cols-2 gap-3">
|
||||
<button @click="$emit('restartTor')" :disabled="torRestarting" class="mobile-card-action glass-button rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
|
||||
</button>
|
||||
|
||||
@@ -362,6 +362,94 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.108-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.108-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.</p>
|
||||
<p>Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.</p>
|
||||
<p>Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.</p>
|
||||
<p>The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).</p>
|
||||
<p>More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.</p>
|
||||
<p>Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.106-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.106-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.</p>
|
||||
<p>On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.</p>
|
||||
<p>Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.</p>
|
||||
<p>When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.</p>
|
||||
<p>Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.105-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.105-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.</p>
|
||||
<p>Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.</p>
|
||||
<p>Fixed the white screen some laptop displays showed right after the intro on v1.7.104.</p>
|
||||
<p>The companion phone app no longer suggests installing the companion app from inside itself.</p>
|
||||
<p>The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.</p>
|
||||
<p>Running archipelago --version or --help on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.</p>
|
||||
<p>Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.104-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.104-alpha</span>
|
||||
<span class="text-xs text-white/40">July 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.</p>
|
||||
<p>If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.</p>
|
||||
<p>The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.</p>
|
||||
<p>While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.103-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.103-alpha</span>
|
||||
<span class="text-xs text-white/40">July 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.</p>
|
||||
<p>Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.</p>
|
||||
<p>The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.102-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.102-alpha</span>
|
||||
<span class="text-xs text-white/40">July 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.</p>
|
||||
<p>Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.</p>
|
||||
<p>Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.</p>
|
||||
<p>First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.</p>
|
||||
<p>The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.</p>
|
||||
<p>The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.</p>
|
||||
<p>Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.</p>
|
||||
<p>Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.</p>
|
||||
<p>Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.101-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -23,6 +25,10 @@ interface ServicePricing {
|
||||
accepted_mints: string[]
|
||||
}
|
||||
|
||||
// ── Tabs ──
|
||||
const tab = ref<'dashboard' | 'configure'>('dashboard')
|
||||
|
||||
// ── Configure state ──
|
||||
const services = ref<ServicePricing[]>([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
@@ -32,7 +38,7 @@ const statusIsError = ref(false)
|
||||
|
||||
// "Free everything" is the default — every service ships disabled. The banner
|
||||
// reassures the user nothing is being charged for until they opt in.
|
||||
const allFree = computed(() => services.value.every((s) => !s.enabled))
|
||||
const allFree = computed(() => services.value.every((s) => !s.enabled) && !tollgate.value?.enabled)
|
||||
|
||||
function showStatus(msg: string, isError: boolean) {
|
||||
statusMsg.value = msg
|
||||
@@ -113,86 +119,432 @@ async function saveService(svc: ServicePricing) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// ── TollGate (paid WiFi on the OpenWrt gateway) ──
|
||||
interface TollGateStatus {
|
||||
installed: boolean
|
||||
enabled?: boolean
|
||||
step_size_ms?: number
|
||||
price_per_step?: number
|
||||
min_steps?: number
|
||||
mint_url?: string
|
||||
}
|
||||
|
||||
const tollgate = ref<TollGateStatus | null>(null)
|
||||
const tollgateChecked = ref(false)
|
||||
const tollgateSaving = ref(false)
|
||||
const tollgatePrice = ref(10)
|
||||
const tollgateEnabled = ref(false)
|
||||
|
||||
const tollgateUnit = computed(() => {
|
||||
const ms = tollgate.value?.step_size_ms || 60_000
|
||||
if (ms === 3_600_000) return 'hour'
|
||||
if (ms === 60_000) return 'minute'
|
||||
if (ms === 1000) return 'second'
|
||||
return `${ms.toLocaleString()} ms`
|
||||
})
|
||||
|
||||
async function loadTollgate() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ tollgate?: TollGateStatus }>({ method: 'openwrt.get-status' })
|
||||
tollgate.value = res.tollgate || null
|
||||
tollgateEnabled.value = !!res.tollgate?.enabled
|
||||
tollgatePrice.value = Math.max(1, res.tollgate?.price_per_step || 10)
|
||||
} catch {
|
||||
// No gateway configured (or unreachable) — the card shows the setup hint.
|
||||
tollgate.value = null
|
||||
} finally {
|
||||
tollgateChecked.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTollgate() {
|
||||
if (tollgatePrice.value < 1) tollgatePrice.value = 1
|
||||
tollgateSaving.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'openwrt.provision-tollgate',
|
||||
params: {
|
||||
enabled: tollgateEnabled.value,
|
||||
// Real backend reads price_sats; the mock reads price_per_step —
|
||||
// send both so the same call works against either.
|
||||
price_sats: tollgatePrice.value,
|
||||
price_per_step: tollgatePrice.value,
|
||||
step_size_ms: tollgate.value?.step_size_ms || 60_000,
|
||||
min_steps: tollgate.value?.min_steps || 1,
|
||||
},
|
||||
})
|
||||
showStatus(
|
||||
tollgateEnabled.value
|
||||
? `TollGate WiFi is charging ${tollgatePrice.value} sats per ${tollgateUnit.value}.`
|
||||
: 'TollGate WiFi is now free.',
|
||||
false,
|
||||
)
|
||||
void loadTollgate()
|
||||
} catch (e) {
|
||||
showStatus(e instanceof Error ? e.message : 'Failed to save TollGate settings', true)
|
||||
} finally {
|
||||
tollgateSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard state ──
|
||||
type ProfitSource = 'content_sale' | 'routing_fee' | 'streaming_revenue'
|
||||
|
||||
interface ProfitEntry {
|
||||
source: ProfitSource
|
||||
amount_sats: number
|
||||
timestamp: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ProfitsSummary {
|
||||
total_sats: number
|
||||
content_sales_sats: number
|
||||
routing_fees_sats: number
|
||||
streaming_revenue_sats?: number
|
||||
recent?: ProfitEntry[]
|
||||
}
|
||||
|
||||
interface SessionsSummary {
|
||||
total_active: number
|
||||
total_revenue_sats: number
|
||||
revenue_by_service: Record<string, number>
|
||||
}
|
||||
|
||||
const profits = ref<ProfitsSummary | null>(null)
|
||||
const sessionsSummary = ref<SessionsSummary | null>(null)
|
||||
const dashboardLoading = ref(true)
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboardLoading.value = true
|
||||
try {
|
||||
const [p, s] = await Promise.all([
|
||||
rpcClient.call<ProfitsSummary>({ method: 'wallet.networking-profits' }).catch(() => null),
|
||||
rpcClient.call<SessionsSummary>({ method: 'streaming.list-sessions' }).catch(() => null),
|
||||
])
|
||||
profits.value = p
|
||||
sessionsSummary.value = s
|
||||
} finally {
|
||||
dashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSats(n: number | undefined | null): string {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
// 7 daily buckets, oldest → newest, one series per earning source. A week of
|
||||
// daily totals is the sweet spot here: earnings are sparse events (unlike the
|
||||
// second-by-second system metrics on Monitoring), so finer buckets would just
|
||||
// draw noise, and a longer window would flatten a new node's first sats.
|
||||
const DAY_MS = 86_400_000
|
||||
const WINDOW_DAYS = 7
|
||||
|
||||
const dayLabels = computed(() => {
|
||||
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const labels: string[] = []
|
||||
for (let i = WINDOW_DAYS - 1; i >= 0; i--) {
|
||||
labels.push(names[new Date(Date.now() - i * DAY_MS).getDay()] as string)
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
function bucketize(source: ProfitSource): number[] {
|
||||
const buckets = new Array(WINDOW_DAYS).fill(0)
|
||||
const now = Date.now()
|
||||
for (const e of profits.value?.recent || []) {
|
||||
if (e.source !== source) continue
|
||||
const t = Date.parse(e.timestamp)
|
||||
if (Number.isNaN(t)) continue
|
||||
const age = Math.floor((now - t) / DAY_MS)
|
||||
if (age < 0 || age >= WINDOW_DAYS) continue
|
||||
buckets[WINDOW_DAYS - 1 - age] += e.amount_sats
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
const earningsDatasets = computed<ChartDataset[]>(() => [
|
||||
{ label: 'Streaming', data: bucketize('streaming_revenue'), color: '#f97316' },
|
||||
{ label: 'Content sales', data: bucketize('content_sale'), color: '#3b82f6' },
|
||||
{ label: 'Routing fees', data: bucketize('routing_fee'), color: '#a78bfa' },
|
||||
])
|
||||
|
||||
const hasRecentEarnings = computed(() =>
|
||||
earningsDatasets.value.some((d) => d.data.some((v) => v > 0)),
|
||||
)
|
||||
|
||||
// Revenue-by-service bars, largest first, scaled to the biggest earner.
|
||||
const serviceBars = computed(() => {
|
||||
const by = sessionsSummary.value?.revenue_by_service || {}
|
||||
const entries = Object.entries(by).sort((a, b) => b[1] - a[1])
|
||||
const max = entries[0]?.[1] || 1
|
||||
return entries.map(([id, sats]) => ({
|
||||
id,
|
||||
name: services.value.find((s) => s.service_id === id)?.name || id,
|
||||
sats,
|
||||
pct: Math.max(4, Math.round((sats / max) * 100)),
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Chart sizing (same measure-the-card approach as Monitoring) ──
|
||||
const chartCard = ref<HTMLElement | null>(null)
|
||||
const chartWidth = ref(600)
|
||||
|
||||
function updateChartWidth() {
|
||||
chartWidth.value = Math.max(280, (chartCard.value?.clientWidth || 640) - 40)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadTollgate()
|
||||
void loadDashboard()
|
||||
updateChartWidth()
|
||||
window.addEventListener('resize', updateChartWidth)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateChartWidth)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits — Settings</h1>
|
||||
<p class="text-white/70">
|
||||
Control what your node charges other peers for. By default everything is shared for
|
||||
free — turn a service on to start earning sats (ecash) for it. Payments are collected
|
||||
as Cashu tokens through your node's wallet.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<h1 class="text-3xl font-bold text-white">Networking Profits</h1>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMsg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMsg }}
|
||||
<!-- Tabs: Dashboard | Configure -->
|
||||
<div class="flex gap-1 mb-6 border-b border-white/10">
|
||||
<button
|
||||
@click="tab = 'dashboard'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'configure'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="tab === 'configure' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Everything-free reassurance banner -->
|
||||
<div
|
||||
v-if="!loading && allFree"
|
||||
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-xl">✓</span>
|
||||
<p class="text-sm text-green-200">
|
||||
Everything is free. Your node isn't charging for anything — enable a service below to
|
||||
start earning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services…</div>
|
||||
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
|
||||
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
|
||||
<!-- ============ DASHBOARD ============ -->
|
||||
<div v-show="tab === 'dashboard'">
|
||||
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings…</div>
|
||||
<template v-else>
|
||||
<!-- Stat tiles -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats, all time</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
|
||||
{{ svc.enabled ? 'Paid' : 'Free' }}
|
||||
</span>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
|
||||
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats from paid services</p>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Content sales</p>
|
||||
<p class="text-2xl font-bold text-blue-400">{{ formatSats(profits?.content_sales_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats from ecash sales</p>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Routing fees</p>
|
||||
<p class="text-2xl font-bold text-violet-400">{{ formatSats(profits?.routing_fees_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats from Lightning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
|
||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="svc.price_per_step"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!svc.enabled"
|
||||
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
|
||||
<!-- Earnings chart -->
|
||||
<div ref="chartCard" class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h3 class="text-sm font-medium text-white/80">Earnings — last 7 days</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
|
||||
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
|
||||
{{ d.label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="saveService(svc)"
|
||||
:disabled="savingId === svc.service_id"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<LineChart
|
||||
v-if="hasRecentEarnings"
|
||||
:datasets="earningsDatasets"
|
||||
:labels="dayLabels"
|
||||
:width="chartWidth"
|
||||
:height="200"
|
||||
/>
|
||||
<div v-else class="py-10 text-center text-white/40 text-sm">
|
||||
No earnings in the last 7 days — enable a paid service under Configure to start earning.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions + revenue by service -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide mb-1">Active sessions</p>
|
||||
<p class="text-4xl font-bold text-white mb-1">{{ sessionsSummary?.total_active ?? 0 }}</p>
|
||||
<p class="text-xs text-white/40">peers currently paying for services</p>
|
||||
<p class="text-sm text-white/70 mt-4">
|
||||
Session revenue:
|
||||
<span class="text-white font-medium">{{ formatSats(sessionsSummary?.total_revenue_sats) }} sats</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="glass-card p-5 lg:col-span-2">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">Revenue by service</h3>
|
||||
<div v-if="serviceBars.length === 0" class="py-6 text-center text-white/40 text-sm">
|
||||
No service revenue yet.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="bar in serviceBars" :key="bar.id">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-white/80">{{ bar.name }}</span>
|
||||
<span class="text-white/50">{{ formatSats(bar.sats) }} sats</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-orange-500/70" :style="{ width: bar.pct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ============ CONFIGURE ============ -->
|
||||
<div v-show="tab === 'configure'">
|
||||
<!-- Intro copy, boxed so it reads as its own thing rather than page dressing -->
|
||||
<div class="glass-card p-4 mb-6">
|
||||
<p class="text-sm text-white/70 leading-relaxed">
|
||||
Control what your node charges other peers for. By default everything is shared for
|
||||
free — turn a service on to start earning sats (ecash) for it. Payments are collected
|
||||
as Cashu tokens through your node's wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMsg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMsg }}
|
||||
</div>
|
||||
|
||||
<!-- Everything-free reassurance banner -->
|
||||
<div
|
||||
v-if="!loading && allFree"
|
||||
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-xl">✓</span>
|
||||
<p class="text-sm text-green-200">
|
||||
Everything is free. Your node isn't charging for anything — enable a service below to
|
||||
start earning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services…</div>
|
||||
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
|
||||
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
|
||||
{{ svc.enabled ? 'Paid' : 'Free' }}
|
||||
</span>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
|
||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="svc.price_per_step"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!svc.enabled"
|
||||
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
|
||||
</div>
|
||||
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="saveService(svc)"
|
||||
:disabled="savingId === svc.service_id"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TollGate: paid WiFi on the OpenWrt gateway -->
|
||||
<div v-if="tollgateChecked" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-white">TollGate WiFi</h2>
|
||||
<p class="text-sm text-white/60 mt-0.5">
|
||||
Sell WiFi access on your OpenWrt gateway — visitors pay per {{ tollgateUnit }} in ecash.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="tollgate?.installed" class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs" :class="tollgateEnabled ? 'text-orange-400' : 'text-white/40'">
|
||||
{{ tollgateEnabled ? 'Paid' : 'Free' }}
|
||||
</span>
|
||||
<ToggleSwitch :model-value="tollgateEnabled" @update:model-value="(v) => (tollgateEnabled = v)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tollgate?.installed" class="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !tollgateEnabled }">
|
||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="tollgatePrice"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!tollgateEnabled"
|
||||
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<span class="text-sm text-white/70">sats per {{ tollgateUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="saveTollgate"
|
||||
:disabled="tollgateSaving"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ tollgateSaving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<p class="text-sm text-white/50 flex-1">
|
||||
TollGate isn't set up yet — it needs an OpenWrt gateway paired with this node.
|
||||
</p>
|
||||
<button
|
||||
@click="router.push('/dashboard/server/openwrt')"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm shrink-0"
|
||||
>
|
||||
Set up gateway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+20
-24
@@ -1,36 +1,32 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.",
|
||||
"The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — \"Replay Intro\" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.",
|
||||
"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: the intro plays on every fresh visit, 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)."
|
||||
"Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.",
|
||||
"Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so \"scan, pick a network, type the password, connect\" works without reinstalling.",
|
||||
"Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.",
|
||||
"The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).",
|
||||
"More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.",
|
||||
"Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago",
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "4e9d66f583a6b6119e381782bbc378bfc26e20dc710bfda7b57b6569b4efbb48",
|
||||
"size_bytes": 50100808
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "665d3351980d820287e614b0fdf4916957c042110f1ffd7ef6e93e78766ffa2a",
|
||||
"size_bytes": 50346064
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago-frontend-1.7.101-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.101-alpha.tar.gz",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "67c19515f39d193089d3c73994b6d30571f12f840f1051bc2bdd61ff57b327d4",
|
||||
"size_bytes": 164830686
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "8fba3d3aeb2b506cb6a3b3102a07a1713ae7a2bd1ebd4fc734d83f83ba9a8aa8",
|
||||
"size_bytes": 174598175
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-15",
|
||||
"signature": "9b70df9dcac2d83989dab6092a1fad3587a641d6db0484a515462009431cff68c3b1ea625280a4cd3dc8d9f145bab1602a3404e7efd71ad7ff2d7e91089f3701",
|
||||
"release_date": "2026-07-20",
|
||||
"signature": "94e2d15488383858d41c90c49075476ce56e4fed8b5829b92f68b3889229e795f250a57e557049bb46561c7c252d5d30db442d014b796e76d7ea4fbd7215dc00",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.101-alpha"
|
||||
"version": "1.7.108-alpha"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"updated": "2026-07-10",
|
||||
"updated": "2026-07-18",
|
||||
"apps": {
|
||||
"adguardhome": {
|
||||
"version": "v0.107.55",
|
||||
@@ -333,6 +333,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"barkd": {
|
||||
"version": "0.3.0",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"id": "barkd",
|
||||
"name": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"container": {
|
||||
"image": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
|
||||
"pull_policy": "if-not-present",
|
||||
"network": "archy-net",
|
||||
"generated_secrets": [
|
||||
{
|
||||
"name": "barkd-secret",
|
||||
"kind": "hex32"
|
||||
}
|
||||
],
|
||||
"secret_env": [
|
||||
{
|
||||
"key": "BARKD_SECRET",
|
||||
"secret_file": "barkd-secret"
|
||||
}
|
||||
],
|
||||
"data_uid": "1000:1000"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 1,
|
||||
"memory_limit": "512Mi",
|
||||
"disk_limit": "1Gi"
|
||||
},
|
||||
"security": {
|
||||
"readonly_root": true,
|
||||
"network_policy": "bridge"
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"host": 3535,
|
||||
"container": 3535,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"type": "bind",
|
||||
"source": "/var/lib/archipelago/barkd",
|
||||
"target": "/data",
|
||||
"options": [
|
||||
"rw"
|
||||
]
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
"BARKD_DATADIR=/data",
|
||||
"BARKD_BIND_HOST=0.0.0.0",
|
||||
"BARKD_BIND_PORT=3535"
|
||||
],
|
||||
"health_check": {
|
||||
"type": "tcp",
|
||||
"endpoint": "localhost:3535",
|
||||
"interval": "30s",
|
||||
"timeout": "5s",
|
||||
"retries": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bitcoin-core": {
|
||||
"version": "latest",
|
||||
"manifest": {
|
||||
@@ -1085,7 +1157,7 @@
|
||||
"-lc"
|
||||
],
|
||||
"custom_args": [
|
||||
"export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/\"; exec electrumx_server"
|
||||
"for h in bitcoin-knots bitcoin-core; do if getent hosts \"$h\" >/dev/null 2>&1; then BTC_HOST=\"$h\"; break; fi; done; export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/\"; exec electrumx_server"
|
||||
],
|
||||
"secret_env": [
|
||||
{
|
||||
|
||||
+20
-24
@@ -1,36 +1,32 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.",
|
||||
"The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — \"Replay Intro\" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.",
|
||||
"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: the intro plays on every fresh visit, 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)."
|
||||
"Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.",
|
||||
"Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so \"scan, pick a network, type the password, connect\" works without reinstalling.",
|
||||
"Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.",
|
||||
"The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).",
|
||||
"More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.",
|
||||
"Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago",
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "4e9d66f583a6b6119e381782bbc378bfc26e20dc710bfda7b57b6569b4efbb48",
|
||||
"size_bytes": 50100808
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "665d3351980d820287e614b0fdf4916957c042110f1ffd7ef6e93e78766ffa2a",
|
||||
"size_bytes": 50346064
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.101-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.101-alpha/archipelago-frontend-1.7.101-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.101-alpha.tar.gz",
|
||||
"new_version": "1.7.101-alpha",
|
||||
"sha256": "67c19515f39d193089d3c73994b6d30571f12f840f1051bc2bdd61ff57b327d4",
|
||||
"size_bytes": 164830686
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "8fba3d3aeb2b506cb6a3b3102a07a1713ae7a2bd1ebd4fc734d83f83ba9a8aa8",
|
||||
"size_bytes": 174598175
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-15",
|
||||
"signature": "9b70df9dcac2d83989dab6092a1fad3587a641d6db0484a515462009431cff68c3b1ea625280a4cd3dc8d9f145bab1602a3404e7efd71ad7ff2d7e91089f3701",
|
||||
"release_date": "2026-07-20",
|
||||
"signature": "94e2d15488383858d41c90c49075476ce56e4fed8b5829b92f68b3889229e795f250a57e557049bb46561c7c252d5d30db442d014b796e76d7ea4fbd7215dc00",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.101-alpha"
|
||||
"version": "1.7.108-alpha"
|
||||
}
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# OTA crash-loop guard — runs as root from ExecStartPre=+- on archipelago.service.
|
||||
#
|
||||
# Covers the failure mode verify_pending_update() cannot: a freshly-applied
|
||||
# binary that can't even start (SEGV/ENOEXEC — e.g. the truncated 17MB binary
|
||||
# .198 installed on the v1.7.103 OTA, which crash-looped 236 times with a
|
||||
# perfectly good backup sitting in update-backup/). The in-binary probe never
|
||||
# runs because the binary never runs, so this guard counts start attempts from
|
||||
# outside and restores the backup binary once the new one has clearly failed.
|
||||
#
|
||||
# Scope is deliberately narrow: it acts ONLY while the post-OTA pending-verify
|
||||
# marker exists (written by apply_update just before the restart, deleted by
|
||||
# the new binary once it boots and passes its probes). A crash loop with no
|
||||
# marker is not an OTA gone wrong, and this script stays out of it.
|
||||
#
|
||||
# Always exits 0 — a guard must never be the reason the service can't start.
|
||||
|
||||
set -u
|
||||
|
||||
DATA_DIR=/var/lib/archipelago
|
||||
MARKER="$DATA_DIR/update-pending-verify.json"
|
||||
COUNT_FILE="$DATA_DIR/ota-crash-guard.count"
|
||||
BACKUP="$DATA_DIR/update-backup/archipelago"
|
||||
BINARY=/usr/local/bin/archipelago
|
||||
MAX_ATTEMPTS=5
|
||||
|
||||
log() {
|
||||
echo "$*" | systemd-cat -t ota-crash-guard -p warning 2>/dev/null || true
|
||||
}
|
||||
|
||||
# No pending OTA verification -> nothing to guard; clear any stale counter.
|
||||
if [ ! -f "$MARKER" ]; then
|
||||
rm -f "$COUNT_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count this start attempt. The counter only accumulates while the marker
|
||||
# exists; a healthy new binary deletes the marker on its first successful
|
||||
# boot, and the next start clears the counter above.
|
||||
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
|
||||
case "$count" in ''|*[!0-9]*) count=0 ;; esac
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$COUNT_FILE" 2>/dev/null || true
|
||||
|
||||
if [ "$count" -lt "$MAX_ATTEMPTS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP" ]; then
|
||||
log "OTA crash guard: $count failed start attempts but no backup binary at $BACKUP — cannot roll back"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Already restored (or the OTA never replaced the binary)? Don't loop.
|
||||
if cmp -s "$BACKUP" "$BINARY"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Restore via copy-to-temp + atomic rename; never truncate the live path.
|
||||
tmp="$BINARY.rollback.$$"
|
||||
if cp "$BACKUP" "$tmp" && chown root:root "$tmp" && chmod 755 "$tmp" && mv "$tmp" "$BINARY"; then
|
||||
# Leave a tombstone for the UI/logs instead of the marker so the restored
|
||||
# binary doesn't run the post-OTA probe against the rolled-back version.
|
||||
mv "$MARKER" "$DATA_DIR/update-rolled-back.json" 2>/dev/null || rm -f "$MARKER"
|
||||
rm -f "$COUNT_FILE"
|
||||
log "OTA crash guard: restored previous binary after $count failed start attempts of the updated one"
|
||||
else
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
log "OTA crash guard: failed to restore backup binary (cp/mv error)"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -103,6 +103,23 @@ for f in /usr/local/bin/archipelago \
|
||||
fi
|
||||
done
|
||||
|
||||
# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart
|
||||
# payload. v1.7.104 shipped nostr-relay.service without its binary (3s
|
||||
# crash-loop forever) and archipelago-diag.service without its script
|
||||
# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule.
|
||||
for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do
|
||||
[ -f "$unit" ] || continue
|
||||
systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue
|
||||
exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}')
|
||||
case "$exec_bin" in
|
||||
/*) if [ -e "$exec_bin" ]; then
|
||||
pass "Unit payload exists: $(basename "$unit") → $exec_bin"
|
||||
else
|
||||
fail "Unit payload missing" "$(basename "$unit") → $exec_bin"
|
||||
fi ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1.2 — Critical services active
|
||||
for svc in archipelago nginx; do
|
||||
if systemctl is-active "$svc" >/dev/null 2>&1; then
|
||||
|
||||
Reference in New Issue
Block a user