Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eadec6936 | ||
|
|
6b0a84e710 | ||
|
|
fd361fb35e | ||
|
|
8bb61a51e2 | ||
|
|
17d225190a | ||
|
|
d08c0d29c7 | ||
|
|
a93bd70c5a | ||
|
|
25162ee846 | ||
|
|
837cfdfd1f | ||
|
|
573b469191 | ||
|
|
401f92a24f | ||
|
|
dc0adbef70 | ||
|
|
537c9fa70b | ||
|
|
b6468ebf3c | ||
|
|
70587210fb |
@@ -741,7 +741,16 @@ private fun buildAutoLoginScript(password: String): String {
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(el, pw);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
// Let Vue re-render before submitting: a synchronous Enter arrives
|
||||
// while the login button is still disabled, and the web UI's
|
||||
// controller-nav "Enter in input clicks the next enabled button"
|
||||
// pattern then hits Replay Intro instead — restarting the intro
|
||||
// cinematic on every connect (two frames = value flush + render).
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
});
|
||||
});
|
||||
}, 1500);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.104-alpha (2026-07-19)
|
||||
|
||||
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
|
||||
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
|
||||
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
|
||||
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
|
||||
|
||||
## v1.7.103-alpha (2026-07-18)
|
||||
|
||||
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
|
||||
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
|
||||
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
|
||||
|
||||
## v1.7.102-alpha (2026-07-17)
|
||||
|
||||
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
|
||||
|
||||
@@ -10,9 +10,16 @@ app:
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
|
||||
# on which version the node runs (multi-version switch) — probe which name
|
||||
# resolves on archy-net instead of hardcoding knots, which left electrumx
|
||||
# permanently disconnected (block index 0) on core nodes.
|
||||
custom_args:
|
||||
- >-
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||
for h in bitcoin-knots bitcoin-core; do
|
||||
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
|
||||
done;
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
|
||||
exec electrumx_server
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.102-alpha"
|
||||
version = "1.7.104-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.102-alpha"
|
||||
version = "1.7.104-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
+226
-38
@@ -24,6 +24,16 @@ pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
/// confidence than "looks stuck at 0%".
|
||||
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Serializes the mutating update operations (download, apply, and the
|
||||
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
|
||||
/// exactly this race: two concurrent `update.download` RPCs shared one
|
||||
/// staging file, a cancel wiped staging mid-flight, a third download began
|
||||
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
|
||||
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
|
||||
/// via `try_lock` so a concurrent caller gets an explicit "already running"
|
||||
/// error instead of silently interleaving.
|
||||
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
@@ -976,6 +986,9 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// verified over the complete file at the end of each component, so a
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
state = check_for_updates(data_dir).await?;
|
||||
@@ -1133,7 +1146,6 @@ async fn download_component_resumable(
|
||||
dest: &Path,
|
||||
prior_total: u64,
|
||||
) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
const MAX_ATTEMPTS: u32 = 6;
|
||||
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
|
||||
@@ -1145,8 +1157,19 @@ async fn download_component_resumable(
|
||||
Err(_) => 0,
|
||||
};
|
||||
if existing_len >= component.size_bytes {
|
||||
// File is already complete — break out and go verify.
|
||||
break;
|
||||
// File is already complete (a resumed run finished it, or a
|
||||
// leftover from an earlier attempt) — verify it instead of
|
||||
// trusting it. The old code `break`d here, which skipped
|
||||
// verification entirely AND landed on the error return below
|
||||
// ("download failed without a captured error").
|
||||
match verify_component_on_disk(component, dest).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if attempt > 1 {
|
||||
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
|
||||
@@ -1294,44 +1317,86 @@ async fn download_component_resumable(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Full file — verify hash.
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash == component.sha256 {
|
||||
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
|
||||
// match too. SHA-256 stays the mandatory gate during migration;
|
||||
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
|
||||
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
|
||||
// — treat it like a SHA mismatch and re-download.
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// Full file — verify hashes. On mismatch the file on disk is
|
||||
// garbage: nuke it and start over from scratch on the next attempt.
|
||||
match verify_component_on_disk(component, dest).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// SHA mismatch — the file on disk is garbage. Nuke it and
|
||||
// start over from scratch on the next attempt.
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
}
|
||||
|
||||
/// Verify a fully-downloaded component file on disk: SHA-256 is the
|
||||
/// mandatory gate; when the manifest also pins a BLAKE3 digest it must
|
||||
/// match too (BLAKE3 is the hash the iroh swarm fetches/verifies by, so
|
||||
/// a present-but-wrong BLAKE3 means the bytes aren't swarm-consistent —
|
||||
/// treated exactly like a SHA mismatch). Err = mismatch; the caller
|
||||
/// decides whether to remove the file and retry.
|
||||
async fn verify_component_on_disk(component: &ComponentUpdate, dest: &Path) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash != component.sha256 {
|
||||
anyhow::bail!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
component.sha256,
|
||||
hash
|
||||
));
|
||||
);
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
anyhow::bail!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-verify every manifest component against the bytes actually sitting
|
||||
/// in staging, immediately before install. The download path verifies as
|
||||
/// it goes, but staging can change between download and apply — on .198
|
||||
/// (v1.7.103, 2026-07-18) a concurrent download was re-filling a wiped
|
||||
/// staging dir when apply ran, and a 17MB partial of the 49MB binary got
|
||||
/// installed. This apply-time gate is the one that must never be skipped.
|
||||
async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest) -> Result<()> {
|
||||
for component in &manifest.components {
|
||||
let dest = staging_dir.join(&component.name);
|
||||
let len = tokio::fs::metadata(&dest)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
if len != component.size_bytes {
|
||||
anyhow::bail!(
|
||||
"staged component {} is {} bytes but the manifest says {} — \
|
||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
||||
component.name,
|
||||
len,
|
||||
component.size_bytes
|
||||
);
|
||||
}
|
||||
verify_component_on_disk(component, &dest)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"staged component {} failed verification — refusing to apply",
|
||||
component.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel an in-flight download. Sets the cancellation flag so the
|
||||
@@ -1343,11 +1408,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
// Only wipe staging when no download/apply holds the op lock. Wiping
|
||||
// under a live operation is how .198 ended up applying a re-filling
|
||||
// staging dir; with the lock held elsewhere we just set the cancel
|
||||
// flag and let the in-flight loop bail at its next chunk boundary
|
||||
// (partials are size+hash revalidated on the next resume anyway).
|
||||
let staging = data_dir.join("update-staging");
|
||||
let wiped = if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
let wiped = match UPDATE_OP_LOCK.try_lock() {
|
||||
Ok(_op) => {
|
||||
if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
// Clear the "downloaded, ready to apply" marker too — a canceled
|
||||
// download is not a staged update.
|
||||
@@ -1398,11 +1473,34 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
if !staging_dir.exists() {
|
||||
anyhow::bail!("No staged update found. Download first.");
|
||||
}
|
||||
|
||||
// Gate 1: the completion marker is written only after EVERY component
|
||||
// downloaded and hash-verified. A staging dir without it is a partial
|
||||
// or in-flight download — exactly what got installed on .198.
|
||||
if !has_staged_update(data_dir).await {
|
||||
anyhow::bail!(
|
||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
||||
);
|
||||
}
|
||||
|
||||
// Gate 2: re-verify the actual staged bytes against the manifest.
|
||||
let manifest = load_state(data_dir)
|
||||
.await?
|
||||
.available_update
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no update manifest in state to verify staged files against — re-download the update"
|
||||
)
|
||||
})?;
|
||||
verify_staged_components(&staging_dir, &manifest).await?;
|
||||
|
||||
let backup_dir = data_dir.join("update-backup");
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.await
|
||||
@@ -1690,6 +1788,30 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Install the OTA crash-loop guard as a drop-in on existing
|
||||
// nodes (fresh ISOs carry it in the unit file itself). The
|
||||
// guard restores the update-backup binary when a freshly
|
||||
// applied binary SEGVs before it can run its own post-OTA
|
||||
// verification — the .198 v1.7.103 truncated-binary loop.
|
||||
// Best-effort: `+-` in the drop-in means a missing script can
|
||||
// never block the service, and a failed install here must not
|
||||
// abort the apply.
|
||||
if Path::new("/opt/archipelago/scripts/ota-crash-guard.sh").exists() {
|
||||
let dropin_dir = "/etc/systemd/system/archipelago.service.d";
|
||||
let _ = host_sudo(&["mkdir", "-p", dropin_dir]).await;
|
||||
let _ = host_sudo(&[
|
||||
"bash",
|
||||
"-c",
|
||||
&format!(
|
||||
"printf '%s\\n' '[Service]' \
|
||||
'ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh' \
|
||||
> {}/ota-crash-guard.conf",
|
||||
dropin_dir
|
||||
),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
let _ =
|
||||
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
|
||||
@@ -2443,6 +2565,72 @@ mod tests {
|
||||
assert!(!persisted.update_in_progress);
|
||||
}
|
||||
|
||||
/// apply_update takes the global single-flight UPDATE_OP_LOCK, so tests
|
||||
/// that call it must not run concurrently — one would see the other's
|
||||
/// lock and fail with "another update operation is already running".
|
||||
static APPLY_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_refuses_unmarked_staging() {
|
||||
let _serial = APPLY_TEST_SERIAL.lock().await;
|
||||
// Regression: .198 v1.7.103 bricking — apply ran against a staging
|
||||
// dir that a concurrent download was still filling. Without the
|
||||
// .download-complete marker, apply must refuse before touching
|
||||
// anything.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"partial")
|
||||
.await
|
||||
.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("completion marker"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_refuses_staged_bytes_that_mismatch_manifest() {
|
||||
let _serial = APPLY_TEST_SERIAL.lock().await;
|
||||
// Marker present (a complete download once existed) but the staged
|
||||
// bytes no longer match the manifest — apply must re-verify and
|
||||
// refuse rather than install whatever is on disk.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"truncated-garbage")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = UpdateState {
|
||||
available_update: Some(UpdateManifest {
|
||||
version: "999.0.0".to_string(),
|
||||
release_date: "2026-07-18".to_string(),
|
||||
changelog: vec![],
|
||||
components: vec![ComponentUpdate {
|
||||
name: "archipelago".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "999.0.0".to_string(),
|
||||
download_url: "http://example.invalid/archipelago".to_string(),
|
||||
sha256: "0".repeat(64),
|
||||
size_bytes: 49_949_048,
|
||||
blake3: None,
|
||||
}],
|
||||
}),
|
||||
update_in_progress: true,
|
||||
..UpdateState::default()
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("refusing to apply"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dismiss_update_clears_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -25,6 +25,11 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown
|
||||
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
|
||||
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
|
||||
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
|
||||
# in-binary post-OTA probe never runs — this restores the update-backup binary
|
||||
# after 5 failed start attempts while the pending-verify marker exists.
|
||||
# "-" so a missing/failed guard can never block the service itself.
|
||||
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.102-alpha",
|
||||
"version": "1.7.104-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.102-alpha",
|
||||
"version": "1.7.104-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.102-alpha",
|
||||
"version": "1.7.104-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
@@ -53,6 +54,12 @@ function reloadAfterCinematic() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The public demo has no version to update to — the prompt is noise, and
|
||||
// both accept-paths end in a reload that replays the demo intro ("the site
|
||||
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
|
||||
// waiting worker is safe: this page keeps its complete old cache, and the
|
||||
// new build activates on the next visit.
|
||||
if (IS_DEMO) return
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
// On the very first visit the page loads with no controlling SW; the
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keydown.enter="confirmPasswordInputRef?.focus()"
|
||||
@@ -83,6 +84,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keydown.enter="handleSetupWithSound"
|
||||
@@ -127,6 +129,7 @@
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
data-controller-no-submit
|
||||
:aria-label="t('login.totpLabel')"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@@ -165,6 +168,12 @@
|
||||
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All auth inputs opt out of controller-nav's Enter→click-next-button
|
||||
pattern (data-controller-no-submit): they submit via their own Enter
|
||||
handlers, and while the submit button is still disabled the "next
|
||||
focusable" is Replay Intro — the companion's auto-login injects
|
||||
Enter before Vue re-enables the button, which replayed the intro
|
||||
in a loop on every app connect. -->
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
@@ -175,6 +184,7 @@
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keydown.enter="handleLoginWithSound"
|
||||
|
||||
@@ -18,10 +18,8 @@
|
||||
let the app's own UI load instead of a loader stuck on top (B7). -->
|
||||
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
|
||||
</svg>
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
|
||||
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
@@ -119,6 +117,7 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
|
||||
@@ -362,6 +362,31 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.104-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.104-alpha</span>
|
||||
<span class="text-xs text-white/40">July 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.</p>
|
||||
<p>If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.</p>
|
||||
<p>The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.</p>
|
||||
<p>While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.103-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.103-alpha</span>
|
||||
<span class="text-xs text-white/40">July 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.</p>
|
||||
<p>Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.</p>
|
||||
<p>The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.102-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+18
-23
@@ -1,35 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.",
|
||||
"Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a \"finish setup\" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.",
|
||||
"Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.",
|
||||
"First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old \"first install fails, the second works\" pattern), big multi-part apps show their real download progress instead of sitting at \"Preparing\", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.",
|
||||
"The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.",
|
||||
"The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.",
|
||||
"Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.",
|
||||
"Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, \"Connect to Mesh\" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.",
|
||||
"Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false \"restarting\" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring."
|
||||
"Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.",
|
||||
"If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.",
|
||||
"The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.",
|
||||
"While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.102-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago",
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.102-alpha",
|
||||
"sha256": "13218bfac5f3e1b641edebac0fcccb483fb4500d764369da4947a467762f2e5a",
|
||||
"size_bytes": 49951520
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
|
||||
"size_bytes": 49976648
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.102-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago-frontend-1.7.102-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.102-alpha.tar.gz",
|
||||
"new_version": "1.7.102-alpha",
|
||||
"sha256": "663cf9a35d98fa6dad2d7fb2906e4a939a906382f2e9729156c3630e282cdc94",
|
||||
"size_bytes": 174594796
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
|
||||
"size_bytes": 174592628
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-17",
|
||||
"signature": "999e50b9aff22f544677986ca8c686614d8c3d4cbe501c2d5de86d2a1ea56fc731bb9434d722c41c7a124932f98e629706b41a64f57551e69529be59fe671d04",
|
||||
"release_date": "2026-07-19",
|
||||
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.102-alpha"
|
||||
"version": "1.7.104-alpha"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"updated": "2026-07-10",
|
||||
"updated": "2026-07-18",
|
||||
"apps": {
|
||||
"adguardhome": {
|
||||
"version": "v0.107.55",
|
||||
@@ -333,6 +333,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"barkd": {
|
||||
"version": "0.3.0",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"id": "barkd",
|
||||
"name": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"container": {
|
||||
"image": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
|
||||
"pull_policy": "if-not-present",
|
||||
"network": "archy-net",
|
||||
"generated_secrets": [
|
||||
{
|
||||
"name": "barkd-secret",
|
||||
"kind": "hex32"
|
||||
}
|
||||
],
|
||||
"secret_env": [
|
||||
{
|
||||
"key": "BARKD_SECRET",
|
||||
"secret_file": "barkd-secret"
|
||||
}
|
||||
],
|
||||
"data_uid": "1000:1000"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 1,
|
||||
"memory_limit": "512Mi",
|
||||
"disk_limit": "1Gi"
|
||||
},
|
||||
"security": {
|
||||
"readonly_root": true,
|
||||
"network_policy": "bridge"
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"host": 3535,
|
||||
"container": 3535,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"type": "bind",
|
||||
"source": "/var/lib/archipelago/barkd",
|
||||
"target": "/data",
|
||||
"options": [
|
||||
"rw"
|
||||
]
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
"BARKD_DATADIR=/data",
|
||||
"BARKD_BIND_HOST=0.0.0.0",
|
||||
"BARKD_BIND_PORT=3535"
|
||||
],
|
||||
"health_check": {
|
||||
"type": "tcp",
|
||||
"endpoint": "localhost:3535",
|
||||
"interval": "30s",
|
||||
"timeout": "5s",
|
||||
"retries": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bitcoin-core": {
|
||||
"version": "latest",
|
||||
"manifest": {
|
||||
@@ -1085,7 +1157,7 @@
|
||||
"-lc"
|
||||
],
|
||||
"custom_args": [
|
||||
"export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/\"; exec electrumx_server"
|
||||
"for h in bitcoin-knots bitcoin-core; do if getent hosts \"$h\" >/dev/null 2>&1; then BTC_HOST=\"$h\"; break; fi; done; export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/\"; exec electrumx_server"
|
||||
],
|
||||
"secret_env": [
|
||||
{
|
||||
|
||||
+18
-23
@@ -1,35 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.",
|
||||
"Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a \"finish setup\" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.",
|
||||
"Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.",
|
||||
"First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old \"first install fails, the second works\" pattern), big multi-part apps show their real download progress instead of sitting at \"Preparing\", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.",
|
||||
"The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.",
|
||||
"The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.",
|
||||
"Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.",
|
||||
"Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, \"Connect to Mesh\" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.",
|
||||
"Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false \"restarting\" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring."
|
||||
"Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.",
|
||||
"If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.",
|
||||
"The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.",
|
||||
"While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.102-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago",
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.102-alpha",
|
||||
"sha256": "13218bfac5f3e1b641edebac0fcccb483fb4500d764369da4947a467762f2e5a",
|
||||
"size_bytes": 49951520
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
|
||||
"size_bytes": 49976648
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.102-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago-frontend-1.7.102-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.102-alpha.tar.gz",
|
||||
"new_version": "1.7.102-alpha",
|
||||
"sha256": "663cf9a35d98fa6dad2d7fb2906e4a939a906382f2e9729156c3630e282cdc94",
|
||||
"size_bytes": 174594796
|
||||
"current_version": "1.7.104-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
|
||||
"new_version": "1.7.104-alpha",
|
||||
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
|
||||
"size_bytes": 174592628
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-17",
|
||||
"signature": "999e50b9aff22f544677986ca8c686614d8c3d4cbe501c2d5de86d2a1ea56fc731bb9434d722c41c7a124932f98e629706b41a64f57551e69529be59fe671d04",
|
||||
"release_date": "2026-07-19",
|
||||
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.102-alpha"
|
||||
"version": "1.7.104-alpha"
|
||||
}
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# OTA crash-loop guard — runs as root from ExecStartPre=+- on archipelago.service.
|
||||
#
|
||||
# Covers the failure mode verify_pending_update() cannot: a freshly-applied
|
||||
# binary that can't even start (SEGV/ENOEXEC — e.g. the truncated 17MB binary
|
||||
# .198 installed on the v1.7.103 OTA, which crash-looped 236 times with a
|
||||
# perfectly good backup sitting in update-backup/). The in-binary probe never
|
||||
# runs because the binary never runs, so this guard counts start attempts from
|
||||
# outside and restores the backup binary once the new one has clearly failed.
|
||||
#
|
||||
# Scope is deliberately narrow: it acts ONLY while the post-OTA pending-verify
|
||||
# marker exists (written by apply_update just before the restart, deleted by
|
||||
# the new binary once it boots and passes its probes). A crash loop with no
|
||||
# marker is not an OTA gone wrong, and this script stays out of it.
|
||||
#
|
||||
# Always exits 0 — a guard must never be the reason the service can't start.
|
||||
|
||||
set -u
|
||||
|
||||
DATA_DIR=/var/lib/archipelago
|
||||
MARKER="$DATA_DIR/update-pending-verify.json"
|
||||
COUNT_FILE="$DATA_DIR/ota-crash-guard.count"
|
||||
BACKUP="$DATA_DIR/update-backup/archipelago"
|
||||
BINARY=/usr/local/bin/archipelago
|
||||
MAX_ATTEMPTS=5
|
||||
|
||||
log() {
|
||||
echo "$*" | systemd-cat -t ota-crash-guard -p warning 2>/dev/null || true
|
||||
}
|
||||
|
||||
# No pending OTA verification -> nothing to guard; clear any stale counter.
|
||||
if [ ! -f "$MARKER" ]; then
|
||||
rm -f "$COUNT_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count this start attempt. The counter only accumulates while the marker
|
||||
# exists; a healthy new binary deletes the marker on its first successful
|
||||
# boot, and the next start clears the counter above.
|
||||
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
|
||||
case "$count" in ''|*[!0-9]*) count=0 ;; esac
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$COUNT_FILE" 2>/dev/null || true
|
||||
|
||||
if [ "$count" -lt "$MAX_ATTEMPTS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP" ]; then
|
||||
log "OTA crash guard: $count failed start attempts but no backup binary at $BACKUP — cannot roll back"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Already restored (or the OTA never replaced the binary)? Don't loop.
|
||||
if cmp -s "$BACKUP" "$BINARY"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Restore via copy-to-temp + atomic rename; never truncate the live path.
|
||||
tmp="$BINARY.rollback.$$"
|
||||
if cp "$BACKUP" "$tmp" && chown root:root "$tmp" && chmod 755 "$tmp" && mv "$tmp" "$BINARY"; then
|
||||
# Leave a tombstone for the UI/logs instead of the marker so the restored
|
||||
# binary doesn't run the post-OTA probe against the rolled-back version.
|
||||
mv "$MARKER" "$DATA_DIR/update-rolled-back.json" 2>/dev/null || rm -f "$MARKER"
|
||||
rm -f "$COUNT_FILE"
|
||||
log "OTA crash guard: restored previous binary after $count failed start attempts of the updated one"
|
||||
else
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
log "OTA crash guard: failed to restore backup binary (cp/mv error)"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user