diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 1148091..46c204a 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 1ad2e38..cd4c37b 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 1148091..b39b815 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index e533f41..aaa1b38 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index cc56f3e..f689664 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index dd17d2b..b90451d 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 4f02e6e..84a426e 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index c22df52..be8e877 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 4f02e6e..470fa70 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index ecf792e..92caf1c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 1332a8e..3e9dca7 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 627fe7d..ef7f40e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index e0a58c9..3db912b 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 151e261..6ff7500 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index e0a58c9..9caa7bc 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/img/goblin-logo2.svg b/img/goblin-logo2.svg new file mode 100644 index 0000000..0fd9751 --- /dev/null +++ b/img/goblin-logo2.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/img/icon.png b/img/icon.png index 7753cf2..f5b1440 100644 Binary files a/img/icon.png and b/img/icon.png differ diff --git a/linux/Goblin.AppDir/goblin.png b/linux/Goblin.AppDir/goblin.png new file mode 100644 index 0000000..f5b1440 Binary files /dev/null and b/linux/Goblin.AppDir/goblin.png differ diff --git a/linux/Goblin.AppDir/grim.png b/linux/Goblin.AppDir/grim.png deleted file mode 100644 index b176447..0000000 Binary files a/linux/Goblin.AppDir/grim.png and /dev/null differ diff --git a/src/gui/app.rs b/src/gui/app.rs index addb690..fa9b414 100755 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -167,12 +167,28 @@ impl App { /// Draw custom desktop window frame content. fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) { - // Paint the whole window with the theme background first: the frame - // margin ring is otherwise transparent, which X11 without a - // compositor renders as a black border (and a black strip under the - // sidebar in light/yellow themes). + // Paint the window area inside the frame margin with the theme + // background first: surface gaps are otherwise transparent, which X11 + // without a compositor renders as black (strip under the sidebar in + // light/yellow themes). The margin ring itself must STAY transparent — + // painting it gives the window a visible border under a compositor. + let fill_rect = if is_fullscreen { + ui.max_rect() + } else { + ui.max_rect().shrink(Content::WINDOW_FRAME_MARGIN) + }; + let fill_rounding = if is_fullscreen { + CornerRadius::ZERO + } else { + CornerRadius { + nw: 8, + ne: 8, + sw: 0, + se: 0, + } + }; ui.painter() - .rect_filled(ui.max_rect(), CornerRadius::ZERO, Colors::fill()); + .rect_filled(fill_rect, fill_rounding, Colors::fill()); let content_bg_rect = { let mut r = ui.max_rect(); if !is_fullscreen { diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index ea4fef3..9bdc26a 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -2688,7 +2688,7 @@ pub fn widgets_logo(ui: &mut egui::Ui) { /// Tinted goblin mark at a given size. pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) + let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) .tint(theme::tokens().text) .fit_to_exact_size(Vec2::splat(size)); img.paint_at(ui, rect); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 3d743ad..679827d 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -316,7 +316,7 @@ pub fn qr_code(ui: &mut Ui, text: &str, size: f32) { ui.painter() .rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), plate); let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72)); - egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) + egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) .tint(ink) .fit_to_exact_size(m_rect.size()) .paint_at(ui, m_rect); diff --git a/src/gui/views/views.rs b/src/gui/views/views.rs index 31b4217..4470c9e 100644 --- a/src/gui/views/views.rs +++ b/src/gui/views/views.rs @@ -694,7 +694,7 @@ impl View { pub fn app_logo_name_version(ui: &mut egui::Ui) { ui.add_space(-1.0); // Goblin mark (white master, tinted for the current theme). - let logo = egui::include_image!("../../../img/goblin-logo2-256.png"); + let logo = egui::include_image!("../../../img/goblin-logo2.svg"); // Show application logo and name. ui.scope(|ui| { ui.set_opacity(0.9); diff --git a/src/lib.rs b/src/lib.rs index 10d843b..ceed9b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,6 +117,8 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe: if AppConfig::autostart_node() { Node::start(); } + // Pre-warm the Tor client so price/NIP-05/nostr are ready at first use. + tor::Tor::warm_up(); // Launch graphical interface. eframe::run_native("Goblin", options, app_creator) } diff --git a/src/main.rs b/src/main.rs index f0a2e9f..8a725ff 100755 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,9 @@ fn start_desktop_gui(platform: grim::gui::platform::Desktop) { let is_mac = os == egui::os::OperatingSystem::Mac; let is_win = os == egui::os::OperatingSystem::Windows; viewport = viewport + // Wayland taskbars resolve the icon through the .desktop file whose + // name matches this app id (see linux/Goblin.AppDir/goblin.desktop). + .with_app_id("goblin") .with_fullsize_content_view(true) .with_window_level(egui::WindowLevel::Normal) .with_title_shown(is_win) diff --git a/src/tor/tor.rs b/src/tor/tor.rs index c380023..7bc17e6 100644 --- a/src/tor/tor.rs +++ b/src/tor/tor.rs @@ -27,7 +27,7 @@ use parking_lot::RwLock; use safelog::DisplayRedacted; use sha2::Sha512; use std::collections::{BTreeMap, BTreeSet}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -172,6 +172,81 @@ impl Tor { (config, bridges, max_two_bridges) } + /// Endpoint (host, port) a bridge dials first, for a quick reachability probe. + /// Webtunnel and obfs4 need a working TCP path to this endpoint before anything + /// else can happen; snowflake is CDN-fronted, so it gets no probe (None). + fn bridge_probe_addr(bridge: &TorBridge) -> Option<(String, u16)> { + let line = bridge.connection_line(); + match bridge { + TorBridge::Webtunnel(_, _) => { + let url = line + .split_whitespace() + .find_map(|t| t.strip_prefix("url="))?; + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let host_port = rest.split('/').next()?; + let mut parts = host_port.splitn(2, ':'); + let host = parts.next()?.to_string(); + let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(443); + Some((host, port)) + } + TorBridge::Obfs4(_, _) => { + let addr = line.split_whitespace().nth(1)?; + let mut parts = addr.rsplitn(2, ':'); + let port = parts.next()?.parse().ok()?; + let host = parts + .next()? + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(); + Some((host, port)) + } + TorBridge::Snowflake(_, _) => None, + } + } + + /// Order bridges live-endpoint-first via a parallel TCP probe. A dead bridge + /// otherwise costs a full bootstrap timeout (60s) per attempt, which reads as + /// the wallet being stuck on "connecting…" while the list is ground through. + fn sort_bridges_by_reachability(bridges: Vec) -> Vec { + if bridges.len() < 2 { + return bridges; + } + let probes: Vec<(TorBridge, thread::JoinHandle)> = bridges + .into_iter() + .map(|b| { + let addr = Self::bridge_probe_addr(&b); + let handle = thread::spawn(move || match addr { + // No probeable endpoint: assume reachable. + None => true, + Some((host, port)) => (host.as_str(), port) + .to_socket_addrs() + .ok() + .and_then(|mut addrs| addrs.next()) + .map(|a| { + TcpStream::connect_timeout(&a, Duration::from_millis(4000)).is_ok() + }) + .unwrap_or(false), + }); + (b, handle) + }) + .collect(); + let mut alive = vec![]; + let mut dead = vec![]; + for (b, handle) in probes { + if handle.join().unwrap_or(true) { + alive.push(b); + } else { + dead.push(b); + } + } + // Probes can lie on hostile networks — keep dead ones as a last resort + // rather than dropping them. + alive.extend(dead); + alive + } + /// Build bootstrapped client from provided config. fn build_client_bootstrap(config: TorClientConfig) -> Option> { let client_res = TorClient::with_runtime(TOR_STATE.runtime.clone()) @@ -190,8 +265,10 @@ impl Tor { .runtime() .spawn(async move { let task = c.bootstrap(); - // Bootstrap client with 60s timeout. - if tokio::time::timeout(Duration::from_millis(60000), task) + // Bootstrap client with 120s timeout: a cold-cache directory + // consensus over a webtunnel bridge regularly needs more than + // 60s, and timing it out throws the progress away. + if tokio::time::timeout(Duration::from_millis(120000), task) .await .is_ok() { @@ -222,10 +299,11 @@ impl Tor { return; } } - // Cleanup keys, state and cache. + // Cleanup onion service keys only. State and cache are KEPT: the cache + // holds the directory consensus, and wiping it forced a full re-download + // over the (slow) bridge on every app start — minutes of "connecting…" + // where a warm cache bootstraps in seconds. fs::remove_dir_all(TorConfig::keystore_path()).unwrap_or_default(); - fs::remove_dir_all(TorConfig::state_path()).unwrap_or_default(); - fs::remove_dir_all(TorConfig::cache_path()).unwrap_or_default(); TOR_STATE.client_launching.store(true, Ordering::Relaxed); // Get initial bridges. let initial_bridges = if let Some(b) = TorConfig::get_bridge() { @@ -239,7 +317,7 @@ impl Tor { bridge }) .collect::>(); - Some(bridges) + Some(Self::sort_bridges_by_reachability(bridges)) } else { None }; @@ -271,9 +349,8 @@ impl Tor { TOR_STATE.client_config.write().replace((c, config.clone())); break; } else { - // Cleanup state and cache. - fs::remove_dir_all(TorConfig::state_path()).unwrap_or_default(); - fs::remove_dir_all(TorConfig::cache_path()).unwrap_or_default(); + // Keep state and cache between attempts so directory consensus + // progress accumulates instead of restarting from zero. // Check unused bridges to check another part. if !unused_bridges.is_empty() { config_bridges = Self::build_config(Some(unused_bridges)); @@ -286,7 +363,8 @@ impl Tor { .iter() .map(|b| TorBridge::Webtunnel(TorConfig::webtunnel_path(), b.to_string())) .collect::>(); - config_bridges = Self::build_config(Some(add_bridges)); + config_bridges = + Self::build_config(Some(Self::sort_bridges_by_reachability(add_bridges))); continue; } else if TorConfig::get_bridge().is_some() { // Launch without bridges if all attempts failed. @@ -388,6 +466,15 @@ impl Tor { r_client_config.clone() } + /// Pre-warm the Tor client in the background so it is already bootstrapped + /// by the time the first consumer (price feed, NIP-05, nostr relays) needs + /// it — otherwise nothing launches it until a wallet finishes opening. + pub fn warm_up() { + thread::spawn(|| { + let _ = Self::isolated_client_blocking(); + }); + } + /// Get an isolated Tor client for outgoing connections (e.g. Nostr relays), /// launching the embedded client first when needed. Blocking: bootstrap can /// take up to a minute, call from a blocking-friendly context. @@ -550,10 +637,9 @@ impl Tor { let mut w_services = TOR_STATE.start.write(); *w_services = services.clone(); } - // Cleanup keys, state and cache. + // Cleanup onion service keys (services re-save them on relaunch). State + // and cache are kept so the restart reuses the directory consensus. fs::remove_dir_all(TorConfig::keystore_path()).unwrap_or_default(); - fs::remove_dir_all(TorConfig::state_path()).unwrap_or_default(); - fs::remove_dir_all(TorConfig::cache_path()).unwrap_or_default(); { let mut w_client = TOR_STATE.client_config.write(); *w_client = None;