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>
This commit is contained in:
parent
340b981b79
commit
c800293f1f
@ -137,7 +137,11 @@ fun WebViewScreen(
|
|||||||
val intent = android.content.Intent(
|
val intent = android.content.Intent(
|
||||||
android.content.Intent.ACTION_VIEW,
|
android.content.Intent.ACTION_VIEW,
|
||||||
android.net.Uri.parse(url),
|
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)
|
context.startActivity(intent)
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
@ -169,8 +173,29 @@ fun WebViewScreen(
|
|||||||
allowContentAccess = true
|
allowContentAccess = true
|
||||||
allowFileAccess = false
|
allowFileAccess = false
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
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() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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)
|
## 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.
|
- 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.
|
||||||
|
|||||||
@ -13,7 +13,11 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
.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)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -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.
|
/// Spawn the health monitor background task.
|
||||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
.unwrap_or(&90)
|
.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;
|
let restarted = restart_container(&container.name, &container.state).await;
|
||||||
|
|
||||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
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"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,10 +66,6 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
|||||||
|
|
||||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
"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_STATE_FILE: &str = "update_state.json";
|
||||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||||
/// Marker written by apply_update() just before the service restart and
|
/// 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> {
|
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||||
vec![
|
vec![UpdateMirror {
|
||||||
UpdateMirror {
|
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
label: "Server 1 (OVH)".to_string(),
|
||||||
label: "Server 1 (OVH)".to_string(),
|
}]
|
||||||
},
|
|
||||||
UpdateMirror {
|
|
||||||
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
|
|
||||||
label: "Server 2 (tx1138)".to_string(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the operator-configured mirror list. Returns defaults if the
|
/// 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());
|
return Ok(default_mirrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration: the Hetzner VPS at 23.182.128.160 was
|
// One-time migrations: drop decommissioned release servers that may be
|
||||||
// decommissioned 2026-04-23. Existing nodes have it baked into their
|
// baked into existing nodes' saved mirror lists. Strip them on load so
|
||||||
// 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/stale host.
|
||||||
// we don't spend seconds per install timing out against a dead host.
|
// - 23.182.128.160: Hetzner VPS, decommissioned 2026-04-23.
|
||||||
// Exception to the usual "explicit removals stick" rule: the user
|
// - git.tx1138.com: retired as a release server 2026-06-13 — its main
|
||||||
// never chose to add this — it was a default.
|
// 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();
|
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;
|
let mut changed = list.len() != before;
|
||||||
|
|
||||||
// Merge in any default URLs the saved config is missing.
|
// 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() {
|
for mirror in list.iter_mut() {
|
||||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||||
mirror.label = "Server 1 (OVH)".to_string();
|
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| {
|
list.sort_by_key(|m| {
|
||||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||||
0
|
0
|
||||||
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
|
|
||||||
1
|
|
||||||
} else {
|
} else {
|
||||||
2
|
1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -763,10 +752,15 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
|||||||
.context("Failed to create staging dir")?;
|
.context("Failed to create staging dir")?;
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
// Per-request budget; each attempt gets the full hour. A retry
|
// Per-request budget; each attempt gets the full window, and a retry
|
||||||
// restarts the budget cleanly.
|
// resumes via Range from the partial file (download_component_resumable),
|
||||||
.timeout(std::time::Duration::from_secs(3600))
|
// so this is an upper bound per attempt, not the whole download. Sized
|
||||||
.connect_timeout(std::time::Duration::from_secs(30))
|
// 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()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
@ -1683,9 +1677,38 @@ mod tests {
|
|||||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let list = load_mirrors(dir.path()).await.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[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]
|
#[tokio::test]
|
||||||
@ -1699,7 +1722,7 @@ mod tests {
|
|||||||
let back = load_mirrors(dir.path()).await.unwrap();
|
let back = load_mirrors(dir.path()).await.unwrap();
|
||||||
// load_mirrors merges in any missing default mirrors so a node
|
// load_mirrors merges in any missing default mirrors so a node
|
||||||
// that explicitly added a single custom mirror still gets the
|
// 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!(
|
assert!(
|
||||||
back.iter().any(|m| m.url == "https://example.com/m.json"),
|
back.iter().any(|m| m.url == "https://example.com/m.json"),
|
||||||
"custom mirror should round-trip; got {:?}",
|
"custom mirror should round-trip; got {:?}",
|
||||||
|
|||||||
110
neode-ui/src/api/__tests__/remote-relay.test.ts
Normal file
110
neode-ui/src/api/__tests__/remote-relay.test.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { isTextField, typeKeyIntoField } from '../remote-relay'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
|
||||||
|
* values in the browser, so the relay edits `.value` at the caret directly and
|
||||||
|
* fires an `input` event. These tests lock in that behaviour so a regression
|
||||||
|
* (like the old "type goes to document, nothing happens" bug) is caught before
|
||||||
|
* release rather than by a user with a companion controller.
|
||||||
|
*/
|
||||||
|
describe('isTextField', () => {
|
||||||
|
it('accepts text-like inputs and textareas', () => {
|
||||||
|
const text = document.createElement('input')
|
||||||
|
text.type = 'text'
|
||||||
|
const search = document.createElement('input')
|
||||||
|
search.type = 'search'
|
||||||
|
const area = document.createElement('textarea')
|
||||||
|
expect(isTextField(text)).toBe(true)
|
||||||
|
expect(isTextField(search)).toBe(true)
|
||||||
|
expect(isTextField(area)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-text controls and null', () => {
|
||||||
|
const checkbox = document.createElement('input')
|
||||||
|
checkbox.type = 'checkbox'
|
||||||
|
expect(isTextField(checkbox)).toBe(false)
|
||||||
|
expect(isTextField(document.createElement('button'))).toBe(false)
|
||||||
|
expect(isTextField(document.createElement('div'))).toBe(false)
|
||||||
|
expect(isTextField(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('typeKeyIntoField', () => {
|
||||||
|
let input: HTMLInputElement
|
||||||
|
let inputEvents: number
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
input = document.createElement('input')
|
||||||
|
input.type = 'search'
|
||||||
|
document.body.appendChild(input)
|
||||||
|
inputEvents = 0
|
||||||
|
input.addEventListener('input', () => { inputEvents++ })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts printable characters at the caret and fires input', () => {
|
||||||
|
typeKeyIntoField(input, 'b')
|
||||||
|
typeKeyIntoField(input, 't')
|
||||||
|
typeKeyIntoField(input, 'c')
|
||||||
|
expect(input.value).toBe('btc')
|
||||||
|
expect(input.selectionStart).toBe(3)
|
||||||
|
expect(inputEvents).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts a character in the middle of existing text', () => {
|
||||||
|
input.value = 'bc'
|
||||||
|
input.selectionStart = input.selectionEnd = 1
|
||||||
|
typeKeyIntoField(input, 't')
|
||||||
|
expect(input.value).toBe('btc')
|
||||||
|
expect(input.selectionStart).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backspace deletes the char before the caret', () => {
|
||||||
|
input.value = 'btc'
|
||||||
|
input.selectionStart = input.selectionEnd = 3
|
||||||
|
typeKeyIntoField(input, 'Backspace')
|
||||||
|
expect(input.value).toBe('bt')
|
||||||
|
expect(input.selectionStart).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backspace removes the active selection', () => {
|
||||||
|
input.value = 'bitcoin'
|
||||||
|
input.selectionStart = 0
|
||||||
|
input.selectionEnd = 3
|
||||||
|
typeKeyIntoField(input, 'Backspace')
|
||||||
|
expect(input.value).toBe('coin')
|
||||||
|
expect(input.selectionStart).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrow keys move the caret without changing the value', () => {
|
||||||
|
input.value = 'abc'
|
||||||
|
input.selectionStart = input.selectionEnd = 3
|
||||||
|
typeKeyIntoField(input, 'ArrowLeft')
|
||||||
|
expect(input.selectionStart).toBe(2)
|
||||||
|
expect(input.value).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter on a single-line input is left for the app to handle', () => {
|
||||||
|
input.value = 'query'
|
||||||
|
input.selectionStart = input.selectionEnd = 5
|
||||||
|
const consumed = typeKeyIntoField(input, 'Enter')
|
||||||
|
expect(consumed).toBe(false)
|
||||||
|
expect(input.value).toBe('query')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter inserts a newline in a textarea', () => {
|
||||||
|
const area = document.createElement('textarea')
|
||||||
|
area.value = 'a'
|
||||||
|
area.selectionStart = area.selectionEnd = 1
|
||||||
|
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
|
||||||
|
expect(area.value).toBe('a\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-text keys are not consumed as editing', () => {
|
||||||
|
input.value = 'x'
|
||||||
|
input.selectionStart = input.selectionEnd = 1
|
||||||
|
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
|
||||||
|
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
|
||||||
|
expect(input.value).toBe('x')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string {
|
|||||||
return KEY_MAP[xdotoolKey] ?? xdotoolKey
|
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) {
|
function handleMessage(data: string) {
|
||||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
||||||
try {
|
try {
|
||||||
@ -125,9 +222,17 @@ function handleMessage(data: string) {
|
|||||||
if (iframe?.contentWindow) {
|
if (iframe?.contentWindow) {
|
||||||
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
||||||
}
|
}
|
||||||
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
|
// Deliver the key to the actually-focused element (descending into
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
// same-origin iframes) so it reaches embedded-app inputs and search
|
||||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
// 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
|
break
|
||||||
}
|
}
|
||||||
case 'm': {
|
case 'm': {
|
||||||
@ -135,16 +240,29 @@ function handleMessage(data: string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'c': {
|
case 'c': {
|
||||||
const target = document.elementFromPoint(cursorX, cursorY)
|
const target = deepElementFromPoint(cursorX, cursorY)
|
||||||
if (target) {
|
if (target) {
|
||||||
if (cursorEl) {
|
if (cursorEl) {
|
||||||
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
|
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
|
||||||
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
|
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
|
||||||
}
|
}
|
||||||
target.dispatchEvent(new MouseEvent('click', {
|
const eventInit: MouseEventInit = {
|
||||||
bubbles: true, cancelable: true,
|
bubbles: true, cancelable: true, view: window,
|
||||||
clientX: cursorX, clientY: cursorY,
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
57
neode-ui/src/composables/useElectrsSync.ts
Normal file
57
neode-ui/src/composables/useElectrsSync.ts
Normal file
@ -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 }
|
||||||
|
}
|
||||||
25
neode-ui/src/utils/openExternal.ts
Normal file
25
neode-ui/src/utils/openExternal.ts
Normal 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')
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@
|
|||||||
:refresh-key="refreshKey"
|
:refresh-key="refreshKey"
|
||||||
:blocked-reason="blockedReason"
|
:blocked-reason="blockedReason"
|
||||||
:blocked-title="blockedTitle"
|
:blocked-title="blockedTitle"
|
||||||
|
:electrs-sync="electrsSync"
|
||||||
@iframe-load="onLoad"
|
@iframe-load="onLoad"
|
||||||
@iframe-error="onError"
|
@iframe-error="onError"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
@ -106,6 +107,8 @@ import {
|
|||||||
import { launchBlockedReason } from './apps/appsConfig'
|
import { launchBlockedReason } from './apps/appsConfig'
|
||||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||||
|
import { openExternalUrl } from '@/utils/openExternal'
|
||||||
|
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
appIdProp?: string
|
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 blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
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 screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||||
const screensaverSuppressedApps = new Set([
|
const screensaverSuppressedApps = new Set([
|
||||||
'indeedhub',
|
'indeedhub',
|
||||||
@ -294,12 +314,12 @@ function startLoadTimeout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNewTabAndBack() {
|
function openNewTabAndBack() {
|
||||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||||
closeSession()
|
closeSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNewTab() {
|
function openNewTab() {
|
||||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function iframeGoBack() {
|
function iframeGoBack() {
|
||||||
|
|||||||
@ -9,8 +9,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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
|
<div
|
||||||
v-if="appUrl && !iframeBlocked"
|
v-if="appUrl && !iframeBlocked && !electrsSync"
|
||||||
class="absolute inset-0 app-session-frame-scroll-host"
|
class="absolute inset-0 app-session-frame-scroll-host"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@pointerdown="focusIframe"
|
@pointerdown="focusIframe"
|
||||||
@ -79,6 +111,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, watch } from 'vue'
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
appUrl: string
|
appUrl: string
|
||||||
@ -91,6 +124,9 @@ const props = defineProps<{
|
|||||||
refreshKey: number
|
refreshKey: number
|
||||||
blockedReason?: string
|
blockedReason?: string
|
||||||
blockedTitle?: 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<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@ -85,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-stretch justify-stretch bg-black/80 backdrop-blur-md p-0" @click.self="closeCredentialModal">
|
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||||
<div class="credential-modal-panel">
|
<div class="credential-modal-panel">
|
||||||
<div class="flex items-start justify-between gap-4 mb-5">
|
<div class="flex items-start justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
@ -337,17 +337,21 @@ function scrollToPage(index: number) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
max-width: 34rem;
|
||||||
|
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||||
|
matching the wallet receive modal. The body scrolls if content overflows
|
||||||
|
rather than the panel stretching edge-to-edge. */
|
||||||
|
max-height: calc(
|
||||||
|
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||||
|
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||||
|
);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-width: none;
|
|
||||||
max-height: none;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
border-radius: 0;
|
border-radius: 1.5rem;
|
||||||
background: rgba(8, 10, 18, 0.98);
|
background: rgba(8, 10, 18, 0.98);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
.credential-modal-actions {
|
.credential-modal-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@ -99,6 +99,27 @@ 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-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-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)\"'"
|
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
|
fi
|
||||||
|
|
||||||
summary 0
|
summary 0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user