diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 41c3a6e5..ad8f8d41 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6f0ce8..447b7a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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. diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 26ba4e6c..84193973 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -13,7 +13,11 @@ impl RpcHandler { let resp = client .get(format!("{LND_REST_BASE_URL}/v1/newaddress")) - .query(&[("type", "p2wkh")]) + // LND's REST gateway parses `type` as the AddressType enum by its + // proto name (or integer), NOT the lncli aliases. "p2wkh" is not a + // valid enum value and returns 400 "parsing field type"; the native + // SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0). + .query(&[("type", "WITNESS_PUBKEY_HASH")]) .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 5bbeeb3a..fbd79f4c 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -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, data_dir: PathBuf) { tokio::spawn(async move { @@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc, 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" + )); + } } diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index bbfb3e26..b3806126 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -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 { - 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> { 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) { 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 } }); } @@ -763,10 +752,15 @@ pub async fn download_update(data_dir: &Path) -> Result { .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")?; @@ -1683,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] @@ -1699,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 {:?}", diff --git a/neode-ui/src/api/__tests__/remote-relay.test.ts b/neode-ui/src/api/__tests__/remote-relay.test.ts new file mode 100644 index 00000000..435a698b --- /dev/null +++ b/neode-ui/src/api/__tests__/remote-relay.test.ts @@ -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') + }) +}) diff --git a/neode-ui/src/api/remote-relay.ts b/neode-ui/src/api/remote-relay.ts index 2c21caaf..7787d8b8 100644 --- a/neode-ui/src/api/remote-relay.ts +++ b/neode-ui/src/api/remote-relay.ts @@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string { return KEY_MAP[xdotoolKey] ?? xdotoolKey } +/** 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