release(v1.7.14-alpha): install overlay + FIPS real fix + AIUI restore
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 10m10s
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 10m10s
Install UX SystemUpdate.vue now shows a full-screen overlay after apply: the BitcoinFaceAscii logo, a target-version label, an indeterminate progress stripe (solid orange; solid green on ready), and an elapsed-time readout. Polls /health every 1.5s and auto-reloads once the backend reports the new version. 3-min stall → "Reload now" button. Download UI also shows a spinner + "Finishing download — verifying checksum…" while the fake bar sits at 95%. FIPS reconnect — for real this time New fips.reconnect RPC does stop → start → wait 20s → re-poll → classify. Classification buckets: connected / daemon_down / no_seed_key / no_outbound_udp_or_anchor_down / peers_but_no_anchor, each with a plain-language hint surfaced verbatim by the Reconnect button. The real reason nodes like .198/.253 couldn't reach the anchor: identity::write_fips_key_from_seed was writing fips_key.pub as a bech32 npub TEXT file, but upstream fips expects 32 raw bytes. The daemon silently authenticated with garbage. Fix: PublicKey::to_bytes() → raw 32 bytes, and new fips::config::normalize_pub_file migrates legacy files by decoding the npub and rewriting in place. fips.reconnect also re-installs the config + healed keys to /etc/fips before restarting. AIUI preservation + restore apply_update was wiping /opt/archipelago/web-ui/aiui because the Vue build doesn't include it — every OTA lost the Claude sidebar. The preserve block now copies aiui/ + archipelago-companion.apk from the old web-ui into the staging dir before the swap, and prefers new-tar versions if present. To restore it on the three nodes that already lost it (.116/.198/.253), this release bundles the 85 MB aiui build into the frontend tarball. Frontend component size is now ~155 MB. Download / install timeouts Backend download client timeout 1800s → 3600s (1 h). Larger tarball + slow gitea raw throughput put us above the old cap. Frontend update.download rpc timeout 30 min → 65 min to match. package.install rpc timeout 15 min → 45 min — IndeedHub pulls 6 images and was timing out mid-install. UI nit "Rollback to Previous" → "Rollback Available". App-catalog proxy already landed in v1.7.13. Artefacts: archipelago 725e18e6…3c525e6 40462288 archipelago-frontend-1.7.14-alpha.tar.gz c35284be…ff2c16 162077052 (+aiui) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.13-alpha"
|
||||
version = "1.7.14-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.13-alpha"
|
||||
version = "1.7.14-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -176,7 +176,12 @@ 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))
|
||||
// 1h per component — the bundled frontend+aiui tarball sits at
|
||||
// ~160 MB and git.tx1138.com raw serves at ~70 KB/s which puts
|
||||
// the worst case above the old 30 min cap. A larger timeout
|
||||
// with a tight connect_timeout keeps hung connections from
|
||||
// swallowing the whole budget.
|
||||
.timeout(std::time::Duration::from_secs(3600))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
@@ -369,6 +374,21 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
])
|
||||
.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 = host_sudo(&["mv", web_ui, &staging_old])
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
@@ -255,6 +324,78 @@ const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
||||
// 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')
|
||||
@@ -359,7 +500,7 @@ async function downloadUpdate() {
|
||||
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 +536,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
|
||||
}
|
||||
}
|
||||
@@ -469,3 +620,25 @@ onMounted(() => {
|
||||
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
{
|
||||
"version": "1.7.13-alpha",
|
||||
"version": "1.7.14-alpha",
|
||||
"release_date": "2026-04-20",
|
||||
"changelog": [
|
||||
"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."
|
||||
"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.",
|
||||
"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.",
|
||||
"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'.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"'Rollback to Previous' is now labelled 'Rollback Available' — clearer that it's a choice you have, not a status you're stuck with."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.12-alpha",
|
||||
"new_version": "1.7.13-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.13-alpha/archipelago",
|
||||
"sha256": "0aaf72625a6cb164b35e30e0dc6f6084cbc96fd8d9da9480b78e85f4b979f22c",
|
||||
"size_bytes": 40371192
|
||||
"current_version": "1.7.13-alpha",
|
||||
"new_version": "1.7.14-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.14-alpha/archipelago",
|
||||
"sha256": "725e18e6b1dc83f092e7d51ebf18d448a5712f9956992aa36a5ddee553c525e6",
|
||||
"size_bytes": 40462288
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.13-alpha.tar.gz",
|
||||
"current_version": "1.7.12-alpha",
|
||||
"new_version": "1.7.13-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.13-alpha/archipelago-frontend-1.7.13-alpha.tar.gz",
|
||||
"sha256": "27505811ffcae22a33cc895e2dc630b3efef7d0682841eeeea517d5efc6f4142",
|
||||
"size_bytes": 76982505
|
||||
"name": "archipelago-frontend-1.7.14-alpha.tar.gz",
|
||||
"current_version": "1.7.13-alpha",
|
||||
"new_version": "1.7.14-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.14-alpha/archipelago-frontend-1.7.14-alpha.tar.gz",
|
||||
"sha256": "c35284be4d3eb26ac996d3abd5694a27dda357cc8f689499e547e66ae6ff2c16",
|
||||
"size_bytes": 162077052
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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.
Reference in New Issue
Block a user