Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb808df89a | ||
|
|
c800293f1f | ||
|
|
340b981b79 | ||
|
|
c49e8fcacd | ||
|
|
495b90782a |
@@ -137,7 +137,11 @@ fun WebViewScreen(
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder
|
||||
// thread (the JS bridge below runs off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
@@ -169,8 +173,29 @@ fun WebViewScreen(
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
// chain; without this, window.open() from a Vue click
|
||||
// handler silently no-ops and "Open in new tab" dies.
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
// Deterministic bridge for "open in the phone's browser".
|
||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||
// when present (companion app), falling back to window.open
|
||||
// in a plain mobile browser. This avoids relying on the
|
||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||
// can suppress in the WebView.
|
||||
val webViewRef = this
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { openExternalUrl(url) }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.90-alpha (2026-06-13)
|
||||
|
||||
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
|
||||
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
|
||||
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
|
||||
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
|
||||
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
|
||||
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
|
||||
|
||||
## v1.7.89-alpha (2026-06-12)
|
||||
|
||||
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
|
||||
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
|
||||
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
|
||||
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
|
||||
- The Lightning wallet now recovers and unlocks itself properly after restarts.
|
||||
|
||||
## v1.7.88-alpha (2026-06-12)
|
||||
|
||||
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.88-alpha"
|
||||
version = "1.7.89-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.88-alpha"
|
||||
version = "1.7.90-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -180,6 +181,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
|
||||
@@ -63,6 +63,7 @@ impl RpcHandler {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -13,7 +13,11 @@ impl RpcHandler {
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
.query(&[("type", "p2wkh")])
|
||||
// LND's REST gateway parses `type` as the AddressType enum by its
|
||||
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
|
||||
// valid enum value and returns 400 "parsing field type"; the native
|
||||
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
|
||||
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -530,6 +534,7 @@ impl RpcHandler {
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
@@ -127,6 +127,7 @@ async fn unlock_existing_wallet() -> Result<()> {
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -204,6 +205,7 @@ struct InitWalletRequest {
|
||||
|
||||
async fn init_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -305,12 +307,12 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -3220,11 +3220,11 @@ app:
|
||||
- /data/.filebrowser.json
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp/filebrowser-srv
|
||||
source: /var/lib/archipelago/filebrowser-srv
|
||||
target: /srv
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /tmp/filebrowser-data
|
||||
source: /var/lib/archipelago/filebrowser-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
"#;
|
||||
@@ -3244,7 +3244,7 @@ app:
|
||||
secret_file: bitcoin-rpc-password
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp/lnd
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /root/.lnd
|
||||
"#;
|
||||
AppManifest::parse(yaml).unwrap()
|
||||
|
||||
@@ -731,6 +731,89 @@ async fn restart_container(name: &str, state: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX/electrs on-disk data dir. Wiped to force a clean resync when its
|
||||
/// LevelDB is detected corrupt (see `maybe_recover_corrupt_electrumx`).
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
/// Restart attempt at which we check for — and recover from — a corrupt
|
||||
/// ElectrumX database. Late enough that a transient restart won't trigger a
|
||||
/// destructive resync, early enough to self-heal before MAX_RESTART_ATTEMPTS.
|
||||
const ELECTRUMX_DB_RESET_ATTEMPT: u32 = 3;
|
||||
|
||||
fn is_electrumx(name: &str) -> bool {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
matches!(id, "electrumx" | "electrs" | "mempool-electrs")
|
||||
}
|
||||
|
||||
/// True when a container's logs show the specific corrupt-LevelDB signature.
|
||||
/// ElectrumX exit-loops with a plyvel error when its `hist`/`utxo` LevelDB
|
||||
/// loses its CURRENT/MANIFEST pointer — typically after an unclean SIGKILL
|
||||
/// (e.g. the cgroup cascade on a service restart). We match the exact failure
|
||||
/// so a normal restart never triggers the destructive resync below.
|
||||
fn looks_like_corrupt_electrumx_db(logs: &str) -> bool {
|
||||
logs.contains("create_if_missing is false")
|
||||
|| logs.contains("Corruption:")
|
||||
|| (logs.contains("plyvel") && logs.contains("does not exist"))
|
||||
}
|
||||
|
||||
async fn electrumx_db_corrupt(name: &str) -> bool {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "60", name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match out {
|
||||
Ok(Ok(output)) => {
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
looks_like_corrupt_electrumx_db(&logs)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe the ElectrumX LevelDB stores so the next start resyncs from scratch.
|
||||
/// Files are owned by the container's mapped UID, so removal needs host sudo.
|
||||
/// The mount point itself is preserved (the container expects it to exist).
|
||||
/// Returns true if the reset succeeded.
|
||||
async fn reset_electrumx_data() -> bool {
|
||||
let rm = format!(
|
||||
"rm -rf {dir}/hist {dir}/utxo {dir}/meta {dir}/COIN",
|
||||
dir = ELECTRUMX_DATA_DIR,
|
||||
);
|
||||
matches!(
|
||||
crate::update::host_sudo(&["sh", "-c", &rm]).await,
|
||||
Ok(status) if status.success()
|
||||
)
|
||||
}
|
||||
|
||||
/// Self-heal a wedged ElectrumX: if it's exit-looping on a corrupt database,
|
||||
/// wipe its data dir once (at `ELECTRUMX_DB_RESET_ATTEMPT`) so the impending
|
||||
/// restart resyncs cleanly. Bounded to electrs containers, gated on the exact
|
||||
/// corruption signature, and fired once per failure streak — when the resync
|
||||
/// stabilises the restart tracker clears, so a future corruption can heal too.
|
||||
async fn maybe_recover_corrupt_electrumx(name: &str, attempt: u32) {
|
||||
if attempt != ELECTRUMX_DB_RESET_ATTEMPT || !is_electrumx(name) {
|
||||
return;
|
||||
}
|
||||
if !electrumx_db_corrupt(name).await {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
"ElectrumX {} is exit-looping on a corrupt database — resetting its data dir to force a clean resync",
|
||||
name
|
||||
);
|
||||
if reset_electrumx_data().await {
|
||||
info!("ElectrumX data dir reset; a fresh resync will begin on restart");
|
||||
} else {
|
||||
warn!("Failed to reset ElectrumX data dir (host sudo rm failed) — manual recovery may be needed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
@@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
.unwrap_or(&90)
|
||||
);
|
||||
|
||||
// Before restarting, self-heal a corrupt ElectrumX DB so
|
||||
// the restart resyncs cleanly instead of crash-looping.
|
||||
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
||||
|
||||
let restarted = restart_container(&container.name, &container.state).await;
|
||||
|
||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||
@@ -1471,4 +1558,36 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_electrumx_matches_electrs_variants_only() {
|
||||
assert!(is_electrumx("electrumx"));
|
||||
assert!(is_electrumx("archy-electrumx"));
|
||||
assert!(is_electrumx("electrs"));
|
||||
assert!(is_electrumx("mempool-electrs"));
|
||||
assert!(!is_electrumx("bitcoin-knots"));
|
||||
assert!(!is_electrumx("lnd"));
|
||||
assert!(!is_electrumx("electrs-ui")); // the web UI, not the indexer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_matches_plyvel_signature() {
|
||||
// The exact line ElectrumX exit-loops on (observed on .116).
|
||||
let corrupt = "plyvel._plyvel.Error: b'Invalid argument: hist: does not exist (create_if_missing is false)'";
|
||||
assert!(looks_like_corrupt_electrumx_db(corrupt));
|
||||
assert!(looks_like_corrupt_electrumx_db(
|
||||
"Corruption: bad block in hist"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_db_detection_ignores_healthy_logs() {
|
||||
let healthy = "INFO:BlockProcessor:our height: 117,009 daemon: 953,480 UTXOs 28MB\n\
|
||||
INFO:SessionManager:RPC server listening on 0.0.0.0:8000";
|
||||
assert!(!looks_like_corrupt_electrumx_db(healthy));
|
||||
// "catching up" / normal restart noise must not trigger a destructive wipe.
|
||||
assert!(!looks_like_corrupt_electrumx_db(
|
||||
"Prefetcher:catching up to daemon height 953,480"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+108
-49
@@ -66,10 +66,6 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
/// Secondary mirror on tx1138 gitea — independent network path so a
|
||||
/// single-provider outage doesn't knock out both mirrors.
|
||||
const DEFAULT_SECONDARY_MIRROR_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
@@ -107,16 +103,10 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
]
|
||||
vec![UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Load the operator-configured mirror list. Returns defaults if the
|
||||
@@ -144,14 +134,17 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
return Ok(default_mirrors());
|
||||
}
|
||||
|
||||
// One-time migration: the Hetzner VPS at 23.182.128.160 was
|
||||
// decommissioned 2026-04-23. Existing nodes have it baked into their
|
||||
// saved mirror list (was the original Server 1). Strip it on load so
|
||||
// we don't spend seconds per install timing out against a dead host.
|
||||
// Exception to the usual "explicit removals stick" rule: the user
|
||||
// never chose to add this — it was a default.
|
||||
// One-time migrations: drop decommissioned release servers that may be
|
||||
// baked into existing nodes' saved mirror lists. Strip them on load so
|
||||
// we don't spend seconds per install timing out against a dead/stale host.
|
||||
// - 23.182.128.160: Hetzner VPS, decommissioned 2026-04-23.
|
||||
// - git.tx1138.com: retired as a release server 2026-06-13 — its main
|
||||
// branch had diverged and stopped receiving releases, so it only
|
||||
// ever served a stale manifest as the secondary mirror.
|
||||
// Exception to the usual "explicit removals stick" rule: the user never
|
||||
// chose to add these — they were defaults.
|
||||
let before = list.len();
|
||||
list.retain(|m| !m.url.contains("23.182.128.160"));
|
||||
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
|
||||
let mut changed = list.len() != before;
|
||||
|
||||
// Merge in any default URLs the saved config is missing.
|
||||
@@ -182,17 +175,13 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
mirror.label = "Server 2 (tx1138)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -367,12 +356,20 @@ async fn probe_frontend_once() -> Result<()> {
|
||||
.context("build probe client")?;
|
||||
// Prefer HTTPS since that's the failure mode we're catching (nginx
|
||||
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
|
||||
// the bug.
|
||||
let resp = client
|
||||
.get("https://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET https://127.0.0.1/")?;
|
||||
// the bug. BUT not every node binds 443 on loopback (.116 serves
|
||||
// plain HTTP; 443 there belongs to tailscale) — on a *connect*
|
||||
// error, fall back to HTTP so a healthy node isn't "verified" into
|
||||
// a rollback. An HTTP error status stays fatal on whichever scheme
|
||||
// answered.
|
||||
let resp = match client.get("https://127.0.0.1/").send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) if e.is_connect() => client
|
||||
.get("http://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET http://127.0.0.1/ (https not bound on loopback)")?,
|
||||
Err(e) => return Err(e).context("probe GET https://127.0.0.1/"),
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.is_redirection() {
|
||||
return Ok(());
|
||||
@@ -755,10 +752,15 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
.context("Failed to create staging dir")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
// 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))
|
||||
// Per-request budget; each attempt gets the full window, and a retry
|
||||
// resumes via Range from the partial file (download_component_resumable),
|
||||
// so this is an upper bound per attempt, not the whole download. Sized
|
||||
// generously for slow machines on slow links: a ~200MB release at a
|
||||
// crawling ~50KB/s is ~70min, which the old 1h budget could cut off
|
||||
// mid-attempt. 3h leaves ample headroom; raising it cannot slow down or
|
||||
// break a fast download (those finish well inside the old limit).
|
||||
.timeout(std::time::Duration::from_secs(10800))
|
||||
.connect_timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
@@ -1078,6 +1080,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let current_binary = Path::new("/usr/local/bin/archipelago");
|
||||
if current_binary.exists() {
|
||||
let backup_path = backup_dir.join("archipelago");
|
||||
// A leftover backup from an earlier rollback can be root-owned
|
||||
// (rollback used to chown it in place), and fs::copy O_TRUNCs the
|
||||
// existing file — EACCES as the service user, wedging every apply
|
||||
// (seen on .116, v1.7.86 OTA). Unlink first; the dir is
|
||||
// service-owned so unlink works even when the file isn't ours.
|
||||
if backup_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&backup_path).await {
|
||||
tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo");
|
||||
let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await;
|
||||
}
|
||||
}
|
||||
fs::copy(current_binary, &backup_path)
|
||||
.await
|
||||
.context("Failed to backup current binary")?;
|
||||
@@ -1415,21 +1428,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
let backup_binary = backup_dir.join("archipelago");
|
||||
if backup_binary.exists() {
|
||||
// Use host_sudo + mv so we escape the archipelago service's
|
||||
// ProtectSystem=strict mount namespace. A plain fs::copy or
|
||||
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
|
||||
// which would silently orphan the rollback — exactly the
|
||||
// opposite of what auto-rollback is for. Pattern matches
|
||||
// apply_update()'s binary swap above.
|
||||
// Same two namespace gotchas as apply_update()'s binary swap:
|
||||
// `cp` straight onto the running binary is O_TRUNC and fails
|
||||
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
|
||||
// plain sudo inherits ProtectSystem=strict, so everything goes
|
||||
// through host_sudo. Copy to a temp name on the same filesystem,
|
||||
// fix ownership on the TEMP file (never the stored backup — an
|
||||
// in-place chown is what later wedged apply_update), then mv,
|
||||
// which is an atomic rename and tolerates a busy destination.
|
||||
let backup_str = backup_binary.to_string_lossy().to_string();
|
||||
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
|
||||
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
|
||||
let tmp = format!(
|
||||
"/usr/local/bin/.archipelago.rollback.{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let copy = host_sudo(&["cp", &backup_str, &tmp])
|
||||
.await
|
||||
.context("Failed to stage backup binary via host_sudo")?;
|
||||
if !copy.success() {
|
||||
anyhow::bail!(
|
||||
"cp backup binary to {} failed (exit {:?})",
|
||||
tmp,
|
||||
copy.code()
|
||||
);
|
||||
}
|
||||
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
|
||||
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.context("Failed to restore backup binary via host_sudo")?;
|
||||
if !status.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!(
|
||||
"cp backup binary into /usr/local/bin failed (exit {:?})",
|
||||
"mv backup binary into /usr/local/bin failed (exit {:?})",
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
@@ -1647,9 +1677,38 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(list[0].url.contains("146.59.87.168"));
|
||||
assert!(list[1].url.contains("git.tx1138.com"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_mirrors_strips_retired_tx1138_mirror() {
|
||||
// A node that was running before tx1138 was retired has it baked
|
||||
// into its saved mirror list. load_mirrors must strip it on load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let saved = vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
];
|
||||
save_mirrors(dir.path(), &saved).await.unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"retired tx1138 mirror should be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1663,7 +1722,7 @@ mod tests {
|
||||
let back = load_mirrors(dir.path()).await.unwrap();
|
||||
// load_mirrors merges in any missing default mirrors so a node
|
||||
// that explicitly added a single custom mirror still gets the
|
||||
// built-in OVH + tx1138 fallbacks. The custom mirror is preserved.
|
||||
// built-in OVH default. The custom mirror is preserved.
|
||||
assert!(
|
||||
back.iter().any(|m| m.url == "https://example.com/m.json"),
|
||||
"custom mirror should round-trip; got {:?}",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.88-alpha",
|
||||
"version": "1.7.90-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.88-alpha",
|
||||
"version": "1.7.90-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.88-alpha",
|
||||
"version": "1.7.90-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { isTextField, typeKeyIntoField } from '../remote-relay'
|
||||
|
||||
/**
|
||||
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
|
||||
* values in the browser, so the relay edits `.value` at the caret directly and
|
||||
* fires an `input` event. These tests lock in that behaviour so a regression
|
||||
* (like the old "type goes to document, nothing happens" bug) is caught before
|
||||
* release rather than by a user with a companion controller.
|
||||
*/
|
||||
describe('isTextField', () => {
|
||||
it('accepts text-like inputs and textareas', () => {
|
||||
const text = document.createElement('input')
|
||||
text.type = 'text'
|
||||
const search = document.createElement('input')
|
||||
search.type = 'search'
|
||||
const area = document.createElement('textarea')
|
||||
expect(isTextField(text)).toBe(true)
|
||||
expect(isTextField(search)).toBe(true)
|
||||
expect(isTextField(area)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-text controls and null', () => {
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
expect(isTextField(checkbox)).toBe(false)
|
||||
expect(isTextField(document.createElement('button'))).toBe(false)
|
||||
expect(isTextField(document.createElement('div'))).toBe(false)
|
||||
expect(isTextField(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeKeyIntoField', () => {
|
||||
let input: HTMLInputElement
|
||||
let inputEvents: number
|
||||
|
||||
beforeEach(() => {
|
||||
input = document.createElement('input')
|
||||
input.type = 'search'
|
||||
document.body.appendChild(input)
|
||||
inputEvents = 0
|
||||
input.addEventListener('input', () => { inputEvents++ })
|
||||
})
|
||||
|
||||
it('inserts printable characters at the caret and fires input', () => {
|
||||
typeKeyIntoField(input, 'b')
|
||||
typeKeyIntoField(input, 't')
|
||||
typeKeyIntoField(input, 'c')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(3)
|
||||
expect(inputEvents).toBe(3)
|
||||
})
|
||||
|
||||
it('inserts a character in the middle of existing text', () => {
|
||||
input.value = 'bc'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
typeKeyIntoField(input, 't')
|
||||
expect(input.value).toBe('btc')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace deletes the char before the caret', () => {
|
||||
input.value = 'btc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('bt')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('backspace removes the active selection', () => {
|
||||
input.value = 'bitcoin'
|
||||
input.selectionStart = 0
|
||||
input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'Backspace')
|
||||
expect(input.value).toBe('coin')
|
||||
expect(input.selectionStart).toBe(0)
|
||||
})
|
||||
|
||||
it('arrow keys move the caret without changing the value', () => {
|
||||
input.value = 'abc'
|
||||
input.selectionStart = input.selectionEnd = 3
|
||||
typeKeyIntoField(input, 'ArrowLeft')
|
||||
expect(input.selectionStart).toBe(2)
|
||||
expect(input.value).toBe('abc')
|
||||
})
|
||||
|
||||
it('Enter on a single-line input is left for the app to handle', () => {
|
||||
input.value = 'query'
|
||||
input.selectionStart = input.selectionEnd = 5
|
||||
const consumed = typeKeyIntoField(input, 'Enter')
|
||||
expect(consumed).toBe(false)
|
||||
expect(input.value).toBe('query')
|
||||
})
|
||||
|
||||
it('Enter inserts a newline in a textarea', () => {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = 'a'
|
||||
area.selectionStart = area.selectionEnd = 1
|
||||
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
|
||||
expect(area.value).toBe('a\n')
|
||||
})
|
||||
|
||||
it('non-text keys are not consumed as editing', () => {
|
||||
input.value = 'x'
|
||||
input.selectionStart = input.selectionEnd = 1
|
||||
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
|
||||
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
|
||||
expect(input.value).toBe('x')
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string {
|
||||
return KEY_MAP[xdotoolKey] ?? xdotoolKey
|
||||
}
|
||||
|
||||
/** <input> types that accept free-text entry (so we should type into them). */
|
||||
const TEXT_INPUT_TYPES = new Set([
|
||||
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
|
||||
])
|
||||
|
||||
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
|
||||
if (!el) return false
|
||||
if (el.tagName === 'TEXTAREA') return true
|
||||
if (el.tagName === 'INPUT') {
|
||||
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
|
||||
return TEXT_INPUT_TYPES.has(type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
|
||||
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any
|
||||
* app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on
|
||||
* direct ports) are opaque to the parent by browser security policy, so the
|
||||
* deepest reachable element there is the <iframe> itself.
|
||||
*/
|
||||
function deepElementFromPoint(x: number, y: number): Element | null {
|
||||
let cx = x
|
||||
let cy = y
|
||||
let el = document.elementFromPoint(cx, cy)
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc) break
|
||||
const rect = el.getBoundingClientRect()
|
||||
cx -= rect.left
|
||||
cy -= rect.top
|
||||
const inner = doc.elementFromPoint(cx, cy)
|
||||
if (!inner || inner === el) break
|
||||
el = inner
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/** The actually-focused element, descending through same-origin iframes. */
|
||||
function deepActiveElement(): Element | null {
|
||||
let el: Element | null = document.activeElement
|
||||
let guard = 0
|
||||
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
|
||||
let doc: Document | null = null
|
||||
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
|
||||
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
|
||||
el = doc.activeElement
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
|
||||
* input values (browser security), so we edit `.value` at the caret directly
|
||||
* and fire an `input` event so Vue v-model / reactive search pick it up.
|
||||
* Returns true if the key was consumed as text editing.
|
||||
*/
|
||||
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
|
||||
const value = el.value
|
||||
const start = el.selectionStart ?? value.length
|
||||
const end = el.selectionEnd ?? value.length
|
||||
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
|
||||
const replaceSelection = (text: string) => {
|
||||
el.value = value.slice(0, start) + text + value.slice(end)
|
||||
setCaret(start + text.length)
|
||||
}
|
||||
|
||||
if (key === 'Backspace') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
|
||||
else return true
|
||||
} else if (key === 'Delete') {
|
||||
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
|
||||
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
|
||||
} else if (key === 'ArrowLeft') {
|
||||
setCaret(Math.max(0, start - 1))
|
||||
} else if (key === 'ArrowRight') {
|
||||
setCaret(Math.min(value.length, end + 1))
|
||||
} else if (key === 'Home') {
|
||||
setCaret(0)
|
||||
} else if (key === 'End') {
|
||||
setCaret(value.length)
|
||||
} else if (key === 'Enter') {
|
||||
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
|
||||
else return false // let the app's keydown handler act (e.g. search submit)
|
||||
} else if (key.length === 1) {
|
||||
replaceSelection(key) // printable character
|
||||
} else {
|
||||
return false // Tab / Escape / F-keys / etc. — not text editing
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
}
|
||||
|
||||
function handleMessage(data: string) {
|
||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
||||
try {
|
||||
@@ -125,9 +222,17 @@ function handleMessage(data: string) {
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
||||
}
|
||||
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
// Deliver the key to the actually-focused element (descending into
|
||||
// same-origin iframes) so it reaches embedded-app inputs and search
|
||||
// boxes, not just the top-level document.
|
||||
const focused = deepActiveElement()
|
||||
const keyTarget: EventTarget = focused ?? document
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
// Synthetic key events never insert text, so edit the field directly.
|
||||
if (isTextField(focused)) {
|
||||
typeKeyIntoField(focused, key)
|
||||
}
|
||||
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
break
|
||||
}
|
||||
case 'm': {
|
||||
@@ -135,16 +240,29 @@ function handleMessage(data: string) {
|
||||
break
|
||||
}
|
||||
case 'c': {
|
||||
const target = document.elementFromPoint(cursorX, cursorY)
|
||||
const target = deepElementFromPoint(cursorX, cursorY)
|
||||
if (target) {
|
||||
if (cursorEl) {
|
||||
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
|
||||
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, cancelable: true,
|
||||
const eventInit: MouseEventInit = {
|
||||
bubbles: true, cancelable: true, view: window,
|
||||
clientX: cursorX, clientY: cursorY,
|
||||
}))
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
|
||||
target.dispatchEvent(new MouseEvent('click', eventInit))
|
||||
// A synthetic click does NOT move keyboard focus the way a real click
|
||||
// does, so the app-store search box (and any input) would stay
|
||||
// unfocused and untypable. Explicitly focus the nearest focusable
|
||||
// element — for same-origin iframe targets this focuses inside the app.
|
||||
const focusable = (target.closest?.(
|
||||
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
|
||||
) ?? target) as HTMLElement
|
||||
if (typeof focusable.focus === 'function') {
|
||||
focusable.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
/** Shape of GET /electrs-status (see core electrs_status.rs ElectrsSyncStatus). */
|
||||
export interface ElectrsSyncStatus {
|
||||
indexed_height: number
|
||||
bitcoin_height: number
|
||||
network_height: number
|
||||
progress_pct: number
|
||||
status: string // "starting" | "waiting" | "syncing" | "indexing" | "synced" | "error"
|
||||
stale: boolean
|
||||
error: string | null
|
||||
index_size: string | null
|
||||
tor_onion: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GET /electrs-status while active. Used to show an ElectrumX sync screen
|
||||
* *before* the real Electrum UI — the Electrum server only accepts client
|
||||
* connections (and the UI only works) once the on-chain index is built, which
|
||||
* is a long initial process.
|
||||
*
|
||||
* Fails OPEN: if the status can't be fetched we report not-syncing, so a status
|
||||
* outage never blocks the normal iframe path. We only gate the UI when we
|
||||
* positively know the index is still being built (status !== "synced").
|
||||
*/
|
||||
export function useElectrsSync() {
|
||||
const status = ref<ElectrsSyncStatus | null>(null)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch('/electrs-status', { cache: 'no-store' })
|
||||
if (res.ok) status.value = (await res.json()) as ElectrsSyncStatus
|
||||
} catch {
|
||||
/* keep last known value; fail open */
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer) return
|
||||
void poll()
|
||||
timer = setInterval(() => void poll(), 8000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** True only when we know ElectrumX is still building its index. */
|
||||
const syncing = computed(() => !!status.value && status.value.status !== 'synced')
|
||||
|
||||
onUnmounted(stop)
|
||||
return { status, syncing, start, stop }
|
||||
}
|
||||
@@ -556,8 +556,8 @@ input[type="radio"]:active + * {
|
||||
context) stay above the tab bar instead of sliding underneath it. */
|
||||
@media (max-width: 767px) {
|
||||
.chat-iframe-mobile {
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Open a URL in the device's real browser.
|
||||
*
|
||||
* In a normal mobile/desktop browser this is just `window.open(_blank)`. Inside
|
||||
* the Android companion app the page runs in a WebView where `window.open` is
|
||||
* unreliable (noopener/noreferrer can suppress onCreateWindow), so the native
|
||||
* shell injects a `window.ArchipelagoNative.openExternal(url)` bridge that hands
|
||||
* the URL to an ACTION_VIEW intent. We prefer the bridge when present and fall
|
||||
* back to `window.open` otherwise — so the working mobile-browser path is
|
||||
* untouched.
|
||||
*/
|
||||
interface ArchipelagoNativeBridge {
|
||||
openExternal?: (url: string) => void
|
||||
}
|
||||
|
||||
export function openExternalUrl(url: string): void {
|
||||
if (!url) return
|
||||
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge })
|
||||
.ArchipelagoNative
|
||||
if (native && typeof native.openExternal === 'function') {
|
||||
native.openExternal(url)
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:electrs-sync="electrsSync"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@@ -106,6 +107,8 @@ import {
|
||||
import { launchBlockedReason } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
@@ -155,6 +158,23 @@ const blockedReason = computed(() => launchBlockedReason(appId.value, packageEnt
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
const isElectrsApp = computed(() =>
|
||||
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
||||
)
|
||||
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
||||
useElectrsSync()
|
||||
const electrsSync = computed(() =>
|
||||
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
||||
)
|
||||
watch(
|
||||
isElectrsApp,
|
||||
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
||||
{ immediate: true }
|
||||
)
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
@@ -294,12 +314,12 @@ function startLoadTimeout() {
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
function iframeGoBack() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill flex">
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -13,14 +13,7 @@
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile back button -->
|
||||
<button class="chat-mobile-back md:hidden" :aria-label="t('common.goBack')" @click="closeChat">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator while iframe loads -->
|
||||
<Transition name="fade">
|
||||
@@ -79,8 +72,8 @@ let broker: ContextBroker | null = null
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true`
|
||||
if (import.meta.env.PROD) return '/aiui/?embedded=true'
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
|
||||
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
<div
|
||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||
:class="[
|
||||
'h-full dashboard-scroll-panel mobile-scroll-pad',
|
||||
route.path === '/dashboard/mesh' ? 'mesh-dashboard-panel' : '',
|
||||
'h-full',
|
||||
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
|
||||
mobileTabPaddingTop ? 'overflow-y-auto' : ''
|
||||
]"
|
||||
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
||||
|
||||
@@ -9,8 +9,40 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
index is still being built (the Electrum server can't serve clients
|
||||
until then). Mirrors the Fedimint Guardian "wait page" design. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="electrsSync" 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>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
ElectrumX is building its index from the blockchain. The UI opens
|
||||
automatically once it's ready — you can keep using the rest of Archipelago.
|
||||
</p>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400/80 transition-all duration-700"
|
||||
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
|
||||
<p class="text-white/40 text-xs">
|
||||
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
|
||||
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
|
||||
</p>
|
||||
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked"
|
||||
v-if="appUrl && !iframeBlocked && !electrsSync"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@@ -79,6 +111,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
@@ -91,6 +124,9 @@ const props = defineProps<{
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// Non-null only for ElectrumX while its index is still building — shows the
|
||||
// sync screen and gates the iframe until status flips to "synced".
|
||||
electrsSync?: ElectrsSyncStatus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-stretch justify-stretch bg-black/80 backdrop-blur-md p-0" @click.self="closeCredentialModal">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
@@ -337,17 +337,21 @@ function scrollToPage(index: number) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal. The body scrolls if content overflows
|
||||
rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: none;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -150,6 +150,12 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
|
||||
'/aiui': {
|
||||
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
+19
-16
@@ -1,27 +1,30 @@
|
||||
{
|
||||
"version": "1.7.88-alpha",
|
||||
"release_date": "2026-06-12",
|
||||
"version": "1.7.90-alpha",
|
||||
"release_date": "2026-06-13",
|
||||
"changelog": [
|
||||
"AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.",
|
||||
"Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.",
|
||||
"Validation pending on the AIUI rollback; the rest of the release train remains unchanged."
|
||||
"Generating a Bitcoin receive address works again \u2014 the wallet now requests the correct address type, fixing the \"400 Bad Request\" error when creating an address.",
|
||||
"In the companion app, the on-screen pointer can now click into apps and type \u2014 including the app store search box \u2014 instead of clicks and keystrokes not reaching app content.",
|
||||
"\"Open in a new tab\" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.",
|
||||
"The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.",
|
||||
"The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.",
|
||||
"Software updates are more reliable on slow internet connections \u2014 downloads are given much more time to finish before giving up."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.88-alpha",
|
||||
"new_version": "1.7.88-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago",
|
||||
"sha256": "5948655918c76f7a3e53e2c61c3e5662b4035eb4692545546b17d7f2d4d782c3",
|
||||
"size_bytes": 44057520
|
||||
"current_version": "1.7.90-alpha",
|
||||
"new_version": "1.7.90-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.90-alpha/archipelago",
|
||||
"sha256": "b4b0c0b0021c0532f35831febb17c996db023bad47baa44e9941c59f2c5a5124",
|
||||
"size_bytes": 44089736
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.88-alpha.tar.gz",
|
||||
"current_version": "1.7.88-alpha",
|
||||
"new_version": "1.7.88-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago-frontend-1.7.88-alpha.tar.gz",
|
||||
"sha256": "158be23c11d3a5d007e2a99d70eaafce2f287c5d3c240c4ab37945245f006a13",
|
||||
"size_bytes": 184056182
|
||||
"name": "archipelago-frontend-1.7.90-alpha.tar.gz",
|
||||
"current_version": "1.7.90-alpha",
|
||||
"new_version": "1.7.90-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.90-alpha/archipelago-frontend-1.7.90-alpha.tar.gz",
|
||||
"sha256": "8adb6a46def342e2e89632fcd8524ed206ab984df5a883576d7a5534d922934a",
|
||||
"size_bytes": 184059667
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+19
-16
@@ -1,27 +1,30 @@
|
||||
{
|
||||
"version": "1.7.88-alpha",
|
||||
"release_date": "2026-06-12",
|
||||
"version": "1.7.90-alpha",
|
||||
"release_date": "2026-06-13",
|
||||
"changelog": [
|
||||
"AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.",
|
||||
"Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.",
|
||||
"Validation pending on the AIUI rollback; the rest of the release train remains unchanged."
|
||||
"Generating a Bitcoin receive address works again \u2014 the wallet now requests the correct address type, fixing the \"400 Bad Request\" error when creating an address.",
|
||||
"In the companion app, the on-screen pointer can now click into apps and type \u2014 including the app store search box \u2014 instead of clicks and keystrokes not reaching app content.",
|
||||
"\"Open in a new tab\" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.",
|
||||
"The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.",
|
||||
"The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.",
|
||||
"Software updates are more reliable on slow internet connections \u2014 downloads are given much more time to finish before giving up."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.88-alpha",
|
||||
"new_version": "1.7.88-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago",
|
||||
"sha256": "5948655918c76f7a3e53e2c61c3e5662b4035eb4692545546b17d7f2d4d782c3",
|
||||
"size_bytes": 44057520
|
||||
"current_version": "1.7.90-alpha",
|
||||
"new_version": "1.7.90-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.90-alpha/archipelago",
|
||||
"sha256": "b4b0c0b0021c0532f35831febb17c996db023bad47baa44e9941c59f2c5a5124",
|
||||
"size_bytes": 44089736
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.88-alpha.tar.gz",
|
||||
"current_version": "1.7.88-alpha",
|
||||
"new_version": "1.7.88-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago-frontend-1.7.88-alpha.tar.gz",
|
||||
"sha256": "158be23c11d3a5d007e2a99d70eaafce2f287c5d3c240c4ab37945245f006a13",
|
||||
"size_bytes": 184056182
|
||||
"name": "archipelago-frontend-1.7.90-alpha.tar.gz",
|
||||
"current_version": "1.7.90-alpha",
|
||||
"new_version": "1.7.90-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.90-alpha/archipelago-frontend-1.7.90-alpha.tar.gz",
|
||||
"sha256": "8adb6a46def342e2e89632fcd8524ed206ab984df5a883576d7a5534d922934a",
|
||||
"size_bytes": 184059667
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
# Release gate harness — seed of the full-system test harness.
|
||||
#
|
||||
# Ties together the checks that already exist in this repo (catalog drift,
|
||||
# release manifest, lifecycle bats, vitest, cargo tests) plus live-node
|
||||
# smoke probes, so "is this release OK?" is one command instead of folklore.
|
||||
#
|
||||
# Usage:
|
||||
# tests/release/run.sh # static + frontend + backend stages
|
||||
# tests/release/run.sh --quick # static + frontend unit only
|
||||
# tests/release/run.sh --with-build # also production-build the frontend
|
||||
# # and verify the dist version changed
|
||||
# tests/release/run.sh --manifest # also validate releases/manifest.json
|
||||
# # (run AFTER create-release staged it)
|
||||
# tests/release/run.sh --live [URL] # also smoke-probe a running node
|
||||
# # (default http://127.0.0.1)
|
||||
#
|
||||
# Flags compose. Exits non-zero on the first failing stage.
|
||||
#
|
||||
# CAUTION (.116 and other dev nodes): full `cargo test -p archipelago` has
|
||||
# hung tool PTYs here before — every cargo invocation below is wrapped in
|
||||
# `timeout` and scoped to focused module filters.
|
||||
|
||||
set -u
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
QUICK=0 WITH_BUILD=0 MANIFEST=0 LIVE=0 LIVE_URL="http://127.0.0.1"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--quick) QUICK=1 ;;
|
||||
--with-build) WITH_BUILD=1 ;;
|
||||
--manifest) MANIFEST=1 ;;
|
||||
--live) LIVE=1; [[ "${2:-}" == http* ]] && { LIVE_URL="$2"; shift; } ;;
|
||||
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
PASS=() FAIL=()
|
||||
stage() { # stage <name> <cmd...>
|
||||
local name="$1"; shift
|
||||
echo
|
||||
echo "=== [$name] $*"
|
||||
if "$@"; then
|
||||
echo "=== [$name] PASS"
|
||||
PASS+=("$name")
|
||||
else
|
||||
echo "=== [$name] FAIL (exit $?)"
|
||||
FAIL+=("$name")
|
||||
summary 1
|
||||
fi
|
||||
}
|
||||
summary() {
|
||||
echo
|
||||
echo "──────── release gate summary ────────"
|
||||
printf 'PASS: %s\n' "${PASS[@]:-none}"
|
||||
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||
stage "git-diff-check" git diff --check
|
||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
|
||||
if [[ $MANIFEST -eq 1 ]]; then
|
||||
stage "release-manifest" scripts/check-release-manifest.sh
|
||||
fi
|
||||
|
||||
# ── Stage 2: frontend ────────────────────────────────────────────────
|
||||
stage "ui-type-check" bash -c 'cd neode-ui && npm run --silent type-check'
|
||||
stage "ui-unit-tests" bash -c 'cd neode-ui && npx vitest run --silent 2>&1 | tail -4; exit ${PIPESTATUS[0]}'
|
||||
|
||||
if [[ $WITH_BUILD -eq 1 ]]; then
|
||||
# npm run build can fail silently (vue-tsc EACCES burned us before) —
|
||||
# require the packaged output to actually contain the current version.
|
||||
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | cut -d'"' -f2)
|
||||
stage "ui-build" bash -c 'cd neode-ui && npm run build'
|
||||
stage "ui-dist-version" bash -c "grep -rqo '${VERSION}' web/dist/neode-ui/assets/*.js"
|
||||
fi
|
||||
|
||||
[[ $QUICK -eq 1 ]] && summary 0
|
||||
|
||||
# ── Stage 3: backend ─────────────────────────────────────────────────
|
||||
stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml -p archipelago
|
||||
# Focused suites for the subsystems this release train touched:
|
||||
# update:: — OTA download/apply/rollback/probe (v1.7.89 hardening)
|
||||
# lnd — receive address + wallet readiness (v1.7.85–.89)
|
||||
# container::image_versions — image pinning / false-update detection
|
||||
# scanner — RAII in-flight guard (v1.7.84)
|
||||
# 1500s: the non-incremental test-profile compile alone takes ~9 min on the
|
||||
# .116 ThinkPad; 580s expires mid-compile (exit 124) before a single test runs.
|
||||
stage "cargo-test-weekly" timeout 1500 env CARGO_INCREMENTAL=0 \
|
||||
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
||||
update:: lnd container::image_versions scanner
|
||||
|
||||
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
||||
if [[ $LIVE -eq 1 ]]; then
|
||||
stage "live-frontend" bash -c "curl -skf -o /dev/null '$LIVE_URL/' || curl -skf -o /dev/null '${LIVE_URL/http:/https:}/'"
|
||||
stage "live-aiui" curl -sf -o /dev/null "$LIVE_URL/aiui/"
|
||||
stage "live-rpc" bash -c "curl -s -X POST '$LIVE_URL/rpc/v1' -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"update.status\",\"params\":{}}' | grep -qE '\"(result|error)\"'"
|
||||
|
||||
# Bitcoin-receive regression guard. The backend asks LND REST for a new
|
||||
# on-chain address with ?type=<AddressType>. The REST gateway parses that
|
||||
# as the proto enum (WITNESS_PUBKEY_HASH / 0), NOT the lncli aliases —
|
||||
# sending "p2wkh" returns 400 "parsing field type ... is not a valid
|
||||
# value" and bitcoin-receive silently breaks for the whole fleet (the bug
|
||||
# that slipped through v1.7.88/89 because nothing exercised LND live).
|
||||
# This hits LND REST directly and FAILS only on that exact parse-error
|
||||
# signature; a "wallet locked" / "still syncing" reply means the type was
|
||||
# accepted, which is all we're validating here.
|
||||
stage "live-lnd-address-type" bash -c '
|
||||
mac=$(sudo cat /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon 2>/dev/null | od -An -tx1 | tr -d " \n")
|
||||
for port in 18080 8080; do
|
||||
resp=$(curl -sk --max-time 8 "https://127.0.0.1:$port/v1/newaddress?type=WITNESS_PUBKEY_HASH" -H "Grpc-Metadata-macaroon: $mac" 2>/dev/null)
|
||||
[ -z "$resp" ] && continue
|
||||
echo "LND($port): $resp"
|
||||
echo "$resp" | grep -q "is not a valid value" && { echo "FAIL: LND rejected the address type the backend sends"; exit 1; }
|
||||
echo "OK: LND accepted the address type"; exit 0
|
||||
done
|
||||
echo "SKIP: LND REST not reachable on 18080/8080 — cannot validate address type live"; exit 0
|
||||
'
|
||||
fi
|
||||
|
||||
summary 0
|
||||
Reference in New Issue
Block a user