Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06feb85aa5 | ||
|
|
aa0677be57 | ||
|
|
974fce5870 | ||
|
|
682b93f2d6 | ||
|
|
18f0929614 | ||
|
|
1709149ebd | ||
|
|
2664074210 | ||
|
|
9868991900 | ||
|
|
0d15ca588a | ||
|
|
1c1416cc1a | ||
|
|
1735098d81 | ||
|
|
b5da6875d7 | ||
|
|
4b6a088e38 | ||
|
|
f8304aed90 | ||
|
|
40f76013dc | ||
|
|
4e2c6d210b | ||
|
|
7d8ddcccef | ||
|
|
f853d14421 | ||
|
|
f2360d570f | ||
|
|
749234b8b0 | ||
|
|
be8e5ee46b | ||
|
|
687c216e65 | ||
|
|
26630e5ffd | ||
|
|
fee690744d | ||
|
|
79f4ec4bde | ||
|
|
61bfdac7cc | ||
|
|
1e648800ad | ||
|
|
8a2cabdba9 | ||
|
|
4d1bd063e8 |
@@ -74,12 +74,38 @@ jobs:
|
||||
|
||||
- name: Include AIUI if available
|
||||
run: |
|
||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
mkdir -p web/dist/neode-ui/aiui
|
||||
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
|
||||
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
|
||||
# AIUI (the Claude chat sidebar) lives outside the Vue build
|
||||
# and must be copied into the frontend dist BEFORE packaging,
|
||||
# otherwise OTA-tarball upgrades silently strip it from nodes
|
||||
# in the field. Try in order: cached on runner, then the
|
||||
# newest release tarball in this repo's releases/ dir as a
|
||||
# fallback so a freshly-provisioned runner still gets AIUI.
|
||||
AIUI_SRC=""
|
||||
if [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
AIUI_SRC="/opt/archipelago/web-ui/aiui"
|
||||
elif [ -f "$HOME/archy/web/dist/neode-ui/aiui/index.html" ]; then
|
||||
AIUI_SRC="$HOME/archy/web/dist/neode-ui/aiui"
|
||||
else
|
||||
echo "WARNING: AIUI not found on build server — ISO will not include AIUI"
|
||||
LATEST_FRONTEND=$(ls -t releases/v*/archipelago-frontend-*.tar.gz 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST_FRONTEND" ]; then
|
||||
echo "Extracting AIUI from $LATEST_FRONTEND (runner cache miss)"
|
||||
TMP=$(mktemp -d)
|
||||
tar xzf "$LATEST_FRONTEND" -C "$TMP" ./aiui 2>/dev/null || true
|
||||
if [ -f "$TMP/aiui/index.html" ]; then
|
||||
AIUI_SRC="$TMP/aiui"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$AIUI_SRC" ]; then
|
||||
mkdir -p web/dist/neode-ui/aiui
|
||||
cp -r "$AIUI_SRC/"* web/dist/neode-ui/aiui/
|
||||
echo "AIUI included from $AIUI_SRC ($(du -sh web/dist/neode-ui/aiui | cut -f1))"
|
||||
else
|
||||
echo "FAIL: AIUI not found anywhere (runner cache + release tarballs)"
|
||||
echo " checked: /opt/archipelago/web-ui/aiui"
|
||||
echo " \$HOME/archy/web/dist/neode-ui/aiui"
|
||||
echo " releases/v*/archipelago-frontend-*.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure root podman for insecure registry
|
||||
@@ -93,7 +119,15 @@ jobs:
|
||||
run: |
|
||||
cd image-recipe
|
||||
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||
if [ ! -x "$ARCHIPELAGO_BIN" ]; then
|
||||
echo "FAIL: backend binary missing or not executable at $ARCHIPELAGO_BIN"
|
||||
exit 1
|
||||
fi
|
||||
BIN_VERSION=$(strings "$ARCHIPELAGO_BIN" | grep -oE 'archipelago [0-9]+\.[0-9]+\.[0-9]+(-[a-z]+)?' | head -1 || true)
|
||||
EXPECTED=$(grep '^version' ../core/archipelago/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
echo "Binary: $ARCHIPELAGO_BIN ($(du -h "$ARCHIPELAGO_BIN" | cut -f1))"
|
||||
echo "Embedded version string: ${BIN_VERSION:-unknown}"
|
||||
echo "Expected version (Cargo.toml): $EXPECTED"
|
||||
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||
./build-auto-installer-iso.sh
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.5-alpha"
|
||||
version = "1.7.34-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.5-alpha"
|
||||
version = "1.7.34-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -113,6 +113,53 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side fetch of the upstream app catalog so the browser can
|
||||
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). Tries the
|
||||
/// upstream URLs in the same order the frontend used, returns the
|
||||
/// first 2xx response. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
|
||||
const UPSTREAMS: &[&str] = &[
|
||||
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
];
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(build_response(
|
||||
hyper::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"text/plain",
|
||||
hyper::Body::from(format!("client build failed: {}", e)),
|
||||
));
|
||||
}
|
||||
};
|
||||
for url in UPSTREAMS {
|
||||
match client.get(*url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
return Ok(Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| {
|
||||
Response::new(hyper::Body::from("proxy response build failed"))
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(build_response(
|
||||
hyper::StatusCode::BAD_GATEWAY,
|
||||
"text/plain",
|
||||
hyper::Body::from("all upstream catalog URLs failed"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
@@ -352,6 +399,18 @@ impl ApiHandler {
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// the logged-in node owner can spin up fetches.
|
||||
(Method::GET, "/api/app-catalog") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
Self::handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
|
||||
@@ -220,6 +220,7 @@ impl RpcHandler {
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
"registry.remove" => self.handle_registry_remove(params).await,
|
||||
"registry.set-primary" => self.handle_registry_set_primary(params).await,
|
||||
"registry.test" => self.handle_registry_test(params).await,
|
||||
|
||||
// Streaming ecash payments
|
||||
@@ -413,12 +414,41 @@ impl RpcHandler {
|
||||
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
"fips.restart" => self.handle_fips_restart().await,
|
||||
"fips.reconnect" => self.handle_fips_reconnect().await,
|
||||
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
|
||||
"fips.add-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_add_seed_anchor(&p).await
|
||||
}
|
||||
"fips.remove-seed-anchor" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_remove_seed_anchor(&p).await
|
||||
}
|
||||
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
"update.status" => self.handle_update_status().await,
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||
"update.list-mirrors" => self.handle_update_list_mirrors().await,
|
||||
"update.add-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_add_mirror(&p).await
|
||||
}
|
||||
"update.remove-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_remove_mirror(&p).await
|
||||
}
|
||||
"update.set-primary-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_primary_mirror(&p).await
|
||||
}
|
||||
"update.test-mirror" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_test_mirror(&p).await
|
||||
}
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -79,6 +79,7 @@ impl RpcHandler {
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
let local_name = data.server_info.name.clone();
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
@@ -90,6 +91,7 @@ impl RpcHandler {
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|data| node_identity.sign(data),
|
||||
)
|
||||
.await?;
|
||||
@@ -447,6 +449,38 @@ impl RpcHandler {
|
||||
.get("fips_npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// Optional, unsigned: peer's display name. Display-only — identity
|
||||
// claims are anchored on the signed did/pubkey below.
|
||||
let incoming_name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Reject self-peering. If somehow our own did / onion / pubkey
|
||||
// comes back at us (misconfigured invite, gossip loop), adding
|
||||
// the entry causes sync loops where the node syncs with itself
|
||||
// forever. Drop it quietly — no useful recovery path.
|
||||
let (own_data, _) = self.state_manager.get_snapshot().await;
|
||||
let own_did_result =
|
||||
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_onion_trim = own_data
|
||||
.server_info
|
||||
.tor_address
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches(".onion")
|
||||
.to_string();
|
||||
let incoming_onion_trim = onion.trim_end_matches(".onion");
|
||||
if own_did_result.as_deref() == Some(did)
|
||||
|| pubkey == own_data.server_info.pubkey
|
||||
|| (!own_onion_trim.is_empty() && own_onion_trim == incoming_onion_trim)
|
||||
{
|
||||
tracing::warn!(
|
||||
peer_did = %did,
|
||||
"Rejected peer-joined: inbound identity matches this node"
|
||||
);
|
||||
anyhow::bail!("Refusing to peer with self");
|
||||
}
|
||||
|
||||
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||
let signature = params.get("signature").and_then(|v| v.as_str());
|
||||
@@ -471,11 +505,12 @@ impl RpcHandler {
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
||||
// If already known but missing onion/pubkey/fips_npub, update them
|
||||
// If already known but missing onion/pubkey/fips_npub/name, update them
|
||||
let needs_onion = existing.onion.is_empty();
|
||||
let needs_pubkey = existing.pubkey.is_empty();
|
||||
let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some();
|
||||
if needs_onion || needs_pubkey || needs_fips {
|
||||
let needs_name = existing.name.is_none() && incoming_name.is_some();
|
||||
if needs_onion || needs_pubkey || needs_fips || needs_name {
|
||||
let mut updated = existing.clone();
|
||||
if needs_onion && !onion.is_empty() {
|
||||
updated.onion = onion.to_string();
|
||||
@@ -486,6 +521,9 @@ impl RpcHandler {
|
||||
if needs_fips {
|
||||
updated.fips_npub = fips_npub.clone();
|
||||
}
|
||||
if needs_name {
|
||||
updated.name = incoming_name.clone();
|
||||
}
|
||||
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||
federation::update_node(&self.config.data_dir, &updated).await?;
|
||||
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
|
||||
@@ -497,7 +535,7 @@ impl RpcHandler {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
name: incoming_name.clone(),
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
@@ -512,9 +550,38 @@ impl RpcHandler {
|
||||
|
||||
// Mirror into mesh state so the inbound peer is addressable from
|
||||
// the chat UI without waiting for the next mesh restart.
|
||||
self.register_federation_peer_in_mesh(pubkey, did, None)
|
||||
self.register_federation_peer_in_mesh(pubkey, did, incoming_name.as_deref())
|
||||
.await;
|
||||
|
||||
// Bump the data-model revision so any Federation view with an
|
||||
// open WebSocket reloads its node list without waiting for the
|
||||
// user to click Sync.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
// Transitive discovery: spawn a task that pulls the new peer's
|
||||
// state (its own federated peers end up as Observer entries on
|
||||
// our side) so after a join every existing peer in our list is
|
||||
// aware of the newcomer via the next pair of syncs, without the
|
||||
// user clicking anything. Best-effort; errors are logged only.
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let new_peer_did = did.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(
|
||||
&data_dir,
|
||||
&new_peer_did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
peer_did = %new_peer_did,
|
||||
error = %e,
|
||||
"Transitive sync on peer-joined failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "accepted": true }))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
@@ -36,12 +35,179 @@ impl RpcHandler {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
fips::config::install(&identity_dir).await?;
|
||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Restart whichever fips unit is supervising the daemon on this host.
|
||||
/// Nodes installed from the archipelago ISO use `archipelago-fips.service`;
|
||||
/// nodes that had the upstream debian package set up first may only have
|
||||
/// `fips.service`. We resolve the active one via `service::active_unit()`
|
||||
/// so the UI button is never a no-op.
|
||||
pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
|
||||
fips::service::restart(fips::SERVICE_UNIT).await?;
|
||||
Ok(serde_json::json!({ "restarted": true }))
|
||||
let unit = fips::service::active_unit().await;
|
||||
fips::service::restart(unit).await?;
|
||||
Ok(serde_json::json!({ "restarted": true, "unit": unit }))
|
||||
}
|
||||
|
||||
/// Full reconnect: stop the daemon, bring it back, wait for the DHT
|
||||
/// bootstrap window, poll the identity-cache + peer list, and
|
||||
/// classify what recovered (or didn't) so the UI can explain it to
|
||||
/// the user instead of showing a generic failure.
|
||||
///
|
||||
/// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
|
||||
pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let before = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
|
||||
// Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
|
||||
// mismatch. The daemon silently authenticates with a garbage
|
||||
// pubkey when the .pub file is 63-char text, which looks like
|
||||
// "anchor unreachable" to the user even though the real fault
|
||||
// was an identity malformed on the node itself. Re-install the
|
||||
// config + keys so /etc/fips gets the healed .pub.
|
||||
let key_src = identity_dir.join("fips_key");
|
||||
let pub_src = identity_dir.join("fips_key.pub");
|
||||
if key_src.exists() {
|
||||
let _ = fips::config::normalize_pub_file(&key_src, &pub_src).await;
|
||||
// Re-install refreshes /etc/fips/fips.pub from the healed
|
||||
// source. No-op if nothing changed.
|
||||
let _ = fips::config::install(&identity_dir).await;
|
||||
}
|
||||
|
||||
// Operate on whichever fips unit is actually up — nodes that
|
||||
// have the upstream `fips.service` rather than the
|
||||
// archipelago-managed `archipelago-fips.service` used to see
|
||||
// Reconnect silently fail because we stopped a unit that
|
||||
// didn't exist. Clean stop+start rather than `restart` so a
|
||||
// daemon that fails to come back up surfaces as
|
||||
// service_active=false instead of quietly sticking with the
|
||||
// old process.
|
||||
let unit = fips::service::active_unit().await;
|
||||
let _ = fips::service::stop(unit).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
|
||||
fips::service::activate(unit).await?;
|
||||
|
||||
// Re-push seed anchors after restart so freshly-bound daemons
|
||||
// don't have to wait 5 min for the periodic apply loop.
|
||||
if let Ok(list) = fips::anchors::load(&self.config.data_dir).await {
|
||||
if !list.is_empty() {
|
||||
let _ = fips::anchors::apply(&list).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let mut last_status: Option<fips::FipsStatus> = None;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected {
|
||||
last_status = Some(s);
|
||||
break;
|
||||
}
|
||||
last_status = Some(s);
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = last_status.unwrap_or_else(|| before.clone());
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
"connected"
|
||||
} else if !after.service_active {
|
||||
"daemon_down"
|
||||
} else if !after.key_present {
|
||||
"no_seed_key"
|
||||
} else if after.authenticated_peer_count == 0 {
|
||||
// Daemon is up with a key but hasn't authenticated any
|
||||
// peers — almost always outbound UDP/8668 dropped by the
|
||||
// local firewall/router, or the anchor itself being down.
|
||||
"no_outbound_udp_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
let hint = match likely_cause {
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_udp_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"recovered": recovered,
|
||||
"likely_cause": likely_cause,
|
||||
"hint": hint,
|
||||
"before": before,
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List the seed-anchor entries configured on this node.
|
||||
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Add (or update) a seed anchor and immediately push it into the
|
||||
/// running daemon. Params: `{ npub, address, transport?, label? }`.
|
||||
pub(super) async fn handle_fips_add_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
|
||||
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
|
||||
if !anchor.npub.starts_with("npub1") {
|
||||
anyhow::bail!("npub must be bech32 (npub1...)");
|
||||
}
|
||||
if !anchor.address.contains(':') {
|
||||
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
|
||||
}
|
||||
let list =
|
||||
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||
// Push just the newly-added anchor into the running daemon so
|
||||
// the user sees effect without waiting for the periodic apply.
|
||||
let results = fips::anchors::apply(&[anchor]).await;
|
||||
Ok(serde_json::json!({
|
||||
"seed_anchors": list,
|
||||
"apply": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
|
||||
/// down an already-authenticated peer connection — it only stops
|
||||
/// us from re-dialing the anchor on the next apply cycle.
|
||||
pub(super) async fn handle_fips_remove_seed_anchor(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let npub = params
|
||||
.get("npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
|
||||
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
|
||||
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||
}
|
||||
|
||||
/// Re-apply all seed anchors to the running daemon. Useful after a
|
||||
/// FIPS restart or when the user wants to force a reconnection
|
||||
/// attempt without waiting for the periodic apply loop.
|
||||
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||
let results = fips::anchors::apply(&list).await;
|
||||
Ok(serde_json::json!({
|
||||
"applied": results.len(),
|
||||
"results": results.iter().map(|r| {
|
||||
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ impl RpcHandler {
|
||||
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let local_name = data.server_info.name.clone();
|
||||
match crate::federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
invite_code,
|
||||
@@ -289,6 +290,7 @@ impl RpcHandler {
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -635,31 +635,32 @@ impl RpcHandler {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Single image pull attempt with progress streaming.
|
||||
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
||||
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
||||
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
/// Pull one image URL with live progress streamed through
|
||||
/// `update_install_progress`. Returns Ok(true) on a successful pull,
|
||||
/// Ok(false) on transient failure (so the caller can try the next
|
||||
/// mirror), Err only for unrecoverable setup errors.
|
||||
async fn pull_one_url_with_progress(
|
||||
&self,
|
||||
url: &str,
|
||||
tls_verify: bool,
|
||||
package_id: &str,
|
||||
user_tmp: &str,
|
||||
) -> Result<bool> {
|
||||
let mut pull_args = vec!["pull".to_string(), url.to_string()];
|
||||
if !tls_verify {
|
||||
pull_args.push("--tls-verify=false".to_string());
|
||||
}
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", docker_image])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.args(&pull_args)
|
||||
.env("TMPDIR", user_tmp)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
|
||||
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
|
||||
// to pull. 60s was too short and caused premature retries on slow registries.
|
||||
// 10-minute per-URL budget — large layers (Minio, Postgres,
|
||||
// ffmpeg) regularly take several minutes and we'd rather wait
|
||||
// than bounce to the next mirror mid-download.
|
||||
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
@@ -677,38 +678,115 @@ impl RpcHandler {
|
||||
})
|
||||
.await;
|
||||
|
||||
let primary_failed = match pull_result {
|
||||
Ok(Ok(status)) => !status.success(),
|
||||
match pull_result {
|
||||
Ok(Ok(status)) => Ok(status.success()),
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Image pull process error: {}", e);
|
||||
true
|
||||
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Image pull timed out after 60s: {}", docker_image);
|
||||
tracing::warn!("Image pull timed out after 600s: {}", url);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
true
|
||||
Ok(false)
|
||||
}
|
||||
};
|
||||
if primary_failed {
|
||||
// Try all configured fallback registries dynamically
|
||||
match crate::container::registry::pull_from_registries(
|
||||
&self.config.data_dir,
|
||||
docker_image,
|
||||
&user_tmp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull a container image, trying each configured registry in
|
||||
/// priority order and streaming progress during every attempt. The
|
||||
/// primary is tried first; if it doesn't have the image (or 404's),
|
||||
/// the next mirror is tried — with its own progress streaming, so
|
||||
/// the UI doesn't freeze at 0% after a primary miss. On success the
|
||||
/// image is tagged under `docker_image` so downstream commands
|
||||
/// (images -q, run -d, etc.) can find it by its canonical name.
|
||||
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
||||
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
||||
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
// Build the ordered candidate list: every enabled registry
|
||||
// (highest priority first), each rewriting the image URL to its
|
||||
// own origin. Deduplicate — two registries that happen to share
|
||||
// a URL should only be tried once.
|
||||
let config = crate::container::registry::load_registries(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut tried: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut candidates: Vec<(String, bool)> = Vec::new();
|
||||
for reg in config.active_registries() {
|
||||
let url = config.rewrite_image(docker_image, reg);
|
||||
if tried.insert(url.clone()) {
|
||||
candidates.push((url, reg.tls_verify));
|
||||
}
|
||||
}
|
||||
// If no registries are configured, fall back to the literal URL.
|
||||
if candidates.is_empty() {
|
||||
candidates.push((docker_image.to_string(), true));
|
||||
}
|
||||
|
||||
// Walk candidates, streaming progress for each attempt.
|
||||
let mut pulled_url: Option<String> = None;
|
||||
let attempts = candidates.len();
|
||||
for (i, (url, tls_verify)) in candidates.iter().enumerate() {
|
||||
if url != docker_image {
|
||||
debug!("Attempt {}/{}: {}", i + 1, attempts, url);
|
||||
} else {
|
||||
debug!("Attempt {}/{}: {} (literal)", i + 1, attempts, url);
|
||||
}
|
||||
// Reset progress at the top of each attempt so the UI reflects
|
||||
// the fresh pull instead of showing stale bytes from a prior
|
||||
// partial attempt.
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
match self
|
||||
.pull_one_url_with_progress(url, *tls_verify, package_id, &user_tmp)
|
||||
.await?
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!("Pulled {} via dynamic registry fallback", docker_image);
|
||||
true => {
|
||||
tracing::info!("Pulled {} from {}", docker_image, url);
|
||||
pulled_url = Some(url.clone());
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Image pull failed: {}", e));
|
||||
false => {
|
||||
tracing::debug!(
|
||||
"Pull attempt {}/{} failed for {}, trying next mirror",
|
||||
i + 1,
|
||||
attempts,
|
||||
url
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify image exists locally after pull
|
||||
let Some(pulled_url) = pulled_url else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Image pull failed from all {} configured registries for {}",
|
||||
attempts,
|
||||
docker_image
|
||||
));
|
||||
};
|
||||
|
||||
// Tag under the original docker_image reference if the successful
|
||||
// pull came from a rewritten URL — downstream code (images -q,
|
||||
// run -d docker_image, etc.) needs to find it by its canonical
|
||||
// name regardless of which mirror actually served the bytes.
|
||||
if pulled_url != docker_image {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["tag", &pulled_url, docker_image])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// Verify image exists locally after pull.
|
||||
let verify = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
@@ -1467,6 +1545,36 @@ server {
|
||||
Ok(serde_json::json!({ "registries": config.registries, "removed": url }))
|
||||
}
|
||||
|
||||
/// Promote a registry to primary by resetting priorities — the named
|
||||
/// URL becomes priority 0, every other enabled registry is bumped up
|
||||
/// by 10. Order is stable (ties broken by original priority).
|
||||
pub(in crate::api::rpc) async fn handle_registry_set_primary(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
|
||||
|
||||
let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?;
|
||||
if !config.registries.iter().any(|r| r.url == url) {
|
||||
return Err(anyhow::anyhow!("Registry '{}' not found", url));
|
||||
}
|
||||
|
||||
// Reassign priorities: target = 0, everyone else = 10, 20, 30…
|
||||
// in their existing priority order.
|
||||
let target_url = url.to_string();
|
||||
config.registries.sort_by_key(|r| (r.url != target_url, r.priority));
|
||||
for (i, r) in config.registries.iter_mut().enumerate() {
|
||||
r.priority = if r.url == target_url { 0 } else { (i as u32) * 10 };
|
||||
}
|
||||
|
||||
crate::container::registry::save_registries(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "registries": config.registries, "primary": url }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_registry_test(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -1481,10 +1589,16 @@ server {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
// Registries are configured as `host[:port]/namespace` (for
|
||||
// example `23.182.128.160:3000/lfg2025`), but the Docker V2
|
||||
// registry API lives at `/v2/` on the ROOT of the host — NOT
|
||||
// under the namespace. Strip the namespace before appending
|
||||
// `/v2/` so the reachability probe hits the correct URL.
|
||||
let host = url.split('/').next().unwrap_or(url);
|
||||
let test_url = if tls_verify {
|
||||
format!("https://{}/v2/", url)
|
||||
format!("https://{}/v2/", host)
|
||||
} else {
|
||||
format!("http://{}/v2/", url)
|
||||
format!("http://{}/v2/", host)
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -1496,8 +1610,11 @@ server {
|
||||
match client.get(&test_url).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
// 200 = open registry, 401 = auth required (both mean it exists)
|
||||
let reachable = status == 200 || status == 401;
|
||||
// Accept any "the server responded to HTTP" code as
|
||||
// reachable — 200 (open registry), 401 (auth required),
|
||||
// or 405 (server rejects GET but the endpoint exists)
|
||||
// all mean the registry daemon is alive on the host.
|
||||
let reachable = status == 200 || status == 401 || status == 405;
|
||||
Ok(serde_json::json!({
|
||||
"url": url,
|
||||
"reachable": reachable,
|
||||
|
||||
@@ -28,6 +28,18 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the uninstall stage label so the UI can show what's happening
|
||||
/// instead of a generic spinner. Each call broadcasts a state change
|
||||
/// — call sparingly (one per pipeline phase, not per container).
|
||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
@@ -81,6 +93,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,28 @@ impl RpcHandler {
|
||||
if containers_to_remove.is_empty() {
|
||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||
}
|
||||
let total = containers_to_remove.len();
|
||||
|
||||
let mut stopped = 0u32;
|
||||
let mut removed = 0u32;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for name in &containers_to_remove {
|
||||
self.set_uninstall_stage(
|
||||
package_id,
|
||||
&if total > 0 {
|
||||
format!("Stopping containers (0/{})", total)
|
||||
} else {
|
||||
"Cleaning up".to_string()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
for (i, name) in containers_to_remove.iter().enumerate() {
|
||||
self.set_uninstall_stage(
|
||||
package_id,
|
||||
&format!("Stopping containers ({}/{})", i + 1, total),
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||
@@ -326,6 +342,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
self.set_uninstall_stage(package_id, "Cleaning up volumes").await;
|
||||
// Clean up dangling volumes associated with removed containers
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
@@ -354,6 +371,7 @@ impl RpcHandler {
|
||||
|
||||
// Clean data directories unless preserve_data
|
||||
if !preserve_data {
|
||||
self.set_uninstall_stage(package_id, "Removing app data").await;
|
||||
let data_dirs = get_data_dirs_for_app(package_id);
|
||||
for dir in &data_dirs {
|
||||
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||
|
||||
@@ -937,6 +937,31 @@ impl RpcHandler {
|
||||
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
|
||||
}
|
||||
|
||||
// Remove any leftover containers from a previous partial install (or
|
||||
// from the first-boot frontend stub that used to race the installer).
|
||||
// Without this, `podman run --name indeedhub` fails on name conflict
|
||||
// and the whole stack install errors out — leaving a half-broken node.
|
||||
for name in [
|
||||
"indeedhub",
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub-build_api_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "rm", "-f", "indeedhub-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
// Create indeedhub-net
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "indeedhub-net"])
|
||||
|
||||
@@ -6,12 +6,18 @@ impl RpcHandler {
|
||||
/// Check for available system updates.
|
||||
/// Tries git-based check first (if repo exists), falls back to manifest-based.
|
||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||
// Try git-based check first (preferred for beta nodes)
|
||||
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
|
||||
// the operator wants OTA via manifest — typically a dev box where
|
||||
// ~/archy/.git exists but isn't the intended update surface.
|
||||
// Without this short-circuit the dev box always advertises "Pull
|
||||
// & Rebuild" and can never exercise the manifest OTA path.
|
||||
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if repo_dir.join(".git").exists() {
|
||||
if !manifest_override && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
@@ -34,6 +40,7 @@ impl RpcHandler {
|
||||
"last_check": state.last_check,
|
||||
"update_available": update_info.is_some(),
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -151,6 +158,37 @@ impl RpcHandler {
|
||||
/// Get update status without checking remote.
|
||||
pub(super) async fn handle_update_status(&self) -> Result<serde_json::Value> {
|
||||
let state = update::get_status(&self.config.data_dir).await?;
|
||||
// Expose live download progress so the UI can resume the
|
||||
// progress bar after navigation instead of showing the fake
|
||||
// creep again. An RPC poll every ~1s during download drives a
|
||||
// real progress indicator that survives route changes.
|
||||
let downloaded = update::DOWNLOAD_BYTES
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = update::DOWNLOAD_TOTAL
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let active = total > 0 && downloaded < total;
|
||||
let completed = total > 0 && downloaded >= total;
|
||||
|
||||
// Stall detection: if the progress-at timestamp hasn't advanced
|
||||
// for 30+ seconds while active, the download is wedged (usually
|
||||
// HTTP stream silently dropped and reqwest is waiting out its
|
||||
// read timeout). The UI uses this to surface a Cancel button
|
||||
// with explanatory copy.
|
||||
let stalled = if active {
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if last_at > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(last_at) > 30_000
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
@@ -158,6 +196,15 @@ impl RpcHandler {
|
||||
"update_available": state.available_update.is_some(),
|
||||
"update_in_progress": state.update_in_progress,
|
||||
"rollback_available": state.rollback_available,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
"download_progress": if active || completed {
|
||||
Some(serde_json::json!({
|
||||
"bytes_downloaded": downloaded,
|
||||
"total_bytes": total,
|
||||
"active": active,
|
||||
"stalled": stalled,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -177,6 +224,13 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight or stuck download. Clears the live counters
|
||||
/// and staging dir so the UI returns to the "Download Update" state.
|
||||
pub(super) async fn handle_update_cancel_download(&self) -> Result<serde_json::Value> {
|
||||
update::cancel_download(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "canceled": true }))
|
||||
}
|
||||
|
||||
/// Apply the staged update.
|
||||
pub(super) async fn handle_update_apply(&self) -> Result<serde_json::Value> {
|
||||
update::apply_update(&self.config.data_dir).await?;
|
||||
@@ -189,6 +243,89 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "rolled_back": true, "restart_required": true }))
|
||||
}
|
||||
|
||||
/// List configured update mirrors in priority order.
|
||||
pub(super) async fn handle_update_list_mirrors(&self) -> Result<serde_json::Value> {
|
||||
let list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
|
||||
/// Duplicates (same URL) are replaced rather than added twice.
|
||||
pub(super) async fn handle_update_add_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
anyhow::bail!("url must start with http:// or https://");
|
||||
}
|
||||
let label = params
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
list.retain(|m| m.url != url);
|
||||
list.push(update::UpdateMirror { url, label });
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Remove a mirror by URL. Params: `{ url }`.
|
||||
pub(super) async fn handle_update_remove_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
list.retain(|m| m.url != url);
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Ping a mirror's manifest URL. Returns reachability, wall-clock
|
||||
/// latency, and HTTP status. Params: `{ url }`.
|
||||
pub(super) async fn handle_update_test_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let result = update::test_mirror(url).await;
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
/// Move a mirror to the top of the list so it's tried first.
|
||||
/// Params: `{ url }`.
|
||||
pub(super) async fn handle_update_set_primary_mirror(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
|
||||
let mut list = update::load_mirrors(&self.config.data_dir).await?;
|
||||
let Some(idx) = list.iter().position(|m| m.url == url) else {
|
||||
anyhow::bail!("mirror not in list");
|
||||
};
|
||||
let entry = list.remove(idx);
|
||||
list.insert(0, entry);
|
||||
update::save_mirrors(&self.config.data_dir, &list).await?;
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Get the current update schedule.
|
||||
pub(super) async fn handle_update_get_schedule(&self) -> Result<serde_json::Value> {
|
||||
let schedule = update::get_schedule(&self.config.data_dir).await?;
|
||||
|
||||
@@ -247,6 +247,7 @@ impl DockerPackageScanner {
|
||||
status: service_status,
|
||||
}),
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
};
|
||||
|
||||
packages.insert(app_id.clone(), package);
|
||||
|
||||
@@ -44,19 +44,26 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
name: "Archipelago Primary".to_string(),
|
||||
tls_verify: true,
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
name: "Server 1 (VPS)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
name: "Archipelago Fallback".to_string(),
|
||||
tls_verify: false,
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
name: "Server 2 (tx1138)".to_string(),
|
||||
tls_verify: true,
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
},
|
||||
Registry {
|
||||
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||
name: "Server 3 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -80,20 +87,6 @@ impl RegistryConfig {
|
||||
format!("{}/{}", registry.url, image_name)
|
||||
}
|
||||
|
||||
/// Generate fallback image URLs to try (excludes the original since it already failed).
|
||||
pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
// Rewrite for each active registry (skip if identical to original)
|
||||
for reg in self.active_registries() {
|
||||
let rewritten = self.rewrite_image(image, reg);
|
||||
if rewritten != image {
|
||||
candidates.push((rewritten, reg.tls_verify));
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the image name from a full image reference.
|
||||
@@ -104,7 +97,12 @@ fn extract_image_name(image: &str) -> &str {
|
||||
image.rsplit('/').next().unwrap_or(image)
|
||||
}
|
||||
|
||||
/// Load registry config from disk.
|
||||
/// Load registry config from disk, merging in any default registries
|
||||
/// that the operator hasn't explicitly removed. This lets us roll out
|
||||
/// new default mirrors (e.g. a new Server 3) to existing nodes without
|
||||
/// them having to edit their saved config. Explicit removals stick —
|
||||
/// if the URL is absent from disk AND absent from current defaults, it
|
||||
/// stays gone.
|
||||
pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
let path = data_dir.join(REGISTRY_FILE);
|
||||
if !path.exists() {
|
||||
@@ -113,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read registry config")?;
|
||||
let config: RegistryConfig =
|
||||
let mut config: RegistryConfig =
|
||||
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
|
||||
|
||||
// Migrate: any default registry URL that isn't already in the
|
||||
// saved list gets appended at the end (so existing priority order
|
||||
// is preserved for anything the operator already configured).
|
||||
let defaults = RegistryConfig::default();
|
||||
let known: std::collections::HashSet<String> =
|
||||
config.registries.iter().map(|r| r.url.clone()).collect();
|
||||
let max_priority = config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| r.priority)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut added = false;
|
||||
for (i, def) in defaults.registries.iter().enumerate() {
|
||||
if !known.contains(&def.url) {
|
||||
let mut cloned = def.clone();
|
||||
cloned.priority = max_priority.saturating_add(10 + i as u32);
|
||||
config.registries.push(cloned);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if added {
|
||||
// Persist so the next load doesn't have to re-merge.
|
||||
let _ = save_registries(data_dir, &config).await;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -133,69 +157,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try pulling an image from configured registries in priority order.
|
||||
/// Returns the image reference that succeeded.
|
||||
pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result<String> {
|
||||
let config = load_registries(data_dir).await?;
|
||||
let candidates = config.image_candidates(image);
|
||||
|
||||
for (candidate, tls_verify) in &candidates {
|
||||
debug!("Trying registry: {}", candidate);
|
||||
|
||||
let mut args = vec!["pull".to_string(), candidate.clone()];
|
||||
if !tls_verify {
|
||||
args.push("--tls-verify=false".to_string());
|
||||
}
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(&args)
|
||||
.env("TMPDIR", tmpdir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.ok();
|
||||
|
||||
let status = if let Some(ref mut c) = child {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(120), c.wait()).await {
|
||||
Ok(Ok(s)) => Some(s.success()),
|
||||
_ => {
|
||||
let _ = c.kill().await;
|
||||
let _ = c.wait().await;
|
||||
debug!("Fallback pull timed out: {}", candidate);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if status == Some(true) {
|
||||
// If we pulled from a non-original registry, tag it with the original name
|
||||
if candidate != image {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["tag", candidate, image])
|
||||
.status()
|
||||
.await;
|
||||
info!(
|
||||
"Pulled {} from fallback registry, tagged as {}",
|
||||
candidate, image
|
||||
);
|
||||
} else {
|
||||
info!("Pulled {} from primary registry", image);
|
||||
}
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
|
||||
debug!("Failed to pull from {}", candidate);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to pull {} from all {} configured registries",
|
||||
image,
|
||||
candidates.len()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -217,34 +178,29 @@ mod tests {
|
||||
#[test]
|
||||
fn test_rewrite_image() {
|
||||
let config = RegistryConfig::default();
|
||||
let fallback = &config.registries[1];
|
||||
// Default primary is now VPS (index 0). A tx1138-hardcoded image
|
||||
// rewrites to VPS when asked for the primary mirror.
|
||||
let primary = &config.registries[0];
|
||||
assert_eq!(
|
||||
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback),
|
||||
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
|
||||
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_candidates() {
|
||||
let config = RegistryConfig::default();
|
||||
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
assert!(candidates.len() >= 2);
|
||||
assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_registries_sorted() {
|
||||
let config = RegistryConfig::default();
|
||||
let active = config.active_registries();
|
||||
assert_eq!(active.len(), 2);
|
||||
assert_eq!(active.len(), 3);
|
||||
assert!(active[0].priority <= active[1].priority);
|
||||
assert!(active[1].priority <= active[2].priority);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = load_registries(tmp.path()).await.unwrap();
|
||||
assert_eq!(config.registries.len(), 2);
|
||||
assert_eq!(config.registries.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -260,6 +216,6 @@ mod tests {
|
||||
});
|
||||
save_registries(tmp.path(), &config).await.unwrap();
|
||||
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.registries.len(), 3);
|
||||
assert_eq!(loaded.registries.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,13 @@ pub struct PackageDataEntry {
|
||||
pub installed: Option<InstalledPackageDataEntry>,
|
||||
#[serde(rename = "install-progress")]
|
||||
pub install_progress: Option<InstallProgress>,
|
||||
/// Live label describing the current uninstall step ("Stopping
|
||||
/// containers (2/5)", "Removing data", …). Set by the uninstall
|
||||
/// pipeline so the UI can show real progress instead of a generic
|
||||
/// "Uninstalling…" spinner. Cleared after the package entry is
|
||||
/// removed.
|
||||
#[serde(rename = "uninstall-stage", skip_serializing_if = "Option::is_none", default)]
|
||||
pub uninstall_stage: Option<String>,
|
||||
/// Pinned image version from image-versions.sh when it differs from running version
|
||||
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
|
||||
pub available_update: Option<String>,
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn accept_invite(
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
local_fips_npub: Option<&str>,
|
||||
local_name: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<FederatedNode> {
|
||||
let ParsedInvite {
|
||||
@@ -131,6 +132,20 @@ pub async fn accept_invite(
|
||||
fips_npub,
|
||||
} = parse_invite(code)?;
|
||||
|
||||
// Refuse self-peering. If the invite's did / onion / pubkey matches
|
||||
// our own, adding it pollutes the federation list with a node that
|
||||
// sees itself as its own peer and causes sync loops. The user
|
||||
// almost certainly pasted the wrong invite.
|
||||
if did == local_did || pubkey == local_pubkey || {
|
||||
let a = onion.trim_end_matches(".onion");
|
||||
let b = local_onion.trim_end_matches(".onion");
|
||||
!a.is_empty() && a == b
|
||||
} {
|
||||
anyhow::bail!(
|
||||
"Refusing to federate with self — invite points at this node's own did / onion / pubkey"
|
||||
);
|
||||
}
|
||||
|
||||
// Make accept idempotent: drop any existing entry that conflicts with
|
||||
// this invite — same DID (same node, refreshing the link), same onion
|
||||
// (node rotated identity but kept its hidden service), or same pubkey
|
||||
@@ -190,6 +205,7 @@ pub async fn accept_invite(
|
||||
local_onion,
|
||||
local_pubkey,
|
||||
local_fips_npub,
|
||||
local_name,
|
||||
sign_fn,
|
||||
)
|
||||
.await;
|
||||
@@ -201,20 +217,22 @@ pub async fn accept_invite(
|
||||
/// Prefers FIPS (if the remote advertised an npub in their invite) and
|
||||
/// falls back to Tor. Signs the message with our ed25519 key so the
|
||||
/// remote peer can verify authenticity regardless of transport.
|
||||
async fn notify_join(
|
||||
pub(crate) async fn notify_join(
|
||||
remote_onion: &str,
|
||||
remote_fips_npub: Option<&str>,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
local_fips_npub: Option<&str>,
|
||||
local_name: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<()> {
|
||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||
// Signature domain intentionally unchanged — fips_npub is carried
|
||||
// as an unsigned informational field. The FIPS daemon's own Noise
|
||||
// handshake authenticates the actual transport session, so a
|
||||
// stripped/substituted npub here merely downgrades the path to Tor.
|
||||
// Signature domain intentionally unchanged — fips_npub + name are
|
||||
// carried as unsigned informational fields. Name is display-only
|
||||
// (any identity claim is anchored on the signed did/pubkey); the
|
||||
// FIPS daemon's own Noise handshake authenticates the transport
|
||||
// session regardless of the advertised npub.
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||
let signature = sign_fn(sign_data.as_bytes());
|
||||
|
||||
@@ -227,6 +245,9 @@ async fn notify_join(
|
||||
if let Some(npub) = local_fips_npub {
|
||||
params["fips_npub"] = serde_json::Value::String(npub.to_string());
|
||||
}
|
||||
if let Some(name) = local_name {
|
||||
params["name"] = serde_json::Value::String(name.to_string());
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
@@ -321,6 +342,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -355,6 +377,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -388,6 +411,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
@@ -400,6 +424,7 @@ mod tests {
|
||||
"local.onion",
|
||||
"localpub",
|
||||
None,
|
||||
None,
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -17,5 +17,5 @@ pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -82,9 +82,35 @@ pub async fn sync_with_peer(
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Convenience wrapper: look up a federated peer by DID, derive our
|
||||
/// own local_did / signing context from the node identity on disk, and
|
||||
/// call sync_with_peer. Used by transitive-discovery code paths where
|
||||
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
|
||||
/// follow-up task).
|
||||
pub async fn sync_with_peer_by_did(
|
||||
data_dir: &Path,
|
||||
peer_did: &str,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let nodes = super::storage::load_nodes(data_dir).await?;
|
||||
let peer = nodes
|
||||
.into_iter()
|
||||
.find(|n| n.did == peer_did)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
|
||||
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let local_pubkey_hex = node_identity.pubkey_hex();
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
|
||||
|
||||
sync_with_peer(data_dir, &peer, &local_did, |data| node_identity.sign(data)).await
|
||||
}
|
||||
|
||||
/// Merge peers advertised by a Trusted federated node into our own
|
||||
/// federation list. New peers are added at `Observer` trust (not
|
||||
/// Trusted — that requires a direct invite). Existing peers get their
|
||||
/// federation list. New peers are added at `Trusted` — hints only
|
||||
/// arrive from peers we already trust, and `build_local_state` only
|
||||
/// re-exports our Trusted list, so transitive membership carries the
|
||||
/// same trust the direct-invite path gives. Existing peers get their
|
||||
/// `fips_npub` refreshed if we hadn't learned it yet.
|
||||
///
|
||||
/// Peers we are (us) or that we already track by DID are skipped.
|
||||
@@ -118,7 +144,7 @@ async fn merge_transitive_peers(
|
||||
pubkey: hint.pubkey.clone(),
|
||||
onion: hint.onion.clone(),
|
||||
name: hint.name.clone(),
|
||||
trust_level: TrustLevel::Observer,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Seed-anchor management for FIPS bootstrap.
|
||||
//!
|
||||
//! A freshly-installed node can't reach the global mesh via npub
|
||||
//! routing until it's connected to at least one peer that's already in
|
||||
//! the DHT. Upstream `fips` solves this by dialing a public anchor
|
||||
//! (e.g. `fips.v0l.io`) on first start. That's a single point of
|
||||
//! failure and doesn't help nodes behind restrictive firewalls or
|
||||
//! intermittent networks — archipelago operators reported fresh
|
||||
//! installs failing to reach any public anchor.
|
||||
//!
|
||||
//! This module adds a local, operator-editable seed-anchor list. Each
|
||||
//! entry is a `{npub, address, transport}` triple that archipelago
|
||||
//! pushes into the running daemon via `fipsctl connect` on startup and
|
||||
//! periodically thereafter. If one anchor falls over, the next one
|
||||
//! seeds the DHT instead. A well-configured cluster (e.g. a VPS
|
||||
//! running fips in anchor mode + a couple of home nodes) stops
|
||||
//! depending on the global anchor entirely.
|
||||
//!
|
||||
//! The list is persisted at `<data_dir>/seed-anchors.json`. The
|
||||
//! archipelago service user owns that directory, so no sudo is needed
|
||||
//! to read or write it.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// On-disk filename under `data_dir/`.
|
||||
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
|
||||
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
|
||||
/// installs — the one the upstream daemon dials anyway. Operators can
|
||||
/// remove it from the UI once their own cluster has independent anchors.
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||
|
||||
/// 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).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SeedAnchor {
|
||||
/// Bech32 `npub1...` of the anchor's FIPS identity.
|
||||
pub npub: String,
|
||||
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
|
||||
pub address: String,
|
||||
/// Transport to use — almost always `"udp"`.
|
||||
#[serde(default = "default_transport")]
|
||||
pub transport: String,
|
||||
/// Human-readable note shown in the UI (e.g. "Home anchor", "VPS").
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
fn default_transport() -> String {
|
||||
"udp".to_string()
|
||||
}
|
||||
|
||||
fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_ANCHORS_FILE)
|
||||
}
|
||||
|
||||
/// Load the seed-anchor list. Returns an empty list if the file
|
||||
/// doesn't exist yet — a first-boot node with no operator config.
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(anchors)
|
||||
}
|
||||
|
||||
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||
/// crashed archipelago never leaves a half-written config.
|
||||
pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> {
|
||||
tokio::fs::create_dir_all(data_dir)
|
||||
.await
|
||||
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
|
||||
let path = anchors_path(data_dir);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?;
|
||||
tokio::fs::write(&tmp, json)
|
||||
.await
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
tokio::fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add (or update) one anchor, keyed by npub. Returns the resulting list.
|
||||
pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result<Vec<SeedAnchor>> {
|
||||
let mut list = load(data_dir).await?;
|
||||
if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) {
|
||||
*existing = anchor;
|
||||
} else {
|
||||
list.push(anchor);
|
||||
}
|
||||
save(data_dir, &list).await?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Remove an anchor by npub. Returns the resulting list.
|
||||
pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
let mut list = load(data_dir).await?;
|
||||
list.retain(|a| a.npub != npub);
|
||||
save(data_dir, &list).await?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Apply the seed anchors to the running FIPS daemon. For each entry,
|
||||
/// asks `fipsctl connect` to dial the peer. Errors are logged but don't
|
||||
/// fail the whole operation — a single unreachable anchor shouldn't
|
||||
/// block the others.
|
||||
///
|
||||
/// `fipsctl connect` is idempotent-ish: calling it for an already-
|
||||
/// connected peer is a no-op at the protocol layer, so re-applying on
|
||||
/// a timer is safe. Returns a list of per-anchor results for logging.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("fipsctl")
|
||||
.args([
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
Ok(o) if o.status.success() => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: true,
|
||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
},
|
||||
Ok(o) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
"fipsctl exited {}: {}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
},
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("fipsctl launch failed: {}", e),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
npub = %result.npub,
|
||||
message = %result.message,
|
||||
"Seed anchor apply failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Outcome of a single `fipsctl connect` call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApplyResult {
|
||||
pub npub: String,
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mk(npub: &str) -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: npub.to_string(),
|
||||
address: "example.test:8668".to_string(),
|
||||
transport: "udp".to_string(),
|
||||
label: "test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_returns_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = mk("npub1aaa");
|
||||
let b = mk("npub1bbb");
|
||||
save(dir.path(), &[a.clone(), b.clone()]).await.unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![a, b]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_replaces_existing_by_npub() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut a = mk("npub1aaa");
|
||||
save(dir.path(), &[a.clone()]).await.unwrap();
|
||||
a.address = "newhost:8668".to_string();
|
||||
let list = add(dir.path(), a.clone()).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].address, "newhost:8668");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_by_npub() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(
|
||||
dir.path(),
|
||||
&[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let list = remove(dir.path(), "npub1bbb").await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(list.iter().all(|a| a.npub != "npub1bbb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_anchor_uses_udp_by_default() {
|
||||
let json = r#"{"npub":"npub1x","address":"h:8668"}"#;
|
||||
let a: SeedAnchor = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(a.transport, "udp");
|
||||
assert_eq!(a.label, "");
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,27 @@ use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_UDP_PORT};
|
||||
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
|
||||
/// 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 + Tor,
|
||||
/// IPv6 TUN + DNS on defaults. Static peer list is empty — archipelago
|
||||
/// feeds peers dynamically via federation updates.
|
||||
/// 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.
|
||||
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`, and the upstream no longer
|
||||
// has a `tor:` transport — archipelago's own Tor fallback handles that.
|
||||
// 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\
|
||||
@@ -44,9 +49,12 @@ pub fn render_config_yaml() -> String {
|
||||
bind_addr: \"127.0.0.1\"\n\
|
||||
transports:\n \
|
||||
udp:\n \
|
||||
bind_addr: \"0.0.0.0:{port}\"\n\
|
||||
bind_addr: \"0.0.0.0:{udp}\"\n \
|
||||
tcp:\n \
|
||||
bind_addr: \"0.0.0.0:{tcp}\"\n\
|
||||
peers: []\n",
|
||||
port = DEFAULT_UDP_PORT,
|
||||
udp = DEFAULT_UDP_PORT,
|
||||
tcp = DEFAULT_TCP_PORT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,11 +86,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
install_result?;
|
||||
|
||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||
// fips expects 32 raw bytes; a text file silently passes through
|
||||
// and then the daemon can't identify itself to peers. This
|
||||
// rewrites the source file in place with the correct binary form
|
||||
// derived from fips_key before staging it to /etc/fips/fips.pub.
|
||||
normalize_pub_file(&src_key, &src_pub).await?;
|
||||
sudo_install_file(&src_pub, DAEMON_PUB_PATH, "0644").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure `fips_key.pub` is 32 raw bytes. If it's a bech32 npub text
|
||||
/// file (from the pre-fix writer), decode it and rewrite in place. If
|
||||
/// the file is missing or its content doesn't match either format,
|
||||
/// re-derive the public key from `fips_key` and write that.
|
||||
pub async fn normalize_pub_file(key_path: &Path, pub_path: &Path) -> Result<()> {
|
||||
// Happy path: already 32 raw bytes.
|
||||
if let Ok(bytes) = tokio::fs::read(pub_path).await {
|
||||
if bytes.len() == 32 {
|
||||
return Ok(());
|
||||
}
|
||||
// bech32 npub text from the pre-fix writer: decode in place.
|
||||
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.starts_with("npub1") {
|
||||
if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) {
|
||||
let raw: [u8; 32] = pk.to_bytes();
|
||||
tokio::fs::write(pub_path, raw)
|
||||
.await
|
||||
.context("rewriting fips_key.pub as 32 raw bytes")?;
|
||||
tracing::info!(
|
||||
"Migrated legacy bech32 fips_key.pub to raw-byte form at {}",
|
||||
pub_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no pub file, or unreadable format. Re-derive from the
|
||||
// private key file (already validated by load_fips_keys).
|
||||
let secret_bytes = tokio::fs::read(key_path)
|
||||
.await
|
||||
.with_context(|| format!("read {} to derive public", key_path.display()))?;
|
||||
let text = std::str::from_utf8(&secret_bytes)
|
||||
.context("fips_key is not UTF-8 — can't derive public")?;
|
||||
let secret = nostr_sdk::SecretKey::parse(text.trim())
|
||||
.context("fips_key not parseable as bech32 nsec")?;
|
||||
let keys = nostr_sdk::Keys::new(secret);
|
||||
let raw: [u8; 32] = keys.public_key().to_bytes();
|
||||
tokio::fs::write(pub_path, raw)
|
||||
.await
|
||||
.context("writing re-derived fips_key.pub")?;
|
||||
tracing::info!("Re-derived fips_key.pub from fips_key");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sudo_install_dir(path: &str) -> Result<()> {
|
||||
let out = Command::new("sudo")
|
||||
.args(["install", "-d", "-m", "0755", path])
|
||||
@@ -131,7 +193,9 @@ mod tests {
|
||||
let yaml = render_config_yaml();
|
||||
assert!(yaml.contains("persistent: true"));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
|
||||
assert!(yaml.contains("udp:"));
|
||||
assert!(yaml.contains("tcp:"));
|
||||
assert!(yaml.contains("tun:"));
|
||||
assert!(yaml.contains("name: fips0"));
|
||||
// Upstream fips dropped the `tor:` transport variant; archipelago
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
// the module is deliberately API-ready ahead of those call-sites.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod anchors;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
@@ -52,6 +53,14 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
|
||||
/// Default UDP port the daemon listens on.
|
||||
pub const DEFAULT_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// Default TCP port the daemon listens on. Used as a fallback when a
|
||||
/// peer can't be reached over UDP — common on networks that block UDP
|
||||
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
|
||||
/// currently accepts. Upstream factory default enables both transports
|
||||
/// and archipelago intentionally matches that baseline so fresh nodes
|
||||
/// can reach the broader FIPS mesh without operator config.
|
||||
pub const DEFAULT_TCP_PORT: u16 = 8443;
|
||||
|
||||
/// Upstream systemd unit shipped by the `fips` debian package. Archipelago
|
||||
/// prefers its own supervision (`archipelago-fips.service`) but respects an
|
||||
/// already-running upstream unit so legacy/dev nodes — where no seed-derived
|
||||
@@ -98,7 +107,13 @@ pub struct FipsStatus {
|
||||
|
||||
impl FipsStatus {
|
||||
/// Snapshot the current state across package, key, and service.
|
||||
pub async fn query(identity_dir: &Path) -> Self {
|
||||
///
|
||||
/// `data_dir` is the archipelago data-dir (used to load the
|
||||
/// operator-configured seed-anchor list so "anchor_connected" means
|
||||
/// "at least one authenticated peer matches a public or configured
|
||||
/// seed anchor", not just "fips.v0l.io specifically").
|
||||
pub async fn query(data_dir: &Path) -> Self {
|
||||
let identity_dir = identity_dir_from(data_dir);
|
||||
let installed = service::package_installed().await;
|
||||
let version = if installed {
|
||||
service::daemon_version().await.ok()
|
||||
@@ -109,17 +124,24 @@ impl FipsStatus {
|
||||
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
|
||||
let service_active =
|
||||
service_state == "active" || upstream_service_state == "active";
|
||||
let key_present = crate::identity::fips_key_exists(identity_dir);
|
||||
let key_present = crate::identity::fips_key_exists(&identity_dir);
|
||||
|
||||
// Prefer the seed-derived npub; otherwise read the daemon's own
|
||||
// key file at /etc/fips/fips.pub (world-readable per debian pkg).
|
||||
let npub = match crate::identity::fips_npub(identity_dir).await {
|
||||
let npub = match crate::identity::fips_npub(&identity_dir).await {
|
||||
Ok(Some(n)) => Some(n),
|
||||
_ => service::read_upstream_npub().await.ok().flatten(),
|
||||
};
|
||||
|
||||
let (authenticated_peer_count, anchor_connected) = if service_active {
|
||||
service::peer_connectivity_summary().await
|
||||
// Build the anchor-candidate list: hardcoded public anchor
|
||||
// plus every entry in the operator's seed-anchors.json.
|
||||
// The card lights up if any of them is authenticated.
|
||||
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
||||
if let Ok(seed) = anchors::load(data_dir).await {
|
||||
anchor_npubs.extend(seed.into_iter().map(|a| a.npub));
|
||||
}
|
||||
service::peer_connectivity_summary(&anchor_npubs).await
|
||||
} else {
|
||||
(0, false)
|
||||
};
|
||||
@@ -152,10 +174,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_status_reports_no_key_pre_onboarding() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let id_dir = dir.path().join("identity");
|
||||
tokio::fs::create_dir_all(&id_dir).await.unwrap();
|
||||
|
||||
let status = FipsStatus::query(&id_dir).await;
|
||||
// query() now takes a data_dir (parent) rather than identity_dir,
|
||||
// since it also reads seed-anchors.json for the anchor check.
|
||||
// No identity/ subdir → no key; no seed-anchors.json → public
|
||||
// anchor is the only candidate.
|
||||
let status = FipsStatus::query(dir.path()).await;
|
||||
assert!(!status.key_present, "no key before onboarding");
|
||||
assert!(status.npub.is_none());
|
||||
// `installed`, `service_state`, `version` depend on the host and are
|
||||
|
||||
@@ -97,6 +97,27 @@ pub async fn restart(unit: &str) -> Result<()> {
|
||||
sudo_systemctl("restart", unit).await
|
||||
}
|
||||
|
||||
/// Resolve which systemd unit is actually supervising the fips daemon
|
||||
/// on this host. Nodes installed from the archipelago ISO run
|
||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
||||
/// fips running before archipelago took over) may only have the
|
||||
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
|
||||
/// one is running, otherwise the UI button is a silent no-op.
|
||||
///
|
||||
/// Returns the archipelago-managed unit name if it's active,
|
||||
/// else the upstream unit name if that's active,
|
||||
/// else the archipelago-managed name as a default (so activate() can
|
||||
/// bring it up).
|
||||
pub async fn active_unit() -> &'static str {
|
||||
if unit_state(super::SERVICE_UNIT).await == "active" {
|
||||
return super::SERVICE_UNIT;
|
||||
}
|
||||
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
||||
return super::UPSTREAM_SERVICE_UNIT;
|
||||
}
|
||||
super::SERVICE_UNIT
|
||||
}
|
||||
|
||||
pub async fn mask(unit: &str) -> Result<()> {
|
||||
let _ = sudo_systemctl("stop", unit).await;
|
||||
let _ = sudo_systemctl("disable", unit).await;
|
||||
@@ -108,12 +129,19 @@ pub async fn mask(unit: &str) -> Result<()> {
|
||||
pub const PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
|
||||
/// Summarise peer connectivity from `fipsctl show peers` + `identity-cache`.
|
||||
/// Returns `(authenticated_peer_count, anchor_connected)`. Shells out rather
|
||||
/// than embedding a fips client because fipsctl is the daemon's own ground
|
||||
/// truth — the daemon can always rewrite its internal routing and we'd
|
||||
/// rather be consistent with `fipsctl` than snapshot it ourselves.
|
||||
pub async fn peer_connectivity_summary() -> (u32, bool) {
|
||||
/// Summarise peer connectivity from `fipsctl show peers`. Returns
|
||||
/// `(authenticated_peer_count, anchor_connected)`.
|
||||
///
|
||||
/// `anchor_candidates` is the operator-controlled list of npubs this
|
||||
/// node considers a valid mesh anchor — always includes the hard-coded
|
||||
/// public anchor, plus any entries from `seed-anchors.json`. A node is
|
||||
/// "anchor connected" when at least one currently-authenticated peer
|
||||
/// matches one of these npubs. We used to check the identity cache
|
||||
/// (which includes transient hearsay from other peers), but a cache
|
||||
/// hit on `fips.v0l.io` didn't mean we could actually route through
|
||||
/// it, and the card lied to users whose mesh was federated through
|
||||
/// their own seed anchors instead.
|
||||
pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bool) {
|
||||
let peers_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "peers"])
|
||||
.output()
|
||||
@@ -122,39 +150,26 @@ pub async fn peer_connectivity_summary() -> (u32, bool) {
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return (0, false),
|
||||
};
|
||||
let authenticated_peer_count =
|
||||
match serde_json::from_slice::<serde_json::Value>(&peers_json) {
|
||||
Ok(v) => v
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|a| a.len() as u32)
|
||||
.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
let parsed: serde_json::Value =
|
||||
match serde_json::from_slice(&peers_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (0, false),
|
||||
};
|
||||
|
||||
// Anchor check: look in identity-cache (known node pubkeys the daemon
|
||||
// has heard about) rather than authenticated peers — the anchor may be
|
||||
// in the cache but not currently at session depth.
|
||||
let cache_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "identity-cache"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return (authenticated_peer_count, false),
|
||||
};
|
||||
let anchor_connected = match serde_json::from_slice::<serde_json::Value>(&cache_json) {
|
||||
Ok(v) => v
|
||||
.get("entries")
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e.get("npub").and_then(|n| n.as_str()) == Some(PUBLIC_ANCHOR_NPUB))
|
||||
})
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
let peers = parsed
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let authenticated_peer_count = peers.len() as u32;
|
||||
let anchor_connected = peers.iter().any(|p| {
|
||||
let npub = p.get("npub").and_then(|n| n.as_str()).unwrap_or_default();
|
||||
let connected = p
|
||||
.get("connectivity")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s == "connected")
|
||||
.unwrap_or(true);
|
||||
connected && anchor_candidates.iter().any(|a| a == npub)
|
||||
});
|
||||
(authenticated_peer_count, anchor_connected)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,14 +219,22 @@ async fn write_fips_key_from_seed(
|
||||
.await
|
||||
.context("Failed to set FIPS key permissions")?;
|
||||
}
|
||||
let npub = keys.public_key().to_bech32().unwrap_or_default();
|
||||
fs::write(&pub_path, format!("{npub}\n"))
|
||||
// Upstream fips daemon expects 32 raw bytes in /etc/fips/fips.pub —
|
||||
// not a bech32 npub string. Writing the bech32 form here meant the
|
||||
// installed .pub file was a 63-char text file the daemon parsed as
|
||||
// 63 raw bytes of garbage, so it couldn't identify itself to peers
|
||||
// and the anchor never handshook. Write the raw public-key bytes
|
||||
// (PublicKey::to_bytes returns a [u8; 32]) so the daemon reads
|
||||
// them directly.
|
||||
let raw_pub: [u8; 32] = keys.public_key().to_bytes();
|
||||
fs::write(&pub_path, raw_pub)
|
||||
.await
|
||||
.context("Failed to write FIPS public key")?;
|
||||
|
||||
let npub_for_log = keys.public_key().to_bech32().unwrap_or_default();
|
||||
tracing::info!(
|
||||
"Derived FIPS mesh key from seed (npub: {}...)",
|
||||
npub.chars().take(20).collect::<String>()
|
||||
npub_for_log.chars().take(20).collect::<String>()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@ async fn main() -> Result<()> {
|
||||
crash_recovery::remove_pid_marker(&config.data_dir).await;
|
||||
|
||||
info!("Archipelago shut down cleanly");
|
||||
Ok(())
|
||||
|
||||
// Hard-exit after logging. All business state is persisted by now
|
||||
// (connections drained, PID marker removed, disk flushes done via
|
||||
// tokio::fs awaits). Letting tokio try to drop the runtime instead
|
||||
// can stall for 15s+ on non-daemon OS threads we don't directly
|
||||
// own (mdns_sd daemon, reqwest resolver pool, etc.) — long enough
|
||||
// for systemd's TimeoutStopSec to SIGKILL us and mark the service
|
||||
// Failed, which makes an otherwise-successful update look like a
|
||||
// crash in `systemctl status`.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
@@ -353,6 +353,34 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
|
||||
// configured seed anchors into the running fips daemon via
|
||||
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
|
||||
// operators add cluster-local anchors in the UI, and a daemon
|
||||
// restart or a flaky public anchor can't strand the node.
|
||||
// First run is delayed 30s so fips has time to come up after
|
||||
// onboarding before we start dialing.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::fips::anchors::load(&data_dir).await {
|
||||
Ok(list) if !list.is_empty() => {
|
||||
let _ = crate::fips::anchors::apply(&list).await;
|
||||
}
|
||||
Ok(_) => { /* no seed anchors configured yet */ }
|
||||
Err(e) => tracing::debug!(
|
||||
"Seed-anchor apply: load failed (non-fatal): {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// did:dht auto-refresh — re-publish DHT records every 2 hours
|
||||
if config.nostr_discovery_enabled {
|
||||
let data_dir = config.data_dir.clone();
|
||||
@@ -482,10 +510,37 @@ impl Server {
|
||||
tracing::warn!("FIPS key load/migrate failed: {}", e);
|
||||
return;
|
||||
}
|
||||
// Check if the installed fips.yaml matches what we'd
|
||||
// render now. If not, we need to restart the daemon after
|
||||
// reinstalling so it picks up schema changes (e.g. the
|
||||
// v1.7.25 re-addition of the TCP transport). Without this,
|
||||
// OTA'd nodes would be stuck on the old UDP-only config
|
||||
// until someone manually clicked Reconnect.
|
||||
let expected = crate::fips::config::render_config_yaml();
|
||||
let installed = tokio::fs::read_to_string("/etc/fips/fips.yaml")
|
||||
.await
|
||||
.ok();
|
||||
let config_changed = installed.as_deref() != Some(expected.as_str());
|
||||
|
||||
if let Err(e) = crate::fips::config::install(&identity_dir).await {
|
||||
tracing::warn!("FIPS config install failed on startup: {}", e);
|
||||
return;
|
||||
}
|
||||
if config_changed {
|
||||
tracing::info!(
|
||||
"FIPS config schema changed on disk — restarting daemon to pick up new transports"
|
||||
);
|
||||
// Restart whichever unit is actually supervising
|
||||
// the daemon (archipelago-fips vs upstream fips).
|
||||
let unit = crate::fips::service::active_unit().await;
|
||||
if let Err(e) = crate::fips::service::restart(unit).await {
|
||||
tracing::warn!(
|
||||
"FIPS restart after config migration failed on {}: {} — user can retry via fips.reconnect",
|
||||
unit,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||
tracing::warn!(
|
||||
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
|
||||
|
||||
@@ -160,6 +160,18 @@ impl LanTransport {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LanTransport {
|
||||
// The mdns_sd daemon runs on its own OS thread and the browse
|
||||
// listener task blocks on a sync channel. Without this call both
|
||||
// keep the process alive past SIGTERM, long enough for systemd to
|
||||
// SIGKILL us — which makes a normal update look like a crash.
|
||||
fn drop(&mut self) {
|
||||
if let Some(daemon) = self.daemon.take() {
|
||||
let _ = daemon.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeTransport for LanTransport {
|
||||
fn kind(&self) -> TransportKind {
|
||||
TransportKind::Lan
|
||||
|
||||
+876
-134
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.3.5",
|
||||
"version": "1.6.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.3.5",
|
||||
"version": "1.6.0-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -525,7 +525,11 @@ class RPCClient {
|
||||
return this.call({
|
||||
method: 'package.install',
|
||||
params: { id, 'marketplace-url': marketplaceUrl, version },
|
||||
timeout: 900000, // 15 min — multi-GB stacks (IndeedHub, Bitcoin, Penpot) take time
|
||||
// 45 min — IndeedHub is 6 images and gitea raw-file throughput is
|
||||
// ~70 KB/s per image; 15 min was short enough to kill the install
|
||||
// mid-pull and land the user on a "didn't work" screen while the
|
||||
// backend kept working in the background.
|
||||
timeout: 2700000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,7 @@
|
||||
</div>
|
||||
<!-- Normal logo with audio viz ring -->
|
||||
<div v-else class="screensaver-content">
|
||||
<!-- Radial audio visualization - bars around the logo -->
|
||||
<div class="screensaver-viz-ring">
|
||||
<div
|
||||
v-for="(_, i) in segmentCount"
|
||||
:key="i"
|
||||
class="screensaver-viz-segment"
|
||||
:style="getSegmentStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
<ScreensaverRing />
|
||||
<!-- Logo in center -->
|
||||
<div class="screensaver-logo-wrapper">
|
||||
<ScreensaverLogo />
|
||||
@@ -35,21 +27,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
|
||||
const store = useScreensaverStore()
|
||||
|
||||
const segmentCount = 48
|
||||
|
||||
function getSegmentStyle(i: number) {
|
||||
const deg = (i / segmentCount) * 360
|
||||
return {
|
||||
'--segment-index': i,
|
||||
'--segment-deg': `${deg}deg`,
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss on any key (except when typing)
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (store.isActive) {
|
||||
@@ -86,102 +69,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
.screensaver-content {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screensaver-content {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-content {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ring of segments around the logo - audio viz style (behind logo) */
|
||||
.screensaver-viz-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
--viz-radius: 140px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screensaver-viz-ring {
|
||||
--viz-radius: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-viz-ring {
|
||||
--viz-radius: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.screensaver-viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
margin-left: -2px;
|
||||
margin-top: -12px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||
border-radius: 2px;
|
||||
/* Origin at segment center = ring center (segment is centered via left/top 50%) */
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||
animation: segment-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--segment-index) * 0.02s);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-viz-segment {
|
||||
height: 28px;
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5 normal loops (10s) then stronger longer expression (4s) - total 14s */
|
||||
@keyframes segment-pulse {
|
||||
/* Loop 1 */
|
||||
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
14.3% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 2 */
|
||||
21.4% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
28.6% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 3 */
|
||||
35.7% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
42.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 4 */
|
||||
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
57.1% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 5 */
|
||||
64.3% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
71.4% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Strong expression: ramp up (1.5s), hold (2s), ease back (0.5s) */
|
||||
78.6% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
85.7% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
92.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.screensaver-logo-wrapper {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="viz-ring" :class="sizeClass">
|
||||
<div
|
||||
v-for="(_, i) in segmentCount"
|
||||
:key="i"
|
||||
class="viz-segment"
|
||||
:style="getSegmentStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Visual size: 'default' matches the screensaver; 'compact' drops the
|
||||
* min-width breakpoints (useful inside overlays on narrower canvases). */
|
||||
size?: 'default' | 'compact'
|
||||
/** Override segment count. Defaults to 48 (screensaver standard). */
|
||||
segmentCount?: number
|
||||
}>(), { size: 'default', segmentCount: 48 })
|
||||
|
||||
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
|
||||
|
||||
function getSegmentStyle(i: number) {
|
||||
const deg = (i / props.segmentCount) * 360
|
||||
return {
|
||||
'--segment-index': i,
|
||||
'--segment-deg': `${deg}deg`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.viz-ring {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viz-ring-default {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
--viz-radius: 140px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.viz-ring-default {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
--viz-radius: 180px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-ring-default {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
--viz-radius: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.viz-ring-compact {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
--viz-radius: 120px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-ring-compact {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
--viz-radius: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
margin-left: -2px;
|
||||
margin-top: -12px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||
border-radius: 2px;
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||
animation: segment-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--segment-index) * 0.02s);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.viz-segment {
|
||||
height: 28px;
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5 normal loops (10s) then stronger longer expression (4s) — total 14s */
|
||||
@keyframes segment-pulse {
|
||||
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
14.3%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
21.4%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
28.6%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
35.7%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
42.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
57.1%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
64.3%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
71.4%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
78.6%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
85.7%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
}
|
||||
</style>
|
||||
@@ -19,11 +19,24 @@ async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T
|
||||
}
|
||||
|
||||
export async function isOnboardingComplete(): Promise<boolean> {
|
||||
// localStorage is set on completion and survives backend restarts/resets
|
||||
if (localStorage.getItem('neode_onboarding_complete') === '1') return true
|
||||
// Prefer the backend — localStorage gets stale across nodes (a
|
||||
// browser that onboarded node A would otherwise treat fresh node B
|
||||
// as already-onboarded and skip the wizard entirely). Only fall
|
||||
// back to localStorage if the backend is unreachable.
|
||||
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
|
||||
if (result !== null) return result
|
||||
return false
|
||||
if (result !== null) {
|
||||
// Re-seed the localStorage cache so non-async consumers
|
||||
// (OnboardingWrapper's useVideoBackground computed, etc.) see the
|
||||
// right answer after the user clears site data on an already-
|
||||
// onboarded node.
|
||||
if (result) {
|
||||
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
|
||||
} else {
|
||||
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
|
||||
@@ -666,7 +666,7 @@
|
||||
"applyUpdate": "Install Update",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking...",
|
||||
"rollback": "Rollback to Previous",
|
||||
"rollback": "Rollback Available",
|
||||
"backToSettings": "Back to Settings",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"applyWarning": "Installing components and restarting services. Do not power off.",
|
||||
@@ -685,10 +685,26 @@
|
||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||
"rollbackFailed": "Rollback failed.",
|
||||
"pullAndRebuild": "Pull & Rebuild",
|
||||
"finishingDownload": "Finishing download — verifying checksum…",
|
||||
"overlayApplying": "Installing update…",
|
||||
"overlayRestarting": "Restarting server…",
|
||||
"overlayReconnecting": "Reconnecting to the new version…",
|
||||
"overlayReady": "Update installed — reloading…",
|
||||
"overlayStalled": "Taking longer than expected",
|
||||
"overlayTarget": "Installing v{version}",
|
||||
"overlayReloadNow": "Reload now",
|
||||
"gitMethodHint": "This node builds from source. Update will git-pull, rebuild the backend and UI, then restart — takes a few minutes.",
|
||||
"gitApplyTitle": "Pull & Rebuild?",
|
||||
"gitApplyMessage": "Archipelago will pull the latest code, rebuild, and restart. This can take several minutes and the UI will be briefly unavailable.",
|
||||
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes."
|
||||
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes.",
|
||||
"cancelDownload": "Cancel Download",
|
||||
"cancelingDownload": "Canceling…",
|
||||
"cancelDownloadTitle": "Cancel Download?",
|
||||
"cancelDownloadConfirm": "This will stop the current download and discard the partial file. You can start again from scratch afterwards.",
|
||||
"cancelDownloadButton": "Cancel Download",
|
||||
"cancelDownloadSuccess": "Download canceled. You can try again.",
|
||||
"cancelDownloadFailed": "Failed to cancel download.",
|
||||
"downloadStalled": "Download appears stuck — try Cancel and start again."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Press ESC to exit",
|
||||
|
||||
@@ -665,7 +665,7 @@
|
||||
"applyUpdate": "Instalar actualizaci\u00f3n",
|
||||
"checkForUpdates": "Buscar actualizaciones",
|
||||
"checking": "Verificando...",
|
||||
"rollback": "Revertir a la versi\u00f3n anterior",
|
||||
"rollback": "Rollback disponible",
|
||||
"backToSettings": "Volver a configuraci\u00f3n",
|
||||
"percentComplete": "{percent}% completado",
|
||||
"applyWarning": "Instalando componentes y reiniciando servicios. No apague el equipo.",
|
||||
@@ -684,10 +684,26 @@
|
||||
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
||||
"rollbackFailed": "Error al revertir.",
|
||||
"pullAndRebuild": "Pull y Recompilar",
|
||||
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 — tarda unos minutos.",
|
||||
"finishingDownload": "Terminando descarga \u2014 verificando checksum\u2026",
|
||||
"overlayApplying": "Instalando actualizaci\u00f3n\u2026",
|
||||
"overlayRestarting": "Reiniciando servidor\u2026",
|
||||
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n\u2026",
|
||||
"overlayReady": "Actualizaci\u00f3n instalada \u2014 recargando\u2026",
|
||||
"overlayStalled": "Tardando m\u00e1s de lo esperado",
|
||||
"overlayTarget": "Instalando v{version}",
|
||||
"overlayReloadNow": "Recargar ahora",
|
||||
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 \u2014 tarda unos minutos.",
|
||||
"gitApplyTitle": "\u00bfPull y Recompilar?",
|
||||
"gitApplyMessage": "Archipelago descargar\u00e1 el c\u00f3digo m\u00e1s reciente, lo compilar\u00e1 y reiniciar\u00e1. Puede tardar varios minutos y la UI estar\u00e1 brevemente no disponible.",
|
||||
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 — puede tardar unos minutos."
|
||||
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 \u2014 puede tardar unos minutos.",
|
||||
"cancelDownload": "Cancelar descarga",
|
||||
"cancelingDownload": "Cancelando\u2026",
|
||||
"cancelDownloadTitle": "\u00bfCancelar descarga?",
|
||||
"cancelDownloadConfirm": "Esto detendr\u00e1 la descarga actual y descartar\u00e1 el archivo parcial. Podr\u00e1s volver a empezar desde cero.",
|
||||
"cancelDownloadButton": "Cancelar descarga",
|
||||
"cancelDownloadSuccess": "Descarga cancelada. Puedes intentarlo de nuevo.",
|
||||
"cancelDownloadFailed": "No se pudo cancelar la descarga.",
|
||||
"downloadStalled": "La descarga parece atascada \u2014 prueba a cancelar y volver a empezar."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Presione ESC para salir",
|
||||
|
||||
@@ -193,6 +193,11 @@ const router = createRouter({
|
||||
name: 'system-update',
|
||||
component: () => import('../views/SystemUpdate.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/registries',
|
||||
name: 'app-registries',
|
||||
component: () => import('../views/AppRegistries.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
|
||||
@@ -10,10 +10,19 @@ describe('useLoginTransitionStore', () => {
|
||||
it('starts with all flags false', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
expect(store.justCompletedOnboarding).toBe(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setJustCompletedOnboarding updates justCompletedOnboarding', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustCompletedOnboarding(true)
|
||||
expect(store.justCompletedOnboarding).toBe(true)
|
||||
store.setJustCompletedOnboarding(false)
|
||||
expect(store.justCompletedOnboarding).toBe(false)
|
||||
})
|
||||
|
||||
it('setJustLoggedIn updates justLoggedIn', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
|
||||
@@ -4,6 +4,13 @@ import { ref } from 'vue'
|
||||
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
|
||||
export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
const justLoggedIn = ref(false)
|
||||
/**
|
||||
* True only when the user just finished the onboarding wizard
|
||||
* (first password setup), as distinct from a regular re-login.
|
||||
* Dashboard uses this to decide whether to play the full glitchy
|
||||
* reveal vs just a quick interface-draw.
|
||||
*/
|
||||
const justCompletedOnboarding = ref(false)
|
||||
/** Show empty welcome block until typing starts (hide static text) */
|
||||
const pendingWelcomeTyping = ref(false)
|
||||
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
|
||||
@@ -13,6 +20,10 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
justLoggedIn.value = value
|
||||
}
|
||||
|
||||
function setJustCompletedOnboarding(value: boolean) {
|
||||
justCompletedOnboarding.value = value
|
||||
}
|
||||
|
||||
function setPendingWelcomeTyping(value: boolean) {
|
||||
pendingWelcomeTyping.value = value
|
||||
}
|
||||
@@ -24,6 +35,8 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
return {
|
||||
justLoggedIn,
|
||||
setJustLoggedIn,
|
||||
justCompletedOnboarding,
|
||||
setJustCompletedOnboarding,
|
||||
pendingWelcomeTyping,
|
||||
setPendingWelcomeTyping,
|
||||
startWelcomeTyping,
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface PackageDataEntry {
|
||||
manifest: Manifest
|
||||
installed?: InstalledPackageDataEntry
|
||||
'install-progress'?: InstallProgress
|
||||
/** Live label for the current uninstall step ("Stopping containers (2/5)", …). */
|
||||
'uninstall-stage'?: string | null
|
||||
'available-update'?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
||||
<p class="text-white/70">
|
||||
Container registries this node pulls app images from. The primary is tried first; if it's
|
||||
slow or unreachable, the next one in the list is tried automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
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'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Registry list -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Registries</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
@click="openAddRegistry"
|
||||
>+ Add registry</button>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Registries are tried in priority order on every app install. Changing the primary takes
|
||||
effect on the next install — existing containers keep running on whatever image they
|
||||
already pulled.
|
||||
</p>
|
||||
<ul v-if="registries.length" class="space-y-2">
|
||||
<li
|
||||
v-for="r in sortedRegistries"
|
||||
:key="r.url"
|
||||
class="p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<p class="text-sm font-medium text-white truncate">{{ r.name || r.url }}</p>
|
||||
<span
|
||||
v-if="r.priority === 0"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
|
||||
>PRIMARY</span>
|
||||
<span
|
||||
v-if="!r.tls_verify"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
|
||||
title="TLS verification disabled — HTTP or self-signed registry"
|
||||
>HTTP</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 font-mono break-all">{{ r.url }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registryTests[r.url]?.testing"
|
||||
title="Test reachability"
|
||||
@click="testRegistry(r)"
|
||||
>
|
||||
<svg v-if="registryTests[r.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="r.priority !== 0"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
|
||||
title="Make primary"
|
||||
@click="setPrimary(r.url)"
|
||||
>
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="registries.length > 1"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove registry"
|
||||
@click="removeRegistry(r.url)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
|
||||
class="mt-2 pt-2 border-t border-white/5 text-xs"
|
||||
>
|
||||
<span v-if="registryTests[r.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Reachable (HTTP {{ registryTests[r.url]?.status }})
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="truncate">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Back link -->
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Settings
|
||||
</RouterLink>
|
||||
|
||||
<!-- Add-registry modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="addingRegistry"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
|
||||
@click.self="cancelAddRegistry"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
|
||||
— for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
|
||||
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
|
||||
added to the end of the list; use "Make primary" to reorder.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitRegistry">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name</label>
|
||||
<input
|
||||
v-model="registryDraft.name"
|
||||
type="text"
|
||||
placeholder="My private registry"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
|
||||
<input
|
||||
v-model="registryDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="host:port/namespace"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
|
||||
<input
|
||||
v-model="registryDraft.tls_verify"
|
||||
type="checkbox"
|
||||
class="accent-orange-400"
|
||||
/>
|
||||
Verify TLS certificate (uncheck for HTTP or self-signed)
|
||||
</label>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddRegistry"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registrySaving || !registryDraft.url.trim()"
|
||||
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface Registry {
|
||||
url: string
|
||||
name: string
|
||||
tls_verify: boolean
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface RegistryTestState {
|
||||
testing?: boolean
|
||||
reachable?: boolean
|
||||
status?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const registries = ref<Registry[]>([])
|
||||
const sortedRegistries = computed(() =>
|
||||
[...registries.value].sort((a, b) => a.priority - b.priority)
|
||||
)
|
||||
const registryTests = ref<Record<string, RegistryTestState>>({})
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const addingRegistry = ref(false)
|
||||
const registrySaving = ref(false)
|
||||
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
|
||||
|
||||
function showStatus(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function loadRegistries() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
|
||||
registries.value = res.registries
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('registry.list failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddRegistry() {
|
||||
registryDraft.url = ''
|
||||
registryDraft.name = ''
|
||||
registryDraft.tls_verify = true
|
||||
addingRegistry.value = true
|
||||
}
|
||||
function cancelAddRegistry() {
|
||||
addingRegistry.value = false
|
||||
}
|
||||
|
||||
async function submitRegistry() {
|
||||
const url = registryDraft.url.trim()
|
||||
if (!url) return
|
||||
registrySaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.add',
|
||||
params: {
|
||||
url,
|
||||
name: registryDraft.name.trim() || url,
|
||||
tls_verify: registryDraft.tls_verify,
|
||||
},
|
||||
})
|
||||
registries.value = res.registries
|
||||
addingRegistry.value = false
|
||||
showStatus('Registry added.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Add registry failed: ${msg}`, true)
|
||||
} finally {
|
||||
registrySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRegistry(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.remove',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Registry removed.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.set-primary',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Primary registry updated. Next install will try it first.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Set primary failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function testRegistry(r: Registry) {
|
||||
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
url: string
|
||||
reachable: boolean
|
||||
status: number | null
|
||||
error?: string | null
|
||||
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: {
|
||||
testing: false,
|
||||
reachable: res.reachable,
|
||||
status: res.status,
|
||||
error: res.error ?? null,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: { testing: false, reachable: false, error: msg },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadRegistries() })
|
||||
</script>
|
||||
@@ -282,6 +282,9 @@ function closeUninstallModal() {
|
||||
|
||||
async function onConfirmUninstall() {
|
||||
const { appId } = uninstallModal.value
|
||||
// Close the modal immediately so the user can fire off concurrent
|
||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||
// its uninstall is in flight.
|
||||
uninstallModal.value.show = false
|
||||
await actions.confirmUninstall(appId)
|
||||
}
|
||||
|
||||
@@ -264,10 +264,13 @@ watch(() => route.path, (newPath) => {
|
||||
onMounted(() => {
|
||||
previousRoutePath = route.path
|
||||
document.body.classList.add('dashboard-active')
|
||||
if (loginTransition.justLoggedIn) {
|
||||
if (loginTransition.justCompletedOnboarding) {
|
||||
// Full glitchy reveal — only on the very first dashboard entry
|
||||
// right after onboarding (one-time event, persists in feel).
|
||||
playDashboardLoadOomph()
|
||||
showZoomIn.value = true
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustCompletedOnboarding(false)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
const triggerRevealGlitch = () => {
|
||||
isGlitching.value = true
|
||||
@@ -281,6 +284,16 @@ onMounted(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 4000)
|
||||
} else if (loginTransition.justLoggedIn) {
|
||||
// Regular re-login — no zoom, no glitch. Just land on the
|
||||
// dashboard and kick off the welcome typing quickly.
|
||||
playDashboardLoadOomph()
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
scheduledTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKioskShortcuts)
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -537,6 +537,8 @@ async function rotateDid(password: string) {
|
||||
}
|
||||
}
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadNodes()
|
||||
loadDwnStatus()
|
||||
@@ -549,5 +551,16 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
loadNodes()
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -317,26 +317,35 @@ const torConnected = computed(() => {
|
||||
})
|
||||
const vpnStatus = ref({ connected: false, provider: '' })
|
||||
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (s.service_active) return 'bg-green-400'
|
||||
return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
if (s.anchor_connected === false) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (s.service_active) return 'text-green-400'
|
||||
return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (s.service_active) return 'Active'
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability in the label so the
|
||||
// Home and Server rows flip in sync with the FIPS card.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (!systemStats.bitcoinAvailable) return 'Not running'
|
||||
|
||||
@@ -408,6 +408,7 @@ async function handleSetup() {
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
|
||||
@@ -86,9 +86,23 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
||||
// Login uses video when coming from splash, or static + glitch when direct
|
||||
const isLoginRoute = computed(() => route.path === '/login')
|
||||
|
||||
// True once onboarding is complete. Used to skip the intro video on
|
||||
// the /login route so that returning (logged-out) users go straight
|
||||
// to the screensaver-style static + glitch background instead of
|
||||
// replaying the full intro every time.
|
||||
const onboardingDone = computed(() => {
|
||||
try {
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Check if current route should use video background
|
||||
const useVideoBackground = computed(() => {
|
||||
return videoBackgroundRoutes.includes(route.path)
|
||||
if (!videoBackgroundRoutes.includes(route.path)) return false
|
||||
if (route.path === '/login' && onboardingDone.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Map each route to a specific background image
|
||||
@@ -108,7 +122,32 @@ const routeBackgrounds: Record<string, string> = {
|
||||
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
||||
}
|
||||
|
||||
const loginBackground = 'bg-intro-1.jpg'
|
||||
// Rotate the login background so the lock screen doesn't look
|
||||
// identical on every logout. Cycles through bg-intro-1..6 using a
|
||||
// counter persisted to localStorage so subsequent visits advance.
|
||||
const LOGIN_BACKGROUNDS = [
|
||||
'bg-intro-1.jpg',
|
||||
'bg-intro-2.jpg',
|
||||
'bg-intro-3.jpg',
|
||||
'bg-intro-4.jpg',
|
||||
'bg-intro-5.jpg',
|
||||
'bg-intro-6.jpg',
|
||||
]
|
||||
function pickNextLoginBackground(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem('neode_login_bg_idx')
|
||||
const prev = raw !== null ? parseInt(raw, 10) : -1
|
||||
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
|
||||
localStorage.setItem('neode_login_bg_idx', String(next))
|
||||
return LOGIN_BACKGROUNDS[next]!
|
||||
} catch {
|
||||
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]!
|
||||
}
|
||||
}
|
||||
const loginBackground = ref(pickNextLoginBackground())
|
||||
watch(() => route.path, (p) => {
|
||||
if (p === '/login') loginBackground.value = pickNextLoginBackground()
|
||||
})
|
||||
|
||||
// Restore video time from splash screen for seamless transition
|
||||
function restoreVideoTime() {
|
||||
|
||||
@@ -129,7 +129,31 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Server not ready — show boot screen (waiting for backend)
|
||||
// Server not ready. The full BootScreen is meant for a genuine
|
||||
// cold-start (fresh install), not for the brief blip during an
|
||||
// OTA update where the backend restarts. If onboarding has already
|
||||
// completed we just keep the spinner and retry until the server
|
||||
// responds again.
|
||||
const wasOnboardedBefore = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
if (wasOnboardedBefore) {
|
||||
log('server down + onboarded → polling without boot screen')
|
||||
let retries = 0
|
||||
const maxRetries = 30 // 30 * 2s = 60s before giving up and showing boot screen
|
||||
const poll = setInterval(async () => {
|
||||
retries++
|
||||
if (await quickHealthCheck()) {
|
||||
clearInterval(poll)
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
clearInterval(poll)
|
||||
log('server still down after retries → falling back to boot screen')
|
||||
showBootScreen.value = true
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
showBootScreen.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -420,25 +420,31 @@ const networkData = ref({
|
||||
})
|
||||
|
||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsRowLabel = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
// Service-active wins even on legacy nodes with no seed-derived key.
|
||||
if (s.service_active) return 'Active'
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability so the row flips in
|
||||
// sync with the full FIPS card below.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const fipsRowTextClass = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (s.service_active) return 'text-green-400'
|
||||
return 'text-white/60'
|
||||
if (!s.service_active) return 'text-white/60'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
async function loadFipsSummary() {
|
||||
try {
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' })
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
|
||||
} catch { /* backend too old */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
|
||||
<p class="text-sm text-white/60">Version {{ updateInfo.version }} — {{ updateInfo.release_date }}</p>
|
||||
<p v-if="manifestMirrorLabel" class="text-xs text-white/40 mt-1 flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>Served by <span class="text-white/70">{{ manifestMirrorLabel }}</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
|
||||
</div>
|
||||
@@ -109,11 +115,31 @@
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
|
||||
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400 rounded-full transition-all duration-500"
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="downloadStalled ? 'bg-amber-400' : 'bg-orange-400'"
|
||||
:style="{ width: downloadPercentFormatted + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}</p>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div v-if="downloadFinishing && !downloadStalled" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
|
||||
<p class="text-xs" :class="downloadStalled ? 'text-amber-300' : 'text-white/60'">
|
||||
{{ downloadStalled
|
||||
? t('systemUpdate.downloadStalled')
|
||||
: downloadFinishing
|
||||
? t('systemUpdate.finishingDownload')
|
||||
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="requestCancelDownload"
|
||||
:disabled="cancelingDownload"
|
||||
class="glass-button rounded-lg px-4 py-1.5 text-xs font-medium disabled:opacity-40 shrink-0"
|
||||
:class="downloadStalled ? 'bg-amber-500/20 border-amber-400/40 text-amber-200' : ''"
|
||||
>
|
||||
{{ cancelingDownload ? t('systemUpdate.cancelingDownload') : t('systemUpdate.cancelDownload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applying -->
|
||||
@@ -152,6 +178,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mirrors -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Update mirrors</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
@click="openAddMirror"
|
||||
>+ Add mirror</button>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest — switching primary switches where files come from.
|
||||
</p>
|
||||
<ul v-if="mirrors.length" class="space-y-2">
|
||||
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
|
||||
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorTests[m.url]?.testing"
|
||||
title="Test reachability"
|
||||
@click="testMirror(m.url)"
|
||||
>
|
||||
<svg v-if="mirrorTests[m.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="i !== 0"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
|
||||
title="Make primary"
|
||||
@click="setPrimaryMirror(m.url)"
|
||||
>
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="mirrors.length > 1"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove mirror"
|
||||
@click="removeMirror(m.url)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mirrorTests[m.url] && !mirrorTests[m.url]?.testing" class="mt-2 pt-2 border-t border-white/5 text-xs">
|
||||
<span v-if="mirrorTests[m.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Reachable · {{ mirrorTests[m.url]?.latency_ms }}ms
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="truncate">{{ mirrorTests[m.url]?.error || 'Unreachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
||||
@@ -176,6 +283,112 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install progress overlay — covers the UI while the backend
|
||||
swaps files, restarts, and comes back up on the new version.
|
||||
Auto-reloads the page as soon as /health reports the target
|
||||
version. Styled to match the screensaver (ASCII logo, full-
|
||||
screen black). -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="installing"
|
||||
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
|
||||
>
|
||||
<!-- Centered ASCII logo — same asset used by the screensaver -->
|
||||
<div class="install-overlay-ascii">
|
||||
<BitcoinFaceAscii />
|
||||
</div>
|
||||
|
||||
<!-- Status text + progress bar underneath -->
|
||||
<div class="mt-8 w-[min(520px,80vw)] text-center">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">
|
||||
{{ installStage === 'applying' ? t('systemUpdate.overlayApplying')
|
||||
: installStage === 'restarting' ? t('systemUpdate.overlayRestarting')
|
||||
: installStage === 'reconnecting' ? t('systemUpdate.overlayReconnecting')
|
||||
: installStage === 'ready' ? t('systemUpdate.overlayReady')
|
||||
: t('systemUpdate.overlayStalled') }}
|
||||
</h2>
|
||||
<p v-if="installTargetVersion" class="text-sm text-white/60 mb-4">
|
||||
{{ t('systemUpdate.overlayTarget', { version: installTargetVersion }) }}
|
||||
</p>
|
||||
|
||||
<!-- Animated bar: indeterminate stripe while working; full
|
||||
orange when ready; steady at 50% (paused look) when
|
||||
stalled so it reads as "something needs the user". -->
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
|
||||
<div
|
||||
v-if="installStage === 'ready'"
|
||||
class="absolute inset-0 bg-green-400"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="installStage === 'stalled'"
|
||||
class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full install-overlay-bar-anim"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/40">{{ installElapsedLabel }}</p>
|
||||
|
||||
<button
|
||||
v-if="installStage === 'stalled'"
|
||||
@click="reloadNow"
|
||||
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
>
|
||||
{{ t('systemUpdate.overlayReloadNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Add-mirror modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitMirror">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Home VPS"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddMirror"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
@@ -185,14 +398,18 @@
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
@@ -201,13 +418,15 @@
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,10 +436,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
||||
|
||||
interface UpdateDetail {
|
||||
version: string
|
||||
@@ -244,7 +464,9 @@ const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const applying = ref(false)
|
||||
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
|
||||
const cancelingDownload = ref(false)
|
||||
const downloadStalled = ref(false)
|
||||
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | 'cancel-download' | null>(null)
|
||||
const currentVersion = ref('0.0.0')
|
||||
const lastCheck = ref<string | null>(null)
|
||||
const updateInfo = ref<UpdateDetail | null>(null)
|
||||
@@ -256,6 +478,231 @@ const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
||||
|
||||
// Mirrors — servers this node tries for the manifest, in priority
|
||||
// order. First entry is the primary. Add/remove/set-primary are wired
|
||||
// to update.*-mirror RPCs; downloads automatically go to the mirror
|
||||
// that served the manifest.
|
||||
interface UpdateMirror { url: string; label: string }
|
||||
const mirrors = ref<UpdateMirror[]>([])
|
||||
const addingMirror = ref(false)
|
||||
const mirrorSaving = ref(false)
|
||||
const mirrorDraft = reactive({ url: '', label: '' })
|
||||
|
||||
// URL of the mirror that served the currently-available-update manifest.
|
||||
// Backend reports it in update.status and update.check responses; the UI
|
||||
// resolves it to a friendly label by matching against the mirrors list.
|
||||
const manifestMirror = ref<string | null>(null)
|
||||
const manifestMirrorLabel = computed(() => {
|
||||
const url = manifestMirror.value
|
||||
if (!url) return null
|
||||
const match = mirrors.value.find(m => m.url === url)
|
||||
if (match && match.label) return match.label
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
})
|
||||
|
||||
// Per-mirror test state. Populated by testMirror(); each entry is either
|
||||
// { testing: true } while in flight or the backend response shape on
|
||||
// completion. Rendered inline under each mirror row.
|
||||
interface MirrorTestState {
|
||||
testing?: boolean
|
||||
reachable?: boolean
|
||||
latency_ms?: number
|
||||
http_status?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
const mirrorTests = ref<Record<string, MirrorTestState>>({})
|
||||
|
||||
async function testMirror(url: string) {
|
||||
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: true } }
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
reachable: boolean
|
||||
latency_ms: number
|
||||
http_status: number | null
|
||||
error: string | null
|
||||
}>({ method: 'update.test-mirror', params: { url } })
|
||||
mirrorTests.value = { ...mirrorTests.value, [url]: { ...res, testing: false } }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: false, reachable: false, error: msg } }
|
||||
}
|
||||
}
|
||||
|
||||
function openAddMirror() {
|
||||
mirrorDraft.url = ''
|
||||
mirrorDraft.label = ''
|
||||
addingMirror.value = true
|
||||
}
|
||||
function cancelAddMirror() {
|
||||
addingMirror.value = false
|
||||
}
|
||||
|
||||
async function loadMirrors() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
|
||||
mirrors.value = res.mirrors
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('update.list-mirrors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMirror() {
|
||||
const url = mirrorDraft.url.trim()
|
||||
if (!url) return
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
showStatus('Mirror URL must start with http:// or https://', true)
|
||||
return
|
||||
}
|
||||
mirrorSaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
|
||||
method: 'update.add-mirror',
|
||||
params: { url, label: mirrorDraft.label.trim() },
|
||||
})
|
||||
mirrors.value = res.mirrors
|
||||
mirrorDraft.url = ''
|
||||
mirrorDraft.label = ''
|
||||
addingMirror.value = false
|
||||
showStatus('Mirror added.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Add mirror failed: ${msg}`, true)
|
||||
} finally {
|
||||
mirrorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMirror(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
|
||||
method: 'update.remove-mirror',
|
||||
params: { url },
|
||||
})
|
||||
mirrors.value = res.mirrors
|
||||
showStatus('Mirror removed.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimaryMirror(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
|
||||
method: 'update.set-primary-mirror',
|
||||
params: { url },
|
||||
})
|
||||
mirrors.value = res.mirrors
|
||||
showStatus('Primary mirror updated. Next update check will try it first.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Set primary failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the backend for the real bytes_downloaded / total_bytes so the
|
||||
// progress bar tracks actual download state (and survives route
|
||||
// changes). Returns true if a download is currently in progress.
|
||||
async function pollDownloadProgress(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
download_progress?: {
|
||||
bytes_downloaded: number
|
||||
total_bytes: number
|
||||
active: boolean
|
||||
stalled?: boolean
|
||||
} | null
|
||||
}>({ method: 'update.status' })
|
||||
const p = res.download_progress
|
||||
if (p && p.total_bytes > 0) {
|
||||
downloadPercent.value = Math.min(100, (p.bytes_downloaded / p.total_bytes) * 100)
|
||||
downloadStalled.value = !!p.stalled
|
||||
return p.active
|
||||
}
|
||||
downloadStalled.value = false
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Shown next to the progress bar when the fake increment has maxed out
|
||||
// at 95% but the real RPC hasn't returned yet — lets the user know the
|
||||
// UI hasn't frozen while SHA verification and disk writes finish.
|
||||
const downloadFinishing = computed(() => downloading.value && downloadPercent.value >= 95)
|
||||
|
||||
// Install overlay state — drives the full-screen progress modal shown
|
||||
// while the backend swaps files, restarts, and comes back up on the
|
||||
// new version. The overlay polls /health and auto-reloads the browser
|
||||
// as soon as the backend reports the target version, so the user
|
||||
// doesn't need to manually refresh.
|
||||
type InstallStage = 'applying' | 'restarting' | 'reconnecting' | 'ready' | 'stalled'
|
||||
const installing = ref(false)
|
||||
const installStage = ref<InstallStage>('applying')
|
||||
const installTargetVersion = ref<string | null>(null)
|
||||
const installStartedAt = ref<number>(0)
|
||||
const installElapsedSec = ref(0)
|
||||
let installPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
const installElapsedLabel = computed(() => {
|
||||
const s = installElapsedSec.value
|
||||
if (s < 60) return `Elapsed: ${s}s`
|
||||
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
|
||||
})
|
||||
function startInstallOverlay(targetVersion: string) {
|
||||
installing.value = true
|
||||
installStage.value = 'applying'
|
||||
installTargetVersion.value = targetVersion
|
||||
installStartedAt.value = Date.now()
|
||||
installElapsedSec.value = 0
|
||||
// Tick an elapsed counter once per second for the UI.
|
||||
installElapsedTimer = setInterval(() => {
|
||||
installElapsedSec.value = Math.floor((Date.now() - installStartedAt.value) / 1000)
|
||||
// Stop polling after 3 min — surface the manual reload button.
|
||||
if (installElapsedSec.value >= 180 && installStage.value !== 'ready') {
|
||||
installStage.value = 'stalled'
|
||||
}
|
||||
}, 1000)
|
||||
// Start polling /health after a short delay — the backend restarts 2s
|
||||
// after replying to update.apply, so an immediate poll would see the
|
||||
// old backend and conclude nothing happened.
|
||||
setTimeout(() => {
|
||||
installStage.value = 'restarting'
|
||||
installPollTimer = setInterval(pollHealth, 1500)
|
||||
}, 2500)
|
||||
}
|
||||
async function pollHealth() {
|
||||
if (installStage.value === 'ready' || installStage.value === 'stalled') return
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
|
||||
if (!res.ok) throw new Error(`health ${res.status}`)
|
||||
const data = await res.json() as { version?: string }
|
||||
if (data.version && data.version === installTargetVersion.value) {
|
||||
installStage.value = 'ready'
|
||||
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
|
||||
// Brief pause so the user sees the "Ready" state before the reload.
|
||||
setTimeout(() => { window.location.reload() }, 1200)
|
||||
} else {
|
||||
// Backend is up but still reporting the old version — frontend
|
||||
// and backend are mid-swap. Signal to the user.
|
||||
installStage.value = 'reconnecting'
|
||||
}
|
||||
} catch {
|
||||
// Fetch fails while the server is mid-restart. Stay in 'restarting'.
|
||||
}
|
||||
}
|
||||
function reloadNow() { window.location.reload() }
|
||||
// Cleanup if the component is torn down mid-install (unlikely but safe).
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
onBeforeUnmount(() => {
|
||||
if (installPollTimer) clearInterval(installPollTimer)
|
||||
if (installElapsedTimer) clearInterval(installElapsedTimer)
|
||||
})
|
||||
|
||||
const lastCheckDisplay = computed(() => {
|
||||
if (!lastCheck.value) return t('common.never')
|
||||
try {
|
||||
@@ -300,11 +747,13 @@ async function loadStatus() {
|
||||
update_available: boolean
|
||||
update_in_progress: boolean
|
||||
rollback_available: boolean
|
||||
manifest_mirror: string | null
|
||||
}>({ method: 'update.status' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInProgress.value = res.update_in_progress
|
||||
rollbackAvailable.value = res.rollback_available
|
||||
manifestMirror.value = res.manifest_mirror ?? null
|
||||
|
||||
if (res.update_in_progress) {
|
||||
downloaded.value = true
|
||||
@@ -324,11 +773,13 @@ async function checkForUpdates() {
|
||||
update_available: boolean
|
||||
update: UpdateDetail | null
|
||||
update_method?: string
|
||||
manifest_mirror?: string | null
|
||||
}>({ method: 'update.check' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInfo.value = res.update
|
||||
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
|
||||
manifestMirror.value = res.manifest_mirror ?? null
|
||||
if (!res.update_available) {
|
||||
showStatus(t('systemUpdate.upToDateMessage'))
|
||||
}
|
||||
@@ -345,21 +796,19 @@ async function downloadUpdate() {
|
||||
downloadPercent.value = 0
|
||||
statusMessage.value = ''
|
||||
|
||||
// Simulate incremental progress while waiting for the RPC. Capped at
|
||||
// 95% so the bar never shows >100% before the real completion jumps it
|
||||
// to 100 — previously the random increment could overshoot.
|
||||
const progressInterval = setInterval(() => {
|
||||
if (downloadPercent.value < 95) {
|
||||
downloadPercent.value = Math.min(95, downloadPercent.value + Math.random() * 3)
|
||||
}
|
||||
}, 500)
|
||||
// Poll the backend's real byte counter every second instead of
|
||||
// faking progress. The backend exposes bytes_downloaded/total_bytes
|
||||
// via update.status, updated per chunk. This also means the bar
|
||||
// resumes correctly after navigating away and back — no more
|
||||
// "95% for some time" mystery.
|
||||
const progressInterval = setInterval(() => { void pollDownloadProgress() }, 1000)
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
total_bytes: number
|
||||
downloaded_bytes: number
|
||||
components_downloaded: number
|
||||
}>({ method: 'update.download', timeout: 1_800_000 })
|
||||
}>({ method: 'update.download', timeout: 3_900_000 })
|
||||
downloadPercent.value = 100
|
||||
downloaded.value = true
|
||||
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
||||
@@ -385,6 +834,10 @@ function requestRollback() {
|
||||
confirmAction.value = 'rollback'
|
||||
}
|
||||
|
||||
function requestCancelDownload() {
|
||||
confirmAction.value = 'cancel-download'
|
||||
}
|
||||
|
||||
function cancelConfirm() {
|
||||
confirmAction.value = null
|
||||
}
|
||||
@@ -395,40 +848,69 @@ async function executeConfirm() {
|
||||
if (action === 'apply') {
|
||||
await applyUpdate()
|
||||
} else if (action === 'git-apply') {
|
||||
await applyUpdateGit()
|
||||
await applyUpdateGitWithOverlay()
|
||||
} else if (action === 'rollback') {
|
||||
await rollbackUpdate()
|
||||
} else if (action === 'cancel-download') {
|
||||
await cancelDownload()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdateGit() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
async function cancelDownload() {
|
||||
cancelingDownload.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
|
||||
showStatus(t('systemUpdate.gitApplyStarted'))
|
||||
updateInfo.value = null
|
||||
await rpcClient.call({ method: 'update.cancel-download' })
|
||||
downloading.value = false
|
||||
downloaded.value = false
|
||||
downloadPercent.value = 0
|
||||
downloadStalled.value = false
|
||||
showStatus(t('systemUpdate.cancelDownloadSuccess'))
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Git apply failed', e)
|
||||
showStatus(t('systemUpdate.cancelDownloadFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Cancel download failed', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
cancelingDownload.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
const target = updateInfo.value?.version || null
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.apply', timeout: 300_000 })
|
||||
showStatus(t('systemUpdate.applySuccess'))
|
||||
updateInfo.value = null
|
||||
downloaded.value = false
|
||||
await loadStatus()
|
||||
// Apply succeeded. Backend scheduled a restart 2s after returning;
|
||||
// show the full-screen overlay while we wait for the new backend
|
||||
// to report the target version, then auto-reload.
|
||||
applying.value = false
|
||||
if (target) {
|
||||
startInstallOverlay(target)
|
||||
} else {
|
||||
// No target version known (legacy path) — fall back to the old
|
||||
// flash-and-reload behaviour.
|
||||
showStatus(t('systemUpdate.applySuccess'))
|
||||
setTimeout(() => window.location.reload(), 3000)
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdateGitWithOverlay() {
|
||||
// Git-apply (dev path) also restarts the service — reuse the overlay
|
||||
// so the UX matches the manifest path. Target version isn't known up
|
||||
// front for git-apply; we just wait for a version change on /health.
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
|
||||
applying.value = false
|
||||
startInstallOverlay(updateInfo.value?.version || currentVersion.value)
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Git apply failed', e)
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
@@ -465,7 +947,45 @@ async function setSchedule(value: ScheduleValue) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
|
||||
// If a download was already running when the user navigated here
|
||||
// (or refreshed), pick up the progress bar where it is and keep
|
||||
// polling until the backend reports done. No RPC call to start the
|
||||
// download — the backend's already running it.
|
||||
const active = await pollDownloadProgress()
|
||||
if (active) {
|
||||
downloading.value = true
|
||||
const resumeInterval = setInterval(async () => {
|
||||
const stillActive = await pollDownloadProgress()
|
||||
if (!stillActive) {
|
||||
clearInterval(resumeInterval)
|
||||
downloading.value = false
|
||||
downloaded.value = true
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Centered ASCII logo — clamped so the overlay doesn't blow out on
|
||||
narrow viewports. :deep so the rule reaches BitcoinFaceAscii's
|
||||
inner <pre>. */
|
||||
.install-overlay-ascii :deep(pre) {
|
||||
font-size: clamp(6px, 1.2vw, 12px);
|
||||
line-height: 1.1;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Indeterminate progress stripe that slides left-to-right. */
|
||||
.install-overlay-bar-anim {
|
||||
animation: installBarSlide 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes installBarSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(120%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,14 +99,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstalling progress — replaces action buttons -->
|
||||
<!-- Uninstalling progress — live stage label from backend -->
|
||||
<div v-else-if="isUninstalling" class="mt-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3 text-red-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-xs text-red-300">{{ t('common.uninstalling') }}...</span>
|
||||
<span class="text-xs text-red-300 truncate">{{ uninstallStageLabel }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
|
||||
@@ -251,6 +251,13 @@ const tier = computed(() => {
|
||||
return 'optional'
|
||||
})
|
||||
|
||||
// Live uninstall stage from backend, with a sensible fallback so the
|
||||
// label is never blank between WS pushes.
|
||||
const uninstallStageLabel = computed(() => {
|
||||
const raw = props.pkg['uninstall-stage']
|
||||
return raw ? raw : `${t('common.uninstalling')}…`
|
||||
})
|
||||
|
||||
const isTransitioning = computed(() => {
|
||||
const s = props.pkg.state
|
||||
const h = props.pkg.health
|
||||
|
||||
@@ -22,13 +22,13 @@ let cachedCatalog: AppCatalog | null = null
|
||||
let catalogFetchedAt = 0
|
||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Remote catalog URLs — tried in order. First success wins. */
|
||||
/** Catalog URLs tried in order. First success wins.
|
||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||
* bypasses CORS on git.tx1138.com and CSP restrictions on the IP-port
|
||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||
* to the static copy baked into the frontend build. */
|
||||
const CATALOG_URLS = [
|
||||
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
|
||||
'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||
// Fallback: direct IP (HTTP, only works if CSP allows http://$host:*)
|
||||
'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||
// Last resort: local static file (baked into frontend build)
|
||||
'/api/app-catalog',
|
||||
'/catalog.json',
|
||||
]
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
|
||||
for (const url of CATALOG_URLS) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (!res.ok) continue
|
||||
const data = await res.json() as AppCatalog
|
||||
if (!data.apps?.length) continue
|
||||
|
||||
@@ -96,20 +96,32 @@
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Installing — simple button-style indicator -->
|
||||
<button
|
||||
<!-- Installing — live progress with bar + message matching the
|
||||
update download bar's accuracy. Falls back to a simple
|
||||
spinner if no install_progress data is available yet. -->
|
||||
<div
|
||||
v-else-if="!installed && installing"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-80 cursor-wait"
|
||||
class="flex-1 flex flex-col gap-1.5"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<div
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-90 cursor-wait flex items-center justify-center gap-2 text-center"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Installing
|
||||
</span>
|
||||
</button>
|
||||
<span class="truncate">{{ installProgressMessage }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="(installProgress?.progress ?? 0) > 0"
|
||||
class="w-full h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-orange-400 transition-all duration-300"
|
||||
:style="{ width: (installProgress?.progress ?? 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
|
||||
data-controller-install-btn
|
||||
@@ -130,6 +142,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
||||
|
||||
@@ -154,6 +167,14 @@ defineEmits<{
|
||||
launch: [app: MarketplaceApp]
|
||||
}>()
|
||||
|
||||
const installProgressMessage = computed(() => {
|
||||
const p = props.installProgress
|
||||
if (!p) return 'Installing'
|
||||
// The store already formats messages like "Downloading: 50.5 / 200.0 MB (25%)"
|
||||
// so we just surface them directly.
|
||||
return p.message || 'Installing'
|
||||
})
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
|
||||
@@ -9,15 +9,53 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
||||
<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>
|
||||
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Seed anchors"
|
||||
aria-label="Open FIPS seed anchors settings"
|
||||
@click="showAnchorsModal = true"
|
||||
>
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seed anchors modal — operator-editable list of peers this node
|
||||
dials to bootstrap the mesh. Tucked behind the gear so it
|
||||
doesn't crowd the card but is still one click away. Close
|
||||
button and layout mirror the What's New modal (and the rest
|
||||
of the app) so it feels like a first-class modal. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showAnchorsModal"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="showAnchorsModal = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative z-10 max-w-xl w-full"
|
||||
style="max-height: 90vh; overflow-y: auto"
|
||||
@click.stop
|
||||
>
|
||||
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Daemon version</p>
|
||||
@@ -50,7 +88,7 @@
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
|
||||
<span class="text-white/70">Anchor (fips.v0l.io):</span>
|
||||
<span class="text-white/70">Anchor:</span>
|
||||
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
|
||||
{{ status.anchor_connected ? 'connected' : 'not reached' }}
|
||||
</span>
|
||||
@@ -68,7 +106,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
|
||||
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until it reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache.
|
||||
No known anchor is currently an authenticated peer. DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until one reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. Add a cluster-local anchor in Seed Anchors if the public one is unreachable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +122,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||
|
||||
interface FipsStatus {
|
||||
installed: boolean
|
||||
@@ -113,6 +152,7 @@ const reconnecting = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
const copied = ref(false)
|
||||
const showAnchorsModal = ref(false)
|
||||
|
||||
async function copyNpub() {
|
||||
if (!status.value.npub) return
|
||||
@@ -180,21 +220,31 @@ async function installAndActivate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the FIPS daemon to kick it back onto the public anchor. Stale
|
||||
// identity-cache entries are the usual cause of "not reached"; systemctl
|
||||
// restart clears them and re-runs the bootstrap handshake.
|
||||
// Restart the FIPS daemon and wait for the anchor bootstrap window.
|
||||
// The backend runs a proper recovery sequence (stop → start → wait →
|
||||
// classify) and returns a structured diagnostic we can show the user
|
||||
// instead of a generic "still unreachable".
|
||||
async function reconnectAnchor() {
|
||||
reconnecting.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'fips.restart', timeout: 45_000 })
|
||||
// Give the daemon a few seconds to come back and re-populate its
|
||||
// identity cache before we re-query status.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
await loadStatus()
|
||||
if (status.value.anchor_connected) {
|
||||
flash('Anchor reconnected')
|
||||
const res = await rpcClient.call<{
|
||||
recovered: boolean
|
||||
likely_cause: string
|
||||
hint: string
|
||||
after: FipsStatus
|
||||
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
||||
// Update the card with the post-reconnect status returned by the
|
||||
// backend — avoids an extra status fetch race.
|
||||
status.value = { ...status.value, ...res.after }
|
||||
if (res.recovered) {
|
||||
flash('Anchor reconnected.')
|
||||
} else if (res.likely_cause === 'connected') {
|
||||
// Already connected, not a "recovery" per se.
|
||||
flash('Anchor is reachable.')
|
||||
} else {
|
||||
flash('FIPS restarted — anchor still reporting unreachable. Check network / firewall.', true)
|
||||
// Surface the backend's diagnostic hint verbatim — it's been
|
||||
// written for the fleet reader.
|
||||
flash(res.hint || 'Reconnect finished but anchor is still unreachable.', true)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1 relative">
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors z-10"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0" :class="{ 'pr-10': closable }">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<!-- Radio/broadcast icon — three concentric arcs radiating from a
|
||||
dot. Reads as mesh, signal, anchor-reaching-peers. -->
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
|
||||
<circle cx="12" cy="18" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Seed Anchors</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors disabled:opacity-60"
|
||||
:disabled="applying"
|
||||
:title="applying ? 'Applying…' : 'Re-dial every anchor in the list'"
|
||||
@click="applyAll"
|
||||
>
|
||||
{{ applying ? 'Applying…' : 'Apply now' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Peers this node dials to bootstrap the FIPS mesh. A cluster with its own anchors doesn't depend on the global public anchor — if one is down, the next seeds the DHT instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
||||
|
||||
<div v-if="anchors.length === 0" class="p-4 rounded-lg bg-white/5 text-sm text-white/60 mb-3">
|
||||
<p>No seed anchors configured. The daemon will fall back to whatever the upstream FIPS build dials on its own — usually the single public anchor, which is fine until it isn't.</p>
|
||||
<p class="mt-2 text-white/50">Add at least one known-reachable peer (e.g. your VPS or a home node with port-forwarded UDP 8668) to make this cluster self-anchoring.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2 mb-3">
|
||||
<li v-for="a in anchors" :key="a.npub" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ a.label || 'Unlabeled anchor' }}</p>
|
||||
<p class="text-xs text-white/60 font-mono break-all">{{ a.npub.slice(0, 20) }}…{{ a.npub.slice(-8) }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">{{ a.address }} · {{ a.transport }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
:title="`Remove ${a.label || a.npub.slice(0, 12)}`"
|
||||
@click="removeAnchor(a.npub)"
|
||||
>Remove</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-auto pt-3 border-t border-white/10 shrink-0" @submit.prevent="addAnchor">
|
||||
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||
<span class="text-xs text-white/60">Anchor npub</span>
|
||||
<input v-model="draft.npub" type="text" placeholder="npub1…" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Address (host:port)</span>
|
||||
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Label (optional)</span>
|
||||
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
defineProps<{ closable?: boolean }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
interface SeedAnchor {
|
||||
npub: string
|
||||
address: string
|
||||
transport: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
npub: string
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
const anchors = ref<SeedAnchor[]>([])
|
||||
const adding = ref(false)
|
||||
const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
|
||||
npub: '',
|
||||
address: '',
|
||||
label: '',
|
||||
})
|
||||
|
||||
function flash(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||
anchors.value = res.seed_anchors
|
||||
} catch (e: unknown) {
|
||||
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addAnchor() {
|
||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||
adding.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[]; apply: ApplyResult[] }>({
|
||||
method: 'fips.add-seed-anchor',
|
||||
params: {
|
||||
npub: draft.npub.trim(),
|
||||
address: draft.address.trim(),
|
||||
transport: 'udp',
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
draft.npub = ''
|
||||
draft.address = ''
|
||||
draft.label = ''
|
||||
const applied = res.apply.find(r => r.ok)
|
||||
flash(applied ? 'Anchor added and dialed.' : 'Anchor saved — dial failed, will retry on the next apply cycle.', !applied)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Add failed: ${msg}`, true)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAnchor(npub: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||
method: 'fips.remove-seed-anchor',
|
||||
params: { npub },
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
flash('Anchor removed.')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAll() {
|
||||
applying.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ applied: number; results: ApplyResult[] }>({ method: 'fips.apply-seed-anchors' })
|
||||
const ok = res.results.filter(r => r.ok).length
|
||||
flash(`${ok} of ${res.applied} anchor${res.applied === 1 ? '' : 's'} dialed.`, ok === 0 && res.applied > 0)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Apply failed: ${msg}`, true)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -180,6 +180,373 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.30-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.30-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App installs now show a real download progress bar — same accuracy as the system update bar. You'll see "Downloading: 50.5 / 200.0 MB (25%)" with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a "stuck at 0%" again.</p>
|
||||
<p>Uninstalls now show what's actually happening: "Stopping containers (2/5)", "Cleaning up volumes", "Removing app data" — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.</p>
|
||||
<p>OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry — extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.29-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.29-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>New App registries page in Settings — same experience as Update mirrors, but for the container registries your node pulls app images from. Add a mirror, test reachability with one click, pick the primary.</p>
|
||||
<p>New nodes default to the VPS registry as the primary for both app installs and the app catalog, with tx1138 as the automatic fallback if the VPS is slow or unreachable. Existing nodes keep whatever registry order they've already set.</p>
|
||||
<p>App installs now genuinely honor the primary registry: the first pull attempt rewrites the image URL to use your primary, and only falls through to the secondary if that fails. Before, installs always hit whichever registry the image was hardcoded to.</p>
|
||||
<p>Reboot screen now shows the animated "a" logo in the center of the ring — matching the screensaver's look so you get something nice to watch while the node comes back up.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.28-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.28-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Reboot now shows a proper progress screen. Click Reboot and you'll see a full-screen overlay with the familiar pulsing ring animation, a rebooting / reconnecting / back-online status, and an elapsed counter — no more black screen of mystery while you wait.</p>
|
||||
<p>The overlay auto-reloads the page the moment your node is back up; if it takes longer than three minutes it surfaces a manual Reload button.</p>
|
||||
<p>New nodes now default to the VPS mirror as Server 1 (primary) and tx1138 as Server 2 (fallback). Existing nodes keep whatever mirror order they've already set — use Set Primary on the System Update page to change it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.27-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.27-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The Update page now shows which mirror delivered your update — a small "Served by" line under the new version tells you whether Server 1, Server 2, or a custom mirror was the one your node actually reached. Great for spot-checking that mirror fallback is doing its job.</p>
|
||||
<p>Every mirror row has a new lightning-bolt button that pings the mirror and shows whether it's reachable, plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is responding.</p>
|
||||
<p>The Update mirrors section got a visual refresh: Set Primary, Remove, and the new Test action are compact icon buttons instead of crowded text, and adding a mirror now happens in a dedicated dialog that matches the rest of the UI.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.26-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.26-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Update downloads now have a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and downloads the files from there — no more waiting on a stalled server with no recourse.</p>
|
||||
<p>A new 'Update mirrors' section on the System Update page lets you see the list, add your own mirror URL, reorder which is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.</p>
|
||||
<p>Downloads automatically follow the mirror that served the manifest. Previously every mirror served the same manifest, and the manifest's download URLs were hardcoded to a single server — so even picking a faster mirror couldn't speed up the actual download. Now the backend rewrites download URLs to match whichever mirror succeeded.</p>
|
||||
<p>Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Add the URL format <code>https://host/.../releases/manifest.json</code> for custom mirrors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.25-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.25-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Your node can now reach the broader FIPS public mesh, not just your own federated cluster. The FIPS daemon now binds both UDP (fast mesh forwarding) and TCP (NAT-friendly bootstrap) transports — matching the upstream factory default. The public anchor currently answers on TCP, so UDP-only nodes couldn't reach it; this fixes that without any action needed on your end.</p>
|
||||
<p>Upgrading the config happens automatically. On next startup, if the installed FIPS yaml doesn't match the new two-transport schema, the node reinstalls and restarts the daemon so the TCP transport comes online. No manual Reconnect required.</p>
|
||||
<p>Side benefit: TCP also helps on networks that block outbound UDP (corporate, some guest wifi) — your node falls back to TCP/8443 automatically and still joins the mesh.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.24-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.24-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Frontend updates now actually ship. Since roughly v1.7.17 the release pipeline had been rebuilding the backend every version but silently skipping the frontend bundle — a permissions issue on the build server meant vue-tsc failed before vite ever ran, and nobody noticed because the published tarballs still extracted cleanly. The result was the backend moving forward while the UI stayed frozen at its v1.7.9-era state, which is why the FIPS gear icon and the What's New entries for every release since then had been missing on your node.</p>
|
||||
<p>Once this update applies, your node gets the real v1.7.24 frontend: the FIPS Seed Anchors modal (gear icon on the FIPS Mesh card), the current What's New history, the cancel-download button, and every other UI touch from the releases in between.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.23-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.23-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>FIPS Seed Anchors are now one click away. A small gear icon sits next to the status pill on the FIPS Mesh card — click it to open a modal where you can add, remove, and re-apply anchors. No more needing to go digging for the card or editing JSON by hand.</p>
|
||||
<p>The modal lists each anchor with its label, truncated npub, address, and transport, plus an Apply button to force-redial the full list and a Remove button per entry. The add form right below validates that the address is host:port and the npub is bech32 before saving.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.22-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.22-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The FIPS Reconnect and Restart buttons now work on every node, regardless of which systemd unit is actually supervising the daemon. Previously they targeted only the archipelago-managed unit — nodes that were running the upstream unit instead saw the buttons silently do nothing. Both paths now auto-detect which unit is up and act on that one.</p>
|
||||
<p>The FIPS anchor status no longer shows red just because one specific public anchor is unreachable. It now lights green whenever any authenticated peer is a recognised anchor — that's either the public anchor or something you added under Seed Anchors. A federated cluster that routes through its own seed anchor finally reports the truth.</p>
|
||||
<p>Reconnect also re-pushes your seed anchors after the restart, so you don't have to wait five minutes for the background apply loop to re-dial them.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.21-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.21-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>FIPS bootstrap no longer depends on a single public anchor. You can now add your own anchors — other archipelago nodes or a VPS you control — and the node will dial every one of them to join the mesh on startup. If one anchor is down, the next one seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.</p>
|
||||
<p>Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label so you can remember which is which.</p>
|
||||
<p>No behavior change if you don't configure any — the upstream daemon's own defaults keep working as before. This purely adds an operator-controlled list on top.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.20-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.20-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed a critical bug where nodes on the automatic daily-update schedule could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler had a chance to bring the new version back up — leaving the node dead until someone SSH'd in and started it manually. The scheduler now hands off cleanly to the same restart path the 'Install Update' button uses, so auto-applied updates come back online on their own.</p>
|
||||
<p>Applies to any node configured for 'Check & Apply Daily' — no change required on your end, the fix ships with this update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.19-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.19-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Your node no longer offers a version you've already passed as an "available update". If you sideload or skip a release, any stored pointer to an earlier version is dropped on next restart, and the System Update page offers only the genuinely newer release — no more seeing an older version listed as something to install.</p>
|
||||
<p>Version comparison is now numeric, not alphabetic. 1.7.10 correctly outranks 1.7.9 (earlier naive string-order would have got this backwards once the patch number hits double digits), so update prompts and "up to date" checks stay accurate past the nines.</p>
|
||||
<p>A stale manifest from a slow cache or proxy can no longer downgrade your node. If the manifest reports a version equal to or behind what's running, your node treats that as "up to date" rather than offering the older version as an update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.18-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.18-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nodes discovered through a trusted peer now land as Trusted instead of Observer. When your federated peer shares its own peer list with you, those nodes get the same trust level as a direct invite — the link they came through is already one you vetted, so you no longer need to promote them by hand before they can be used normally.</p>
|
||||
<p>The update flow now writes clearer logs at every step. Start of download, cancel, and apply each emit a one-line entry to the system journal with the staging path and the affected files, so if a download misbehaves on your node it's easy to see exactly where it got to.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.17-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.17-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>When a download gets stuck, you can now cancel it. A new Cancel Download button sits next to the progress bar — it stops the transfer, clears the partial file, and returns you to a clean state so you can retry. No more staring at a frozen bar with no way to recover.</p>
|
||||
<p>Downloads that stall for 30 seconds or more now say so. The progress bar turns amber and shows 'Download appears stuck — try Cancel and start again' instead of just sitting silently at whatever percent it reached.</p>
|
||||
<p>Canceling is fast. It no longer has to wait out the retry timer — the download bails within half a second, so you're not stuck watching a stuck screen while you wait to unstick it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.16-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.16-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Federation is now bidirectional and instant. When someone joins using your invite code, their node appears on your Federation page automatically — no need for the inviter to click Sync or wait for the next poll. Names and node details populate within seconds of the handshake finishing.</p>
|
||||
<p>New nodes can no longer federate with themselves. Accepting an invite that points back at the local node (by DID, public key, or onion address) is rejected up front, so self-peering no longer clutters the node list with a duplicate card.</p>
|
||||
<p>Transitive discovery: if nodes A and B are already federated and node C joins A, all three nodes now learn about each other. The new peer is pulled in as an Observer entry on existing federation members, so you can promote to Trusted with one click instead of trading a second invite code.</p>
|
||||
<p>The Federation page auto-refreshes every five seconds while it's open. Status changes, new peers, and incoming join requests surface on their own — clicking Sync remains available for an on-demand pull.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.15-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.15-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p>
|
||||
<p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p>
|
||||
<p>Update check itself retries on slow responses. If git.tx1138.com is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.14-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.14-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Installing an update now shows a full-screen progress overlay with the Archipelago logo, a status message, and an animated bar. The page reloads itself automatically once the new version is up — no manual refresh. If something stalls, a 'Reload now' button appears after a few minutes.</p>
|
||||
<p>Download progress no longer looks frozen near the end. The bar pauses at 95% with a 'Finishing download — verifying checksum…' message and spinner while the last bytes arrive and are hashed.</p>
|
||||
<p>FIPS Reconnect now genuinely tries to fix the anchor. It runs a proper recovery sequence (stop → start → wait for the bootstrap window → check peers) and tells you the likely reason it's still unreachable — corrupt identity key, seed not unlocked, network blocking UDP, or the anchor server being down — instead of a generic 'try again'.</p>
|
||||
<p>Healed a latent FIPS identity bug: the public-key file was being written in text form (an 'npub1…' string) on some nodes, which the daemon couldn't parse and silently authenticated with a garbage key. The Reconnect button now rewrites the file in the correct binary format and re-installs the config before restarting — nodes stuck with no peers for 'no reason' should come back online.</p>
|
||||
<p>AIUI (Claude sidebar) is back. The installer now ships AIUI in the frontend bundle and preserves it across future updates — it was being wiped on every OTA because it lived outside the Vue build.</p>
|
||||
<p>Installing a big app (IndeedHub, Bitcoin, Penpot) no longer gives up early and shows 'didn't work' while the download is still running in the background. The client waits up to 45 minutes for the install pipeline to finish.</p>
|
||||
<p>'Rollback to Previous' is now labelled 'Rollback Available' — clearer that it's a choice you have, not a status you're stuck with.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.13-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.13-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App catalog now loads reliably. Before, the Marketplace / Discover page couldn't fetch the catalog of apps because the upstream host wasn't sending the right CORS headers and the node's security policy didn't allow the fallback URL either. The node now fetches the catalog server-side and serves it same-origin to the browser — no more blank app lists.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.12-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.12-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nothing new — version bump so freshly-installed nodes (from the 1.7.11 ISO) have something to OTA down, confirming the end-to-end update pipeline out of the box.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.11-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.11-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>OTA proof release — first version where Install Update should run clean from the UI with no manual steps. Click it and watch the sidebar flip to 1.7.11-alpha on its own.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.10-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.10-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update actually applies now. The installer had to write into system folders that the backend service was sandboxed out of — every earlier 'Failed to apply update' was a layer of that onion. Fixed by running the file swaps in a separate system context.</p>
|
||||
<p>FIPS status on the Home and Server pages now reflects whether the public anchor is reachable. You'll see 'Active · N peers' (green) when healthy or 'No anchor' (orange) when the network is blocking the bootstrap — same signal as the full FIPS card.</p>
|
||||
<p>Pasting an https://… URL into the profile picture or banner now previews correctly. Before, if the URL failed to load, the UI would silently blank out instead of showing your initial as a placeholder.</p>
|
||||
<p>Uploaded profile pictures under 64 KB are now embedded directly in your Nostr profile (as a data URL), so any Nostr client can see them — not just ones routing over Tor. Larger uploads keep the onion URL for now, with a hint to paste a public URL for wider visibility.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.9-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.9-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>OTA verification release — nothing new to see. Click Install Update, grab a coffee, and watch the sidebar flip to 1.7.9-alpha on its own. If this one works end to end, the pipeline is solid and future updates will flow the same way.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.8-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.8-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update finally works end-to-end over the air. The installer was trying to overwrite the running backend binary with a tool that fails on in-use files (ETXTBSY) — swapped it for an atomic rename, which the kernel allows on a live executable. Every previous 'Failed to apply update' attempt was this one root cause.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.7-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.7-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Over-the-air update test — no feature changes, just a version bump so your node can walk through the whole update flow end-to-end using the new robust installer. Safe to apply; nothing to do afterwards.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.6-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.6-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update is now more robust. Each install gets its own uniquely-named staging folder and then moves files into place — the previous version had a small cleanup step that could hit a transient filesystem hiccup and bail out halfway. You'll also still see a rollback folder after a successful install.</p>
|
||||
<p>Dev-box OTA: nodes that build archipelago from source can now opt into the standard Download → Install flow instead of Pull & Rebuild, by setting ARCHIPELAGO_UPDATE_URL in the service environment. Useful when the dev machine has a checked-out repo but you want to test the regular update path.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.5-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.5-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Over-the-air update test — no feature changes, just a fresh version number so your node can walk through the whole update flow end-to-end: check, download, install, auto-restart. Safe to apply; nothing to do afterwards.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.4-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.4-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update actually installs now. Before, the final step extracted the new UI into the wrong folder and bailed with 'Failed to apply update' — your node ended up backing up cleanly but never swapping in the new files. Fixed.</p>
|
||||
<p>Download progress no longer overshoots 100%. You'll see the bar climb smoothly to 95% and then jump to 100% when the download actually finishes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.3-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.3-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The version number in the sidebar now always matches the actual running version — no more lying to you about being on an older release after an update.</p>
|
||||
<p>FIPS Mesh card on the server page: cleaner layout on desktop (no more awkward gaps), and a one-click Reconnect button when the public anchor is unreachable — it restarts the FIPS daemon so it can re-bootstrap from the anchor.</p>
|
||||
<p>Profile pictures now show correctly in the identity list and editor. Before, uploaded images silently failed to render because the URL was only reachable over Tor; the UI now rewrites them to a local path while keeping the external URL for other Nostr clients.</p>
|
||||
<p>Identity rows now show your Display Name first (from your Nostr profile) with the internal identity name beside it in parentheses, so you see the name other people will see — not just the one you picked when creating it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.2-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.2-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Install Update now actually installs. Before, the button would back up your current version then fail with 'Failed to apply update' because the installer couldn't write into system folders.</p>
|
||||
<p>The button's also been renamed to 'Install Update' (previously 'Apply Update') and the node restarts itself a moment after you click it — no more manual restart step.</p>
|
||||
<p>Your existing identities now show the generated avatar instead of just their initials — same look as freshly created ones.</p>
|
||||
<p>Everything from 1.7.0-alpha and 1.7.1-alpha carries over (default avatars on creation, one-click Save publishes to Nostr relays, public blob URLs for profile pictures, 30-minute download window, VPN peer restore on reboot, reconciler-only-repairs, filebrowser fix).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.1-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.1-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Over-the-air update test — same features as 1.7.0, just a fresh version number so your node can try the new download-and-apply flow end-to-end. Safe to apply; nothing to do afterwards.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.0-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.0-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Every identity now gets a personal avatar the moment it's created. Your main node identity gets a distinctive hexagonal-network icon; other identities get a colourful generated pattern unique to each one.</p>
|
||||
<p>Profile editor: upload a profile picture and a banner, then tap Save — your Nostr profile now goes out to the relays in one step. No more 'Save' vs 'Save & Publish' confusion.</p>
|
||||
<p>Profile pictures and banners you upload are now reachable by other Nostr clients across the network — not just your own browser. Anyone who sees your profile on a relay can load the image.</p>
|
||||
<p>Update downloads on slow connections no longer cut out right at the end. The client waits up to 30 minutes for each component instead of giving up after 15 seconds.</p>
|
||||
<p>When you move a node to a new version without going through Check for Updates (for example via a reinstall or manual copy), it now reports the new version correctly instead of endlessly saying 'update available'.</p>
|
||||
<p>Your VPN peers come back automatically after a reboot. No more rescanning QR codes on your phone or laptop.</p>
|
||||
<p>Fresh installs stay lean — only File Browser is included out of the box. Other apps wait in the Marketplace until you pick them.</p>
|
||||
<p>File Browser stops rebooting itself every few hours — the housekeeper now leaves it alone once it's healthy.</p>
|
||||
<p>One-click 'Pull & Rebuild' button works for nodes that update from source (the development path), not just the standard download path.</p>
|
||||
<p>The download progress number is now clean (like 45.23%) instead of 45.270894%.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.3.5 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- App Registries Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
Choose the primary registry for app installs and add mirrors for fallback.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/settings/registries"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 12V3m0 0l-4 4m4-4l4 4" />
|
||||
</svg>
|
||||
Manage registries
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -13,6 +15,59 @@ const rebooting = ref(false)
|
||||
const rebootPassword = ref('')
|
||||
const rebootError = ref('')
|
||||
|
||||
// Reboot overlay — full-screen progress shown once the reboot is committed.
|
||||
// Mirrors the update overlay pattern in SystemUpdate.vue: poll /health,
|
||||
// auto-reload when the backend returns, stall fallback at 3 min.
|
||||
type RebootStage = 'rebooting' | 'reconnecting' | 'ready' | 'stalled'
|
||||
const rebootOverlay = ref(false)
|
||||
const rebootStage = ref<RebootStage>('rebooting')
|
||||
const rebootStartedAt = ref(0)
|
||||
const rebootElapsedSec = ref(0)
|
||||
let rebootPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let rebootElapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
const rebootElapsedLabel = computed(() => {
|
||||
const s = rebootElapsedSec.value
|
||||
if (s < 60) return `Elapsed: ${s}s`
|
||||
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
|
||||
})
|
||||
|
||||
function startRebootOverlay() {
|
||||
rebootOverlay.value = true
|
||||
rebootStage.value = 'rebooting'
|
||||
rebootStartedAt.value = Date.now()
|
||||
rebootElapsedSec.value = 0
|
||||
rebootElapsedTimer = setInterval(() => {
|
||||
rebootElapsedSec.value = Math.floor((Date.now() - rebootStartedAt.value) / 1000)
|
||||
if (rebootElapsedSec.value >= 180 && rebootStage.value !== 'ready') {
|
||||
rebootStage.value = 'stalled'
|
||||
}
|
||||
}, 1000)
|
||||
// Start health polling after 2.5s — the kernel has to go down before
|
||||
// /health can disappear, and we don't want to see the pre-reboot health
|
||||
// reply and mis-report "ready".
|
||||
setTimeout(() => {
|
||||
rebootStage.value = 'reconnecting'
|
||||
rebootPollTimer = setInterval(pollRebootHealth, 1500)
|
||||
}, 2500)
|
||||
}
|
||||
async function pollRebootHealth() {
|
||||
if (rebootStage.value === 'ready' || rebootStage.value === 'stalled') return
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
|
||||
if (!res.ok) throw new Error(`health ${res.status}`)
|
||||
rebootStage.value = 'ready'
|
||||
if (rebootPollTimer) { clearInterval(rebootPollTimer); rebootPollTimer = null }
|
||||
setTimeout(() => { window.location.reload() }, 1200)
|
||||
} catch {
|
||||
// Fetch failing is the normal state while the host is down.
|
||||
}
|
||||
}
|
||||
function rebootReloadNow() { window.location.reload() }
|
||||
onBeforeUnmount(() => {
|
||||
if (rebootPollTimer) clearInterval(rebootPollTimer)
|
||||
if (rebootElapsedTimer) clearInterval(rebootElapsedTimer)
|
||||
})
|
||||
|
||||
async function performReboot() {
|
||||
if (!rebootPassword.value) return
|
||||
rebooting.value = true
|
||||
@@ -21,6 +76,7 @@ async function performReboot() {
|
||||
await rpcClient.call({ method: 'system.reboot', params: { password: rebootPassword.value } })
|
||||
showRebootConfirm.value = false
|
||||
rebootPassword.value = ''
|
||||
startRebootOverlay()
|
||||
} catch (e) {
|
||||
rebootError.value = e instanceof Error ? e.message : 'Reboot failed'
|
||||
rebooting.value = false
|
||||
@@ -108,6 +164,55 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Reboot Progress Overlay -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="rebootOverlay"
|
||||
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
|
||||
>
|
||||
<!-- Centered animated ring + logo — same composition as the screensaver -->
|
||||
<div class="reboot-ring-content">
|
||||
<ScreensaverRing />
|
||||
<div class="reboot-logo-wrapper">
|
||||
<ScreensaverLogo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage text + progress bar underneath -->
|
||||
<div class="mt-8 w-[min(520px,80vw)] text-center">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">
|
||||
{{ rebootStage === 'rebooting' ? 'Rebooting…'
|
||||
: rebootStage === 'reconnecting' ? 'Reconnecting to your node…'
|
||||
: rebootStage === 'ready' ? 'Back online'
|
||||
: 'Reboot is taking longer than expected' }}
|
||||
</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Your node is restarting. This page will refresh automatically once it's back.
|
||||
</p>
|
||||
|
||||
<!-- Animated progress bar: indeterminate stripe while working,
|
||||
solid green when ready, paused at half while stalled. -->
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
|
||||
<div v-if="rebootStage === 'ready'" class="absolute inset-0 bg-green-400"></div>
|
||||
<div v-else-if="rebootStage === 'stalled'" class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"></div>
|
||||
<div v-else class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full reboot-overlay-bar-anim"></div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/40">{{ rebootElapsedLabel }}</p>
|
||||
|
||||
<button
|
||||
v-if="rebootStage === 'stalled'"
|
||||
@click="rebootReloadNow"
|
||||
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
>
|
||||
Reload now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Factory Reset Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6 border border-red-500/30">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
@@ -148,3 +253,37 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.reboot-ring-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.reboot-logo-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.reboot-overlay-bar-anim {
|
||||
animation: rebootBarSlide 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rebootBarSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(120%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
@@ -14,6 +15,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<ClaudeAuthSection />
|
||||
<AIDataAccessSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<BackupSection />
|
||||
|
||||
@@ -68,8 +68,13 @@
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
||||
<img
|
||||
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
|
||||
:src="displayableUrl(identity.profile.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="() => { listPictureFailed[identity.id] = true }"
|
||||
/>
|
||||
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
@@ -302,8 +307,14 @@
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
||||
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
v-if="profileForm.picture && !editorPictureFailed"
|
||||
:src="displayableUrl(profileForm.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="editorPictureFailed = true"
|
||||
@load="editorPictureFailed = false"
|
||||
/>
|
||||
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +379,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
@@ -409,6 +420,18 @@ const profilePublishing = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
const bannerUploading = ref(false)
|
||||
|
||||
// Track image load failures so the UI can fall back to the initial/
|
||||
// identicon placeholder instead of showing a blank square. Pasted URLs
|
||||
// that 404 (or point at an onion the local browser can't reach) were
|
||||
// previously silently hidden by a display:none handler that left the
|
||||
// fallback unrendered.
|
||||
const editorPictureFailed = ref(false)
|
||||
const listPictureFailed = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Reset the failure flag when the URL changes so a freshly pasted URL
|
||||
// gets re-tried (the watcher fires once the form reacts).
|
||||
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
|
||||
|
||||
// The backend returns onion-based public URLs for uploaded profile
|
||||
// pictures (so they're fetchable by external Nostr clients), but the
|
||||
// local browser session isn't Tor-routed and can't resolve .onion hosts.
|
||||
@@ -423,10 +446,12 @@ function displayableUrl(url: string | null | undefined): string {
|
||||
return url
|
||||
}
|
||||
|
||||
// Upload to the node's blob store and drop the returned public URL into
|
||||
// the profile field. The /api/blob endpoint marks these blobs public, so
|
||||
// the URL served back (`public_url`, onion-rooted when Tor is up) is
|
||||
// reachable by external Nostr clients fetching kind:0 metadata.
|
||||
// Upload to the node's blob store and drop a URL into the profile field.
|
||||
// For small images (≤64KB) we inline the bytes as a data URL so external
|
||||
// Nostr clients can render the picture without needing to reach a tor
|
||||
// onion. Larger uploads fall back to the onion-rooted public_url.
|
||||
const INLINE_MAX = 64 * 1024
|
||||
|
||||
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input?.files?.[0]
|
||||
@@ -436,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
profileError.value = ''
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
// Inline small images as a data URL — universally fetchable by any
|
||||
// Nostr client and bypasses the "only reachable over Tor" limitation.
|
||||
if (buf.byteLength <= INLINE_MAX) {
|
||||
const mime = file.type || 'image/png'
|
||||
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
|
||||
profileForm.value[field] = `data:${mime};base64,${b64}`
|
||||
return
|
||||
}
|
||||
const resp = await fetch('/api/blob', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -451,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
const url = public_url || self_test_url
|
||||
if (!url) throw new Error('blob API returned no URL')
|
||||
profileForm.value[field] = url
|
||||
// Heads-up for large uploads: onion URLs only render on Tor-routed
|
||||
// clients. Not an error, but worth telling the user.
|
||||
if (url.includes('.onion/')) {
|
||||
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
||||
} finally {
|
||||
|
||||
@@ -94,7 +94,7 @@ export default defineConfig({
|
||||
urlPattern: /\/assets\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'assets-cache-v2',
|
||||
cacheName: 'assets-cache-v3',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
|
||||
@@ -1216,44 +1216,6 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
|
||||
fi
|
||||
fi
|
||||
|
||||
# 8b. Indeehub (pull from registry, or use local build)
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
|
||||
# Use image-versions.sh variable if sourced, otherwise detect
|
||||
if [ -z "${INDEEDHUB_IMAGE:-}" ]; then
|
||||
INDEEDHUB_IMAGE=""
|
||||
# Try local image first (pre-built or loaded from ISO)
|
||||
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'localhost/indeedhub'; then
|
||||
INDEEDHUB_IMAGE="localhost/indeedhub:local"
|
||||
# Try pinned registry image
|
||||
elif $DOCKER pull "$ARCHY_REGISTRY/indeedhub:1.0.0" --tls-verify=false 2>>"$LOG"; then
|
||||
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$INDEEDHUB_IMAGE" ]; then
|
||||
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
|
||||
$DOCKER run -d --name indeedhub --restart unless-stopped \
|
||||
--network archy-net --network-alias indeedhub \
|
||||
--health-cmd="curl -sf http://localhost:7777/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
|
||||
--memory=$(mem_limit indeedhub) \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
|
||||
-p 7778:7777 \
|
||||
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
|
||||
# Fix IndeedHub for iframe: remove X-Frame-Options so it loads in Archipelago panel
|
||||
sleep 2
|
||||
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
|
||||
$DOCKER exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||
# Fix Host header for NIP-98 auth — $host strips port, $http_host preserves it
|
||||
$DOCKER exec indeedhub sed -i 's|proxy_set_header Host $host;|proxy_set_header Host $http_host;|g' /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
|
||||
$DOCKER cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
|
||||
fi
|
||||
$DOCKER exec indeedhub nginx -s reload 2>/dev/null || true
|
||||
log "Applied IndeedHub iframe fix (X-Frame-Options, Host header, nostr-provider)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 9. Custom UI containers (bitcoin-ui, lnd-ui)
|
||||
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user