Compare commits
6 Commits
v1.7.9-alp
...
v1.7.15-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fad7ee431 | ||
|
|
923c404678 | ||
|
|
30a26f94f7 | ||
|
|
26d6eddb1c | ||
|
|
c9f6697f02 | ||
|
|
b8ab06dd47 |
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.9-alpha"
|
||||
version = "1.7.15-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.9-alpha"
|
||||
version = "1.7.15-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] = &[
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
"http://23.182.128.160:3000/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
|
||||
|
||||
@@ -413,6 +413,7 @@ 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,
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
|
||||
@@ -44,4 +44,89 @@ impl RpcHandler {
|
||||
fips::service::restart(fips::SERVICE_UNIT).await?;
|
||||
Ok(serde_json::json!({ "restarted": true }))
|
||||
}
|
||||
|
||||
/// 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(&identity_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;
|
||||
}
|
||||
|
||||
// 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 _ = fips::service::stop(fips::SERVICE_UNIT).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
|
||||
fips::service::activate(fips::SERVICE_UNIT).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(&identity_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" => "Anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check archipelago-fips.service.",
|
||||
"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 the anchor (fips.v0l.io) could be down.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but the anchor hasn't been seen yet. Give it a minute and re-check.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"recovered": recovered,
|
||||
"likely_cause": likely_cause,
|
||||
"hint": hint,
|
||||
"before": before,
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ 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;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
@@ -164,6 +174,13 @@ impl RpcHandler {
|
||||
"update_available": state.available_update.is_some(),
|
||||
"update_in_progress": state.update_in_progress,
|
||||
"rollback_available": state.rollback_available,
|
||||
"download_progress": if active || completed {
|
||||
Some(serde_json::json!({
|
||||
"bytes_downloaded": downloaded,
|
||||
"total_bytes": total,
|
||||
"active": active,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -78,11 +78,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])
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -4,9 +4,17 @@ use anyhow::{Context, Result};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Live download progress counters. Updated by download_component_resumable
|
||||
/// as bytes arrive and read by the update.status RPC so the UI can show
|
||||
/// a real progress bar instead of a fake creep. Global because the
|
||||
/// download runs in one place at a time; no need for per-handler state.
|
||||
pub static DOWNLOAD_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
pub static DOWNLOAD_TOTAL: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
@@ -111,36 +119,57 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
||||
let mut state = load_state(data_dir).await?;
|
||||
|
||||
info!("Checking for updates...");
|
||||
// 45s total budget, and we retry up to 3 times so a momentary
|
||||
// gitea hiccup doesn't make the node report "up to date" when an
|
||||
// update actually exists. Short per-attempt timeout keeps the RPC
|
||||
// responsive in the common case.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let manifest_url = update_manifest_url();
|
||||
match client.get(&manifest_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let manifest: UpdateManifest = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse update manifest")?;
|
||||
|
||||
if manifest.version != state.current_version {
|
||||
info!(
|
||||
current = %state.current_version,
|
||||
available = %manifest.version,
|
||||
"Update available"
|
||||
);
|
||||
state.available_update = Some(manifest);
|
||||
} else {
|
||||
debug!("Already on latest version: {}", state.current_version);
|
||||
state.available_update = None;
|
||||
let mut last_err: Option<String> = None;
|
||||
let mut handled = false;
|
||||
for attempt in 1..=3u8 {
|
||||
if attempt > 1 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
match client.get(&manifest_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.json::<UpdateManifest>().await {
|
||||
Ok(manifest) => {
|
||||
if manifest.version != state.current_version {
|
||||
info!(
|
||||
current = %state.current_version,
|
||||
available = %manifest.version,
|
||||
"Update available"
|
||||
);
|
||||
state.available_update = Some(manifest);
|
||||
} else {
|
||||
debug!("Already on latest version: {}", state.current_version);
|
||||
state.available_update = None;
|
||||
}
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(format!("parse: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
last_err = Some(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
debug!("Update check returned status: {}", resp.status());
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Update check failed (offline?): {}", e);
|
||||
}
|
||||
if !handled {
|
||||
if let Some(e) = last_err {
|
||||
debug!("Update check failed after retries: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +192,14 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
/// Download update components to a staging directory.
|
||||
/// Verifies SHA256 hash for each component.
|
||||
///
|
||||
/// Robustness: each component download is **resumable** via HTTP Range
|
||||
/// requests and retried up to 6 times with exponential backoff. When
|
||||
/// gitea drops the connection mid-stream (happens regularly at slow
|
||||
/// raw-file throughput), the next attempt picks up where the previous
|
||||
/// one left off instead of restarting from byte zero. SHA256 is
|
||||
/// verified over the complete file at the end of each component, so a
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let state = load_state(data_dir).await?;
|
||||
let manifest = state
|
||||
@@ -176,7 +213,9 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
.context("Failed to create staging dir")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(1800))
|
||||
// Per-request budget; each attempt gets the full hour. A retry
|
||||
// restarts the budget cleanly.
|
||||
.timeout(std::time::Duration::from_secs(3600))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
@@ -184,49 +223,20 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let mut downloaded = 0u64;
|
||||
let total_bytes: u64 = manifest.components.iter().map(|c| c.size_bytes).sum();
|
||||
|
||||
// Seed the live counters so polls during the handshake show the
|
||||
// right denominator immediately instead of 0/0 → NaN%.
|
||||
DOWNLOAD_TOTAL.store(total_bytes, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
|
||||
for component in &manifest.components {
|
||||
info!(name = %component.name, url = %component.download_url, "Downloading component");
|
||||
|
||||
let resp = client
|
||||
.get(&component.download_url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to download {}", component.name))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"Download failed for {}: HTTP {}",
|
||||
component.name,
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("Failed to read {}", component.name))?;
|
||||
|
||||
// Verify SHA256
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash != component.sha256 {
|
||||
anyhow::bail!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
component.sha256,
|
||||
hash
|
||||
);
|
||||
}
|
||||
|
||||
let dest = staging_dir.join(&component.name);
|
||||
fs::write(&dest, &bytes)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write {}", component.name))?;
|
||||
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
downloaded += component.size_bytes;
|
||||
DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed);
|
||||
info!(
|
||||
name = %component.name,
|
||||
bytes = bytes.len(),
|
||||
bytes = component.size_bytes,
|
||||
"Component downloaded and verified"
|
||||
);
|
||||
}
|
||||
@@ -244,6 +254,192 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Download a single component to `dest`, resuming from the end of
|
||||
/// any existing partial file via a Range request. Retries up to 6
|
||||
/// times with exponential backoff (5s, 15s, 30s, 60s, 120s, 180s).
|
||||
/// Verifies the SHA256 over the full file at the end.
|
||||
async fn download_component_resumable(
|
||||
client: &reqwest::Client,
|
||||
component: &ComponentUpdate,
|
||||
dest: &Path,
|
||||
prior_total: u64,
|
||||
) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
const MAX_ATTEMPTS: u32 = 6;
|
||||
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let existing_len = match tokio::fs::metadata(dest).await {
|
||||
Ok(m) => m.len(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
if existing_len >= component.size_bytes {
|
||||
// File is already complete — break out and go verify.
|
||||
break;
|
||||
}
|
||||
if attempt > 1 {
|
||||
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
|
||||
tracing::warn!(
|
||||
name = %component.name,
|
||||
attempt,
|
||||
resume_at = existing_len,
|
||||
"Retrying download in {}s (previous error: {})",
|
||||
delay,
|
||||
last_err.as_ref().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
}
|
||||
|
||||
let mut req = client.get(&component.download_url);
|
||||
if existing_len > 0 {
|
||||
req = req.header("Range", format!("bytes={}-", existing_len));
|
||||
}
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = resp.status();
|
||||
// 200 OK on a fresh start, 206 Partial Content on a resume
|
||||
// that the server honoured. Anything else is a problem.
|
||||
let is_resume = existing_len > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
|
||||
let is_fresh = existing_len == 0 && status.is_success();
|
||||
let server_ignored_range = existing_len > 0 && status == reqwest::StatusCode::OK;
|
||||
if !is_resume && !is_fresh && !server_ignored_range {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"HTTP {} for {} (resume offset {})",
|
||||
status,
|
||||
component.name,
|
||||
existing_len
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// If the server ignored Range (returned 200 with the full
|
||||
// body), wipe the partial file and start over.
|
||||
let mut file = if server_ignored_range {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(dest)
|
||||
.await
|
||||
.context("open staging file")?
|
||||
} else if is_resume {
|
||||
tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(dest)
|
||||
.await
|
||||
.context("open staging file for append")?
|
||||
} else {
|
||||
tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(dest)
|
||||
.await
|
||||
.context("open staging file")?
|
||||
};
|
||||
|
||||
let mut resp = resp;
|
||||
let mut stream_err = false;
|
||||
let mut on_disk = existing_len;
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(bytes)) => {
|
||||
if let Err(e) = file.write_all(&bytes).await {
|
||||
last_err = Some(anyhow::anyhow!(e).context("writing chunk"));
|
||||
stream_err = true;
|
||||
break;
|
||||
}
|
||||
on_disk += bytes.len() as u64;
|
||||
DOWNLOAD_BYTES.store(
|
||||
prior_total + on_disk.min(component.size_bytes),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
Ok(None) => break, // stream ended cleanly
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(e).context("reading chunk"));
|
||||
stream_err = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
drop(file);
|
||||
if stream_err {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stream ended cleanly. If we've got the expected size, verify
|
||||
// the SHA and succeed. Otherwise loop to resume from the new
|
||||
// offset on the next attempt.
|
||||
let final_len = tokio::fs::metadata(dest)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
if final_len < component.size_bytes {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"download truncated: got {} of {} bytes",
|
||||
final_len,
|
||||
component.size_bytes
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Full file — verify hash.
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash == component.sha256 {
|
||||
return Ok(());
|
||||
}
|
||||
// SHA mismatch — the file on disk is garbage. Nuke it and
|
||||
// start over from scratch on the next attempt.
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
component.sha256,
|
||||
hash
|
||||
));
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
}
|
||||
|
||||
/// Run a command as root, but *outside* the archipelago service's
|
||||
/// restricted mount namespace.
|
||||
///
|
||||
/// archipelago.service uses `ProtectSystem=strict`, which makes `/opt`
|
||||
/// and `/usr` read-only inside the service — and sudo inherits the
|
||||
/// namespace, so `sudo mv /opt/archipelago/...` fails with EROFS even
|
||||
/// though sudo itself is root. `systemd-run --wait` spawns a transient
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(&full)
|
||||
.status()
|
||||
.await
|
||||
.context("sudo systemd-run spawn failed")
|
||||
}
|
||||
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
@@ -277,31 +473,25 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
match name.as_str() {
|
||||
"archipelago" => {
|
||||
// We're running FROM /usr/local/bin/archipelago right now,
|
||||
// so we can't rewrite it in place — `install` / `cp` would
|
||||
// hit ETXTBSY on the busy executable. Use `mv` instead:
|
||||
// rename() is atomic and doesn't modify the existing file,
|
||||
// it just re-points the path at a new inode. The currently
|
||||
// running process keeps executing off the old inode; new
|
||||
// invocations (i.e. after the post-apply systemctl
|
||||
// restart) pick up the new binary.
|
||||
// Two namespace gotchas this block works around:
|
||||
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||
// Use `mv`, which is atomic rename() and tolerates a
|
||||
// busy destination.
|
||||
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||
// sudo inherits the service's mount namespace. Route
|
||||
// the rename through systemd-run so it runs in a
|
||||
// transient unit with default protections.
|
||||
let staged = src.to_string_lossy().to_string();
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chmod", "0755", &staged])
|
||||
.status()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "root:root", &staged])
|
||||
.status()
|
||||
.await;
|
||||
let status = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
.status()
|
||||
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
||||
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"sudo mv failed for {} (exit {:?})",
|
||||
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||
name,
|
||||
status.code()
|
||||
);
|
||||
@@ -320,78 +510,81 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let web_ui = "/opt/archipelago/web-ui";
|
||||
let backup_path = "/opt/archipelago/web-ui.bak";
|
||||
|
||||
let mk = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", &staging_new])
|
||||
.status()
|
||||
// All sudo calls that touch /opt/archipelago go through
|
||||
// host_sudo so they see a normal root mount namespace.
|
||||
let mk = host_sudo(&["mkdir", "-p", &staging_new])
|
||||
.await
|
||||
.context("Failed to create frontend staging dir")?;
|
||||
if !mk.success() {
|
||||
anyhow::bail!("mkdir {} failed", staging_new);
|
||||
}
|
||||
let extract = tokio::process::Command::new("sudo")
|
||||
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", &staging_new])
|
||||
.status()
|
||||
.await
|
||||
.with_context(|| format!("Failed to extract {}", name))?;
|
||||
let extract = host_sudo(&[
|
||||
"tar",
|
||||
"-xzf",
|
||||
&src.to_string_lossy(),
|
||||
"-C",
|
||||
&staging_new,
|
||||
])
|
||||
.await
|
||||
.with_context(|| format!("Failed to extract {}", name))?;
|
||||
if !extract.success() {
|
||||
// Best-effort cleanup of the partial extraction.
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &staging_new])
|
||||
.status()
|
||||
.await;
|
||||
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
||||
anyhow::bail!("tar extraction failed for {}", name);
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "archipelago:archipelago", &staging_new])
|
||||
.status()
|
||||
.await;
|
||||
let _ = host_sudo(&[
|
||||
"chown",
|
||||
"-R",
|
||||
"archipelago:archipelago",
|
||||
&staging_new,
|
||||
])
|
||||
.await;
|
||||
|
||||
// Preserve paths that are installed outside the Vue build
|
||||
// (baked in by the ISO or sibling installers) and so
|
||||
// aren't in the new tarball. Without this copy, every OTA
|
||||
// wipes them — notably aiui/ (Claude Code sidebar) and
|
||||
// the companion APK. `cp -a` preserves mode/ownership.
|
||||
for preserved in ["aiui", "archipelago-companion.apk"] {
|
||||
let src = format!("{}/{}", web_ui, preserved);
|
||||
let dst = format!("{}/{}", staging_new, preserved);
|
||||
// Only preserve the old copy if the new tarball
|
||||
// doesn't already ship a fresher one.
|
||||
if Path::new(&src).exists() && !Path::new(&dst).exists() {
|
||||
let _ = host_sudo(&["cp", "-a", &src, &dst]).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Swap: mv current web-ui aside, then mv new into place.
|
||||
if Path::new(web_ui).exists() {
|
||||
let mv_old = tokio::process::Command::new("sudo")
|
||||
.args(["mv", web_ui, &staging_old])
|
||||
.status()
|
||||
let mv_old = host_sudo(&["mv", web_ui, &staging_old])
|
||||
.await
|
||||
.context("Failed to rotate old web-ui")?;
|
||||
if !mv_old.success() {
|
||||
anyhow::bail!("failed to move old web-ui aside");
|
||||
}
|
||||
}
|
||||
let mv_new = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staging_new, web_ui])
|
||||
.status()
|
||||
let mv_new = host_sudo(&["mv", &staging_new, web_ui])
|
||||
.await
|
||||
.context("Failed to swap new web-ui into place")?;
|
||||
if !mv_new.success() {
|
||||
// Roll back the rename so nginx keeps serving.
|
||||
if Path::new(&staging_old).exists() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staging_old, web_ui])
|
||||
.status()
|
||||
.await;
|
||||
let _ = host_sudo(&["mv", &staging_old, web_ui]).await;
|
||||
}
|
||||
anyhow::bail!("failed to move new web-ui into place");
|
||||
}
|
||||
|
||||
// Rotate previous rollback aside (best-effort) and install
|
||||
// this apply's old copy as the new rollback.
|
||||
// Rotate previous rollback aside and install this apply's
|
||||
// old copy as the new rollback.
|
||||
if Path::new(&staging_old).exists() {
|
||||
if Path::new(backup_path).exists() {
|
||||
// Tag the previous backup with its own ts so it
|
||||
// doesn't collide; best-effort cleanup.
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mv",
|
||||
backup_path,
|
||||
&format!("{}.{}", backup_path, ts),
|
||||
])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staging_old, backup_path])
|
||||
.status()
|
||||
let _ = host_sudo(&[
|
||||
"mv",
|
||||
backup_path,
|
||||
&format!("{}.{}", backup_path, ts),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
let _ = host_sudo(&["mv", &staging_old, backup_path]).await;
|
||||
}
|
||||
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
|
||||
}
|
||||
@@ -422,10 +615,10 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
// starting the new process — it would deadlock otherwise.
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "--no-block", "restart", "archipelago"])
|
||||
.status()
|
||||
.await;
|
||||
// systemctl talks to PID 1 over D-Bus — doesn't need the host
|
||||
// mount namespace, but routing through host_sudo keeps the
|
||||
// apply flow's sudo calls uniform.
|
||||
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +685,14 @@
|
||||
"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.",
|
||||
|
||||
@@ -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,6 +684,14 @@
|
||||
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
||||
"rollbackFailed": "Error al revertir.",
|
||||
"pullAndRebuild": "Pull y Recompilar",
|
||||
"finishingDownload": "Terminando descarga — verificando checksum…",
|
||||
"overlayApplying": "Instalando actualizaci\u00f3n…",
|
||||
"overlayRestarting": "Reiniciando servidor…",
|
||||
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n…",
|
||||
"overlayReady": "Actualizaci\u00f3n instalada — recargando…",
|
||||
"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 — 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.",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,14 @@
|
||||
:style="{ width: downloadPercentFormatted + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="downloadFinishing" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
|
||||
<p class="text-xs text-white/60">
|
||||
{{ downloadFinishing
|
||||
? t('systemUpdate.finishingDownload')
|
||||
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applying -->
|
||||
@@ -176,6 +183,67 @@
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
@@ -221,6 +289,7 @@ import { ref, computed, onMounted } 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
|
||||
@@ -256,6 +325,101 @@ const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
||||
|
||||
// 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
|
||||
} | 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)
|
||||
return p.active
|
||||
}
|
||||
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 {
|
||||
@@ -345,21 +509,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)
|
||||
@@ -395,40 +557,50 @@ async function executeConfirm() {
|
||||
if (action === 'apply') {
|
||||
await applyUpdate()
|
||||
} else if (action === 'git-apply') {
|
||||
await applyUpdateGit()
|
||||
await applyUpdateGitWithOverlay()
|
||||
} else if (action === 'rollback') {
|
||||
await rollbackUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdateGit() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
|
||||
showStatus(t('systemUpdate.gitApplyStarted'))
|
||||
updateInfo.value = null
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Git apply failed', e)
|
||||
} finally {
|
||||
applying.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 +637,45 @@ async function setSchedule(value: ScheduleValue) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStatus(), loadSchedule(), 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -180,21 +180,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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"version": "1.7.9-alpha",
|
||||
"version": "1.7.15-alpha",
|
||||
"release_date": "2026-04-20",
|
||||
"changelog": [
|
||||
"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."
|
||||
"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.",
|
||||
"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.",
|
||||
"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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.8-alpha",
|
||||
"new_version": "1.7.9-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.9-alpha/archipelago",
|
||||
"sha256": "1ec7383de8e6b5caa67ec93311db7b5695e1831730fbd40ce56a5aa5aa301629",
|
||||
"size_bytes": 40378536
|
||||
"current_version": "1.7.14-alpha",
|
||||
"new_version": "1.7.15-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.15-alpha/archipelago",
|
||||
"sha256": "1070c87fd24fc56b2edcb6ea37f42fa47dfbdc9a4840151f723bbc9c081c162b",
|
||||
"size_bytes": 40584792
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.9-alpha.tar.gz",
|
||||
"current_version": "1.7.8-alpha",
|
||||
"new_version": "1.7.9-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.9-alpha/archipelago-frontend-1.7.9-alpha.tar.gz",
|
||||
"sha256": "4fb796643cc9dc8469078ca3392f7cc5541071f6849979922b3259e5f20172e9",
|
||||
"size_bytes": 76984615
|
||||
"name": "archipelago-frontend-1.7.15-alpha.tar.gz",
|
||||
"current_version": "1.7.14-alpha",
|
||||
"new_version": "1.7.15-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.15-alpha/archipelago-frontend-1.7.15-alpha.tar.gz",
|
||||
"sha256": "8e630ebaddf88ac0e0500eeb80cfea24e6cd87c41c0d6b934e66d7b7f63fd43f",
|
||||
"size_bytes": 162078068
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
releases/v1.7.10-alpha/archipelago
Executable file
BIN
releases/v1.7.10-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.10-alpha/archipelago-frontend-1.7.10-alpha.tar.gz
Normal file
BIN
releases/v1.7.10-alpha/archipelago-frontend-1.7.10-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.11-alpha/archipelago
Executable file
BIN
releases/v1.7.11-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.11-alpha/archipelago-frontend-1.7.11-alpha.tar.gz
Normal file
BIN
releases/v1.7.11-alpha/archipelago-frontend-1.7.11-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.12-alpha/archipelago
Executable file
BIN
releases/v1.7.12-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.12-alpha/archipelago-frontend-1.7.12-alpha.tar.gz
Normal file
BIN
releases/v1.7.12-alpha/archipelago-frontend-1.7.12-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.13-alpha/archipelago
Executable file
BIN
releases/v1.7.13-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.13-alpha/archipelago-frontend-1.7.13-alpha.tar.gz
Normal file
BIN
releases/v1.7.13-alpha/archipelago-frontend-1.7.13-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.14-alpha/archipelago
Executable file
BIN
releases/v1.7.14-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.14-alpha/archipelago-frontend-1.7.14-alpha.tar.gz
Normal file
BIN
releases/v1.7.14-alpha/archipelago-frontend-1.7.14-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.15-alpha/archipelago
Executable file
BIN
releases/v1.7.15-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.15-alpha/archipelago-frontend-1.7.15-alpha.tar.gz
Normal file
BIN
releases/v1.7.15-alpha/archipelago-frontend-1.7.15-alpha.tar.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user