Compare commits

...
Author SHA1 Message Date
archipelago bb808df89a chore: release v1.7.90-alpha 2026-06-13 05:05:14 -04:00
archipelagoandClaude Opus 4.8 c800293f1f fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout
- LND wallet: request correct address type so receive-address generation
  no longer 400s
- AIUI/app session: on-screen pointer can click + type into app content
  (incl. app store search); "open in new tab" opens the phone browser;
  mobile credential modal centered instead of full-height
  (remote-relay.ts, AppSession.vue, AppSessionFrame.vue, AppIconGrid.vue,
  openExternal.ts, WebViewScreen.kt) + remote-relay tests
- health_monitor: electrs auto-recovers from a corrupt index and shows a
  percent/block-height progress screen while reindexing (useElectrsSync.ts)
- update.rs: drop retired tx1138 secondary mirror (one-time migration);
  longer download timeout for slow connections
- CHANGELOG: v1.7.90-alpha notes
- tests/release/run.sh: harness tweaks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 04:49:32 -04:00
archipelago 340b981b79 chore: release v1.7.89-alpha 2026-06-13 01:34:11 -04:00
archipelagoandClaude Opus 4.8 c49e8fcacd fix: harden OTA updates, AIUI desktop gap, LND no-proxy
- update.rs: post-OTA probe falls back to http://127.0.0.1/ on connect
  error (nginx binds :80, not :443) so good updates are no longer rolled
  back; recover stuck update_in_progress; avoid ETXTBSY on running binary
- LND: REST client bypasses proxy, GET newaddress p2wkh, wallet
  readiness/unlock after restart
- Dashboard.vue: chat route back to plain h-full (desktop bottom-gap fix)
- vite.config.ts: dev-only /aiui proxy
- tests/release/run.sh: release gate harness (static+frontend+backend)
- CHANGELOG: v1.7.89-alpha notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:23:32 -04:00
archipelago 495b90782a fix: restore AIUI mobile layout 2026-06-12 06:01:24 -04:00
archipelago 0cfb4dc81c chore: release v1.7.88-alpha 2026-06-12 05:12:52 -04:00
archipelago b8ac68d844 fix: restore aiui and bitcoin receive before release 2026-06-12 05:10:03 -04:00
archipelago eaf13effd5 fix: restore fast AIUI launch 2026-06-12 05:04:42 -04:00
archipelago 0339268c43 chore: sync cargo lock for v1.7.87-alpha 2026-06-12 04:55:09 -04:00
archipelago 6fd1cf9ba7 chore: release v1.7.87-alpha 2026-06-12 04:49:58 -04:00
archipelago 8d4b309753 fix: patch bitcoin receive and full-screen launch overlays 2026-06-12 04:42:23 -04:00
archipelago b11c6c17d1 chore: release v1.7.86-alpha 2026-06-12 04:21:18 -04:00
43 changed files with 1216 additions and 248 deletions
@@ -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
+38
View File
@@ -1,5 +1,43 @@
# 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.
- 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.
## v1.7.87-alpha (2026-06-12)
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
## v1.7.86-alpha (2026-06-12)
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
+1
View File
@@ -57,6 +57,7 @@ app:
interval: 30s
timeout: 5s
retries: 3
start_period: 10m
bitcoin_integration:
rpc_access: read-only
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.85-alpha"
version = "1.7.89-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.85-alpha"
version = "1.7.90-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+2
View File
@@ -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()
+1
View File
@@ -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()
+16 -6
View File
@@ -13,6 +13,10 @@ impl RpcHandler {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
// 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()
@@ -20,15 +24,20 @@ impl RpcHandler {
.context("LND REST connection failed")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
let raw_body = resp
.text()
.await
.context("Failed to parse newaddress response")?;
.context("LND address response could not be read")?;
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
serde_json::json!({
"raw": raw_body,
})
});
if !status.is_success() {
let message = lnd_error_message(&body);
anyhow::bail!(
"LND could not generate a Bitcoin address ({}): {}",
"Bitcoin address generation failed ({}): {}",
status,
message
);
@@ -39,14 +48,14 @@ impl RpcHandler {
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
anyhow::bail!("LND could not generate a Bitcoin address: {}", error);
anyhow::bail!("Bitcoin address generation failed: {}", error);
}
let address = body
.get("address")
.and_then(|v| v.as_str())
.filter(|addr| !addr.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.ok_or_else(|| anyhow::anyhow!("Bitcoin address generation failed: LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.to_string();
Ok(serde_json::json!({ "address": address }))
@@ -525,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()
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Failed to start",
"Container",
"Image",
"Bitcoin address",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
+4 -2
View File
@@ -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()
+119
View File
@@ -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
View File
@@ -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 {:?}",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.85-alpha",
"version": "1.7.90-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.85-alpha",
"version": "1.7.90-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.85-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')
})
})
+125 -7
View File
@@ -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
}
@@ -3,16 +3,17 @@
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
class="fixed inset-0 z-[2400] flex items-stretch justify-stretch p-0"
@click.self="store.close()"
>
<!-- Backdrop - blur like spotlight -->
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div class="app-launcher-backdrop absolute inset-0 bg-black/75 backdrop-blur-md"></div>
<!-- Panel - inset with margins, glass style like spotlight -->
<!-- Panel - full-screen overlay -->
<div
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none shadow-2xl"
:class="panelClasses"
style="border-radius: 0;"
>
<!-- Header bar - sticky on mobile -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
@@ -69,7 +70,7 @@
<!-- Loading indicator -->
<Transition name="content-fade">
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<svg class="animate-spin h-8 w-8 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@@ -398,7 +399,7 @@ function injectScrollbarHideIfSameOrigin() {
const panelClasses = [
'glass-card',
'w-full h-full',
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
'max-w-none max-h-none',
]
function onKeyDown(e: KeyboardEvent) {
@@ -5,7 +5,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
@@ -36,6 +36,11 @@ type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; tar
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
@@ -50,6 +55,7 @@ function nodeRadius(n: MapNode): number {
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
@@ -160,7 +166,7 @@ onUnmounted(() => {
resizeObserver?.disconnect()
})
watch(() => [props.nodes, props.links], () => render(), { deep: true })
watch(graphSignature, () => render())
</script>
<style scoped>
@@ -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 }
}
@@ -17,4 +17,8 @@ describe('explainReceiveAddressFailure', () => {
it('explains lnd transport failures', () => {
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
})
it('keeps bitcoin address generation failures visible', () => {
expect(explainReceiveAddressFailure(new Error('Bitcoin address generation failed: LND is not ready'))).toContain('Bitcoin address generation failed')
})
})
+25
View File
@@ -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')
}
+3 -3
View File
@@ -19,11 +19,11 @@
<!-- Registry list -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-2">
<h2 class="text-lg font-semibold text-white">Registries</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors w-full sm:w-auto"
@click="openAddRegistry"
>+ Add registry</button>
</div>
@@ -118,7 +118,7 @@
<!-- Back link -->
<RouterLink
to="/dashboard/settings"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
+22 -2
View File
@@ -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() {
+17 -15
View File
@@ -244,10 +244,10 @@
<Transition name="fade">
<div
v-if="credentialModal.show"
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
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 class="sideload-modal credential-modal">
<div class="credential-modal-panel">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
@@ -259,7 +259,7 @@
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
@@ -802,15 +802,24 @@ async function submitSideload() {
}
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
.credential-modal {
.credential-modal-panel {
display: flex;
flex-direction: column;
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);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
width: 100%;
height: 100%;
min-height: 0;
max-width: none;
max-height: none;
overflow: hidden;
border: 0;
border-radius: 0;
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;
}
.credential-modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
@@ -818,11 +827,4 @@ async function submitSideload() {
.credential-modal-actions {
flex-shrink: 0;
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>
+17 -35
View File
@@ -1,6 +1,6 @@
<template>
<div class="chat-fullscreen">
<!-- Close button (desktop: top-right pill) -->
<!-- Close button + connection indicator (desktop: top-right pill) -->
<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">
@@ -8,11 +8,16 @@
</svg>
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
</button>
<div
v-if="aiuiConnected"
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>
<!-- Loading indicator while checking availability -->
<!-- Loading indicator while iframe loads -->
<Transition name="fade">
<div v-if="aiuiAvailable === null" class="chat-loading" role="status" aria-live="polite">
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
@@ -27,24 +32,23 @@
:src="aiuiUrl"
:title="t('chat.aiAssistant')"
class="chat-iframe chat-iframe-mobile"
allow="microphone"
style="background: transparent"
/>
<!-- Fallback when AIUI is not deployed -->
<div v-else-if="aiuiAvailable === false" class="chat-placeholder">
<div class="chat-placeholder-inner glass-card p-8">
<!-- Fallback when no AIUI URL configured -->
<div v-else class="chat-placeholder">
<div class="chat-placeholder-inner">
<div class="chat-placeholder-icon">
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
<p class="text-white/80 mb-4 leading-relaxed">
<p class="text-white/60 mb-4 leading-relaxed">
{{ t('chat.notConfigured') }}
</p>
<p class="text-sm text-white/50">
<p class="text-xs text-white/30">
{{ t('chat.deployCta') }}
</p>
</div>
@@ -63,37 +67,16 @@ const { t } = useI18n()
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
// In production, only return the URL if we've confirmed AIUI files exist
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&hideClose=true&v=${Date.now()}`
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
return ''
})
/** Check if AIUI is actually deployed by fetching its index.html */
async function checkAiuiAvailable() {
if (import.meta.env.VITE_AIUI_URL) {
aiuiAvailable.value = true
return
}
if (!import.meta.env.PROD) {
aiuiAvailable.value = false
return
}
try {
const res = await fetch('/aiui/', { method: 'HEAD' })
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
aiuiAvailable.value = res.ok
} catch {
aiuiAvailable.value = false
}
}
function closeChat() {
if (window.history.length > 1) {
router.back()
@@ -111,13 +94,12 @@ function onAiuiMessage(event: MessageEvent) {
} catch { return }
// Listen for ready messages from AIUI iframe
if (event.data?.type === 'ready') {
// AIUI connected - could use for future features
aiuiConnected.value = true
}
}
onMounted(async () => {
onMounted(() => {
window.addEventListener('message', onAiuiMessage)
await checkAiuiAvailable()
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
+2 -2
View File
@@ -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 }"
+56 -14
View File
@@ -69,12 +69,12 @@
</div>
<!-- Actions -->
<div class="flex gap-3">
<div class="flex flex-col gap-3 sm:flex-row">
<!-- Git path: one-shot pull+rebuild+restart -->
<button
v-if="updateMethod === 'git' && !applying"
@click="requestGitApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
>
{{ t('systemUpdate.pullAndRebuild') }}
</button>
@@ -82,14 +82,14 @@
<button
v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded"
@click="downloadUpdate"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium sm:w-auto"
>
{{ t('systemUpdate.downloadUpdate') }}
</button>
<button
v-if="updateMethod !== 'git' && downloaded && !applying"
@click="requestApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
>
{{ t('systemUpdate.applyUpdate') }}
</button>
@@ -145,8 +145,16 @@
<!-- Applying -->
<div v-if="applying" class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
<div class="flex items-center gap-3">
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<svg
class="w-5 h-5 shrink-0 animate-spin text-orange-400"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.2"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
</div>
</div>
@@ -262,22 +270,25 @@
<!-- Actions row -->
<div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
<div class="flex flex-wrap gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
@click="checkForUpdates"
:disabled="loading"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40 sm:w-auto"
>
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
</button>
<button
v-if="rollbackAvailable"
@click="requestRollback"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20 sm:w-auto"
>
{{ t('systemUpdate.rollback') }}
</button>
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
<RouterLink
to="/dashboard/settings"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium text-center sm:w-auto"
>
{{ t('systemUpdate.backToSettings') }}
</RouterLink>
</div>
@@ -650,12 +661,28 @@ const installStartedAt = ref<number>(0)
const installElapsedSec = ref(0)
let installPollTimer: ReturnType<typeof setInterval> | null = null
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
let installReadyTimer: ReturnType<typeof setTimeout> | null = null
const installElapsedLabel = computed(() => {
const s = installElapsedSec.value
if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
})
function clearInstallTimers() {
if (installPollTimer) {
clearInterval(installPollTimer)
installPollTimer = null
}
if (installElapsedTimer) {
clearInterval(installElapsedTimer)
installElapsedTimer = null
}
if (installReadyTimer) {
clearTimeout(installReadyTimer)
installReadyTimer = null
}
}
function startInstallOverlay(targetVersion: string) {
clearInstallTimers()
installing.value = true
installStage.value = 'applying'
installTargetVersion.value = targetVersion
@@ -672,7 +699,7 @@ function startInstallOverlay(targetVersion: string) {
// Start polling /health after a short delay the backend restarts 2s
// after replying to update.apply, so an immediate poll would see the
// old backend and conclude nothing happened.
setTimeout(() => {
installReadyTimer = setTimeout(() => {
installStage.value = 'restarting'
installPollTimer = setInterval(pollHealth, 1500)
}, 2500)
@@ -687,22 +714,37 @@ async function pollHealth() {
installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
// Brief pause so the user sees the "Ready" state before the reload.
setTimeout(() => { window.location.reload() }, 1200)
installReadyTimer = setTimeout(() => { window.location.reload() }, 1200)
} else {
// Backend is up but still reporting the old version frontend
// and backend are mid-swap. Signal to the user.
installStage.value = 'reconnecting'
void confirmBackendUpdateSettled()
}
} catch {
// Fetch fails while the server is mid-restart. Stay in 'restarting'.
}
}
async function confirmBackendUpdateSettled() {
try {
const res = await rpcClient.call<{
current_version: string
update_in_progress: boolean
}>({ method: 'update.status' })
if (!res.update_in_progress && installStage.value !== 'ready' && installStage.value !== 'stalled') {
installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
installReadyTimer = setTimeout(() => { window.location.reload() }, 800)
}
} catch {
// Keep waiting on /health.
}
}
function reloadNow() { window.location.reload() }
// Cleanup if the component is torn down mid-install (unlikely but safe).
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
if (installPollTimer) clearInterval(installPollTimer)
if (installElapsedTimer) clearInterval(installElapsedTimer)
clearInstallTimers()
})
const lastCheckDisplay = computed(() => {
@@ -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<{
+23 -15
View File
@@ -85,8 +85,8 @@
</div>
<Transition name="fade">
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
<div class="sideload-modal credential-modal">
<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>
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
@@ -98,7 +98,7 @@
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
@@ -328,24 +328,32 @@ function scrollToPage(index: number) {
background: rgba(255, 255, 255, 0.06);
}
.credential-modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.credential-modal {
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);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
.credential-modal-panel {
display: flex;
flex-direction: column;
width: 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;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.5rem;
background: rgba(8, 10, 18, 0.98);
padding: 1.25rem;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.credential-modal-actions {
flex-shrink: 0;
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>
@@ -16,7 +16,7 @@
</div>
<button
@click="$emit('generate-invite', 'trusted')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite"
>
{{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }}
@@ -36,7 +36,7 @@
</div>
<button
@click="$emit('generate-invite', 'observer')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite"
>
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
@@ -56,7 +56,7 @@
</div>
<button
@click="$emit('show-join')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Enter Code
</button>
@@ -75,7 +75,7 @@
</div>
<button
@click="$emit('sync')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="syncing"
>
{{ syncing ? 'Syncing...' : 'Sync Now' }}
@@ -13,9 +13,10 @@
<th
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-matrix-header-cell font-mono"
class="fleet-matrix-header-cell"
:title="fleetNodeSubtitle(node)"
>
{{ node.node_id.slice(0, 6) }}
{{ fleetNodeDisplayName(node) }}
</th>
</tr>
</thead>
@@ -39,7 +40,7 @@
</template>
<script setup lang="ts">
import { type FleetNode, getContainerState } from './useFleetData'
import { type FleetNode, getContainerState, fleetNodeDisplayName, fleetNodeSubtitle } from './useFleetData'
defineProps<{
nodes: FleetNode[]
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Hostname</p>
<p class="text-lg font-bold text-white truncate">{{ node.hostname || 'Unknown' }}</p>
<p class="text-lg font-bold text-white truncate">{{ node.hostname || fleetNodeDisplayName(node) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p>
+82 -6
View File
@@ -187,21 +187,59 @@ export function normalizeNodeHistoryResponse(data: {
return []
}
type FleetCache = {
nodes: FleetNode[]
fleetAlerts: FleetAlert[]
lastRefreshed: string
selectedNodeId: string | null
sortBy: SortOption
}
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v1'
function readFleetCache(): Partial<FleetCache> {
if (typeof window === 'undefined') return {}
try {
const raw = window.sessionStorage.getItem(FLEET_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<FleetCache>
return {
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode) : [],
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts : [],
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
}
} catch {
return {}
}
}
function writeFleetCache(state: FleetCache) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(FLEET_CACHE_KEY, JSON.stringify(state))
} catch {
// Cache is opportunistic only.
}
}
// --- Composable ---
export function useFleetData() {
const loading = ref(true)
const cached = readFleetCache()
const loading = ref(!(cached.nodes?.length ?? 0))
const errorMessage = ref('')
const nodes = ref<FleetNode[]>([])
const fleetAlerts = ref<FleetAlert[]>([])
const nodes = ref<FleetNode[]>(cached.nodes ?? [])
const fleetAlerts = ref<FleetAlert[]>(cached.fleetAlerts ?? [])
const refreshing = ref(false)
const alertsLoading = ref(false)
const selectedNodeId = ref<string | null>(null)
const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null)
const nodeHistory = ref<NodeHistoryEntry[]>([])
const nodeHistoryLoading = ref(false)
const autoRefresh = ref(true)
const lastRefreshed = ref('')
const sortBy = ref<SortOption>('status')
const lastRefreshed = ref(cached.lastRefreshed ?? '')
const sortBy = ref<SortOption>(cached.sortBy ?? 'status')
const chartWidth = ref(300)
let pollTimer: ReturnType<typeof setInterval> | null = null
@@ -284,6 +322,13 @@ export function useFleetData() {
if (data?.nodes) {
nodes.value = data.nodes.map(normalizeFleetNode)
lastRefreshed.value = new Date().toISOString()
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
}
} catch (err) {
if (loading.value) {
@@ -300,6 +345,13 @@ export function useFleetData() {
})
if (data?.alerts) {
fleetAlerts.value = data.alerts
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
}
} catch {
// Non-critical, retry on next poll
@@ -342,6 +394,13 @@ export function useFleetData() {
} else {
selectedNodeId.value = nodeId
}
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
}
function toggleAutoRefresh() {
@@ -400,6 +459,23 @@ export function useFleetData() {
} else {
nodeHistory.value = []
}
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
})
watch(sortBy, () => {
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
})
// --- Lifecycle ---
@@ -5,7 +5,7 @@ import { RouterLink } from 'vue-router'
<template>
<!-- App Registries Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
<p class="text-sm text-white/60 mt-1">
@@ -14,7 +14,7 @@ import { RouterLink } from 'vue-router'
</div>
<RouterLink
to="/dashboard/settings/registries"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -7,12 +7,15 @@ const { t } = useI18n()
<template>
<!-- System Updates Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
</div>
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<RouterLink
to="/dashboard/settings/update"
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
+68 -4
View File
@@ -324,14 +324,49 @@ const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore()
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v1'
type ConnectedNodesCache = {
peers: Peer[]
observers: Peer[]
peerReachable: Record<string, boolean>
connectionRequests: ConnectionRequest[]
}
function readConnectedNodesCache(): Partial<ConnectedNodesCache> {
if (typeof window === 'undefined') return {}
try {
const raw = window.sessionStorage.getItem(CONNECTED_NODES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<ConnectedNodesCache>
return {
peers: Array.isArray(parsed.peers) ? parsed.peers : [],
observers: Array.isArray(parsed.observers) ? parsed.observers : [],
peerReachable: parsed.peerReachable && typeof parsed.peerReachable === 'object' ? parsed.peerReachable : {},
connectionRequests: Array.isArray(parsed.connectionRequests) ? parsed.connectionRequests : [],
}
} catch {
return {}
}
}
function writeConnectedNodesCache(state: ConnectedNodesCache) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(CONNECTED_NODES_CACHE_KEY, JSON.stringify(state))
} catch {
// Cache is best-effort.
}
}
const nodesContainerRef = ref<HTMLElement | null>(null)
const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
const peers = ref<Peer[]>([])
const observers = ref<Peer[]>([])
const cached = readConnectedNodesCache()
const peers = ref<Peer[]>(cached.peers ?? [])
const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>({})
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false)
@@ -351,7 +386,7 @@ const sendMessageError = ref('')
const sendMessageSuccess = ref('')
// Connection requests
const connectionRequests = ref<ConnectionRequest[]>([])
const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
const loadingRequests = ref(false)
const processingRequestId = ref<string | null>(null)
@@ -388,6 +423,7 @@ function switchToRequestsTab() {
}
async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true
try {
const res = await rpcClient.listPeers()
@@ -427,8 +463,18 @@ async function loadPeers() {
peerReachableLocal.value[p.onion] = false
}
}
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
if (!hadPeers) {
peers.value = []
observers.value = []
}
} finally {
loadingPeers.value = false
}
@@ -483,6 +529,12 @@ async function loadConnectionRequests() {
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
connectionRequests.value = res.requests || []
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch {
if (!hadRequests) connectionRequests.value = []
} finally {
@@ -496,6 +548,12 @@ async function acceptRequest(requestId: string) {
await rpcClient.call({ method: 'network.accept-request', params: { request_id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
await loadPeers()
emit('toast', t('web5.connectionAccepted'))
} catch {
@@ -511,6 +569,12 @@ async function rejectRequest(requestId: string) {
await rpcClient.call({ method: 'network.reject-request', params: { request_id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
emit('toast', t('web5.requestRejected'))
} catch {
emit('toast', t('web5.failedToRejectRequest'))
+25 -1
View File
@@ -389,6 +389,29 @@ import type { ManagedIdentity, IdentityProfile } from './types'
const { t } = useI18n()
const IDENTITIES_CACHE_KEY = 'archipelago.web5.identities.v1'
function readIdentitiesCache(): ManagedIdentity[] {
if (typeof window === 'undefined') return []
try {
const raw = window.sessionStorage.getItem(IDENTITIES_CACHE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as ManagedIdentity[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeIdentitiesCache(identities: ManagedIdentity[]) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(IDENTITIES_CACHE_KEY, JSON.stringify(identities))
} catch {
// Cache is opportunistic only.
}
}
defineProps<{
showStagger: boolean
}>()
@@ -397,7 +420,7 @@ const emit = defineEmits<{
toast: [text: string]
}>()
const managedIdentities = ref<ManagedIdentity[]>([])
const managedIdentities = ref<ManagedIdentity[]>(readIdentitiesCache())
const identitiesLoading = ref(false)
const showCreateIdentityModal = ref(false)
const newIdentityName = ref('Personal')
@@ -508,6 +531,7 @@ async function loadIdentities() {
try {
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
managedIdentities.value = res.identities || []
writeIdentitiesCache(managedIdentities.value)
} catch {
if (!hadIdentities) managedIdentities.value = []
} finally {
+19 -19
View File
@@ -33,12 +33,12 @@
</div>
</div>
<div v-if="userDid" class="flex gap-2 mt-auto">
<button
@click="$emit('copyDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button>
<button
@click="$emit('copyDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button>
<button
@click="$emit('showDidDocument')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
@@ -69,19 +69,19 @@
</div>
</div>
<div v-if="dhtDid" class="flex gap-2 mt-auto">
<button
@click="$emit('copyDhtDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button>
<button
@click="$emit('refreshDhtDid')"
:disabled="publishingDht"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button>
<button
@click="$emit('copyDhtDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button>
<button
@click="$emit('refreshDhtDid')"
:disabled="publishingDht"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button>
</div>
<button
v-else-if="userDid"
+6
View File
@@ -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 -19
View File
@@ -1,30 +1,30 @@
{
"version": "1.7.85-alpha",
"release_date": "2026-06-12",
"version": "1.7.90-alpha",
"release_date": "2026-06-13",
"changelog": [
"ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.",
"Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.",
"LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.",
"Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.",
"Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.",
"Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting."
"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.85-alpha",
"new_version": "1.7.85-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago",
"sha256": "06a6fe6e8f2e50bcda6c152c2de1a874edc84b2e65377f6e06d195c4eebc9cde",
"size_bytes": 44049488
"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.85-alpha.tar.gz",
"current_version": "1.7.85-alpha",
"new_version": "1.7.85-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago-frontend-1.7.85-alpha.tar.gz",
"sha256": "c809fb27772773925d89b711236a81834465229d9f544bd65cf5816776cfda76",
"size_bytes": 184057997
"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 -19
View File
@@ -1,30 +1,30 @@
{
"version": "1.7.85-alpha",
"release_date": "2026-06-12",
"version": "1.7.90-alpha",
"release_date": "2026-06-13",
"changelog": [
"ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.",
"Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.",
"LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.",
"Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.",
"Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.",
"Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting."
"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.85-alpha",
"new_version": "1.7.85-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago",
"sha256": "06a6fe6e8f2e50bcda6c152c2de1a874edc84b2e65377f6e06d195c4eebc9cde",
"size_bytes": 44049488
"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.85-alpha.tar.gz",
"current_version": "1.7.85-alpha",
"new_version": "1.7.85-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago-frontend-1.7.85-alpha.tar.gz",
"sha256": "c809fb27772773925d89b711236a81834465229d9f544bd65cf5816776cfda76",
"size_bytes": 184057997
"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
}
]
}
+125
View File
@@ -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