diff --git a/Cargo.toml b/Cargo.toml index ef45c54..9085744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ features = [ "Win32_System_Registry", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", + "Win32_Graphics_DirectWrite", ] [profile.release] diff --git a/src/boot.rs b/src/boot.rs new file mode 100644 index 0000000..da0fe2b --- /dev/null +++ b/src/boot.rs @@ -0,0 +1,129 @@ +//! Boot-time readiness gating. +//! +//! When the app is launched at logon via the HKCU Run key, parts of the Windows +//! graphics/shell stack are not necessarily ready yet. In particular, +//! gpui::Application::new() constructs WindowsPlatform::new(), which creates a +//! shared DirectWrite factory (DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)) +//! and enumerates the system font collection (GetSystemFontCollection). Both of +//! those depend on the Windows Font Cache Service (FontCache), which is often +//! still StartPending in the first seconds of a session. +//! +//! When they fail, GPUI calls show_error("Failed to launch", ...) a +//! MB_ICONERROR | MB_SYSTEMMODAL message box — and then .unwrap() panics, so the +//! process aborts and the app never starts. That is the "Windows error message box at +//! startup" symptom. +//! +//! To remove the race we gate GPUI construction on the *same* DirectWrite calls +//! succeeding here first, with backoff. On a manual launch the font subsystem is +//! already up, so the probe succeeds on the first attempt and we return immediately. + +use std::time::Duration; + +/// Number of probe attempts before giving up (120 × 250ms ≈ 30s). +const FONT_WAIT_ATTEMPTS: u32 = 120; +/// Delay between probe attempts. +const FONT_WAIT_DELAY: Duration = Duration::from_millis(250); + +/// Polls probe until it returns Ok, sleeping delay between attempts, up to +/// max_attempts times. +/// +/// Returns Ok(attempt_count) on the first success, or Err(last_error) if every +/// attempt failed (or max_attempts is 0). This is a pure control-flow harness with +/// the OS call injected, so it can be unit-tested deterministically. +pub fn poll_until_ready(max_attempts: u32, delay: Duration, mut probe: F) -> Result +where + F: FnMut() -> Result<(), String>, +{ + let mut last_error = String::from("probe never ran"); + for attempt in 1..=max_attempts { + match probe() { + Ok(()) => return Ok(attempt), + Err(e) => { + last_error = e; + if attempt < max_attempts { + std::thread::sleep(delay); + } + } + } + } + Err(last_error) +} + +/// Probes the DirectWrite font subsystem once, mirroring the Font-Cache-dependent +/// calls GPUI makes during WindowsPlatform::new(): create a shared factory and +/// enumerate the system font collection. +fn probe_font_subsystem() -> Result<(), String> { + use windows::Win32::Graphics::DirectWrite::{ + DWriteCreateFactory, IDWriteFactory, IDWriteFontCollection, DWRITE_FACTORY_TYPE_SHARED, + }; + + // SAFETY: DWriteCreateFactory returns an owned COM interface on success. + // GetSystemFontCollection is a read-only query; we pass a valid out-pointer and + // only read the returned interface. No raw pointers escape this block. + unsafe { + let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) + .map_err(|e| format!("DWriteCreateFactory(shared) failed: {e}"))?; + + let mut collection: Option = None; + factory + .GetSystemFontCollection(&mut collection, false) + .map_err(|e| format!("GetSystemFontCollection failed: {e}"))?; + + match collection { + // A populated collection means the Font Cache Service answered. + Some(c) if c.GetFontFamilyCount() > 0 => Ok(()), + Some(_) => Err("system font collection is empty".to_string()), + None => Err("system font collection was null".to_string()), + } + } +} + +/// Blocks until the DirectWrite font subsystem is ready, or a ~30s timeout elapses. +/// +/// Must be called before gpui::Application::new(). See the module docs for why. +/// If the timeout is hit we proceed anyway (GPUI may then show its own error box), +/// having recorded the last error to the startup log for diagnosis. +pub fn wait_for_font_subsystem() { + match poll_until_ready(FONT_WAIT_ATTEMPTS, FONT_WAIT_DELAY, probe_font_subsystem) { + // The common case (manual launch / fast boot): ready immediately, no log noise. + Ok(1) => {} + Ok(attempts) => { + let waited_ms = (attempts - 1) * FONT_WAIT_DELAY.as_millis() as u32; + log_startup(&format!( + "Font subsystem ready after {attempts} attempt(s) (~{waited_ms}ms wait)." + )); + } + Err(last_error) => { + let timeout_s = FONT_WAIT_ATTEMPTS as u128 * FONT_WAIT_DELAY.as_millis() / 1000; + log_startup(&format!( + "Font subsystem NOT ready after ~{timeout_s}s; constructing GPUI anyway. \ + Last error: {last_error}" + )); + } + } +} + +/// Appends a timestamped line to %APPDATA%\Polterdesk\startup.log. Best-effort; +/// any failure is ignored (we must never block startup on logging). +fn log_startup(msg: &str) { + use std::io::Write; + + let Ok(appdata) = std::env::var("APPDATA") else { + return; + }; + let dir = std::path::PathBuf::from(appdata).join("Polterdesk"); + let _ = std::fs::create_dir_all(&dir); + + let unix_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("startup.log")) + { + let _ = writeln!(f, "[{unix_secs}] {msg}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 64cc868..489a154 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app_state; +pub mod boot; pub mod desktop; pub mod settings; pub mod startup; diff --git a/src/main.rs b/src/main.rs index 9c3944b..0fb6d62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![windows_subsystem = "windows"] mod app_state; +mod boot; mod desktop; mod settings; mod startup; @@ -41,9 +42,16 @@ fn main() { let state = AppState::new(settings.clone(), gpui_tx); - // Spawn the WinAPI background thread (hotkey, tray, mouse hook) + // Spawn the WinAPI background thread (hotkey, tray, mouse hook). + // Done before the font wait so the tray icon appears promptly at boot. let _winapi_handle = winapi_thread::spawn(state.clone()); + // At logon the Windows Font Cache Service may still be starting, which makes + // gpui::Application::new() -> WindowsPlatform::new() fail in DirectWrite and show a + // system-modal "Failed to launch" error box, then abort. Gate GPUI construction on + // the font subsystem actually being ready. See boot::wait_for_font_subsystem. + boot::wait_for_font_subsystem(); + // Start the GPUI application on the main thread gpui::Application::new().run(move |cx| { ui::theme::apply_theme(cx); diff --git a/tests/boot_test.rs b/tests/boot_test.rs new file mode 100644 index 0000000..6ddb31a --- /dev/null +++ b/tests/boot_test.rs @@ -0,0 +1,57 @@ +use polterdesk::boot::poll_until_ready; +use std::cell::Cell; +use std::time::Duration; + +// The boot-readiness retry harness gates GPUI construction on the DirectWrite +// font subsystem becoming ready at logon. The OS probe is injected, so these +// tests exercise the control flow deterministically with a zero delay. + +#[test] +fn returns_on_first_success_without_retrying() { + let calls = Cell::new(0); + let result = poll_until_ready(5, Duration::ZERO, || { + calls.set(calls.get() + 1); + Ok(()) + }); + assert_eq!(result, Ok(1)); + assert_eq!(calls.get(), 1, "must not probe again after first success"); +} + +#[test] +fn retries_until_probe_succeeds() { + let calls = Cell::new(0); + let result = poll_until_ready(10, Duration::ZERO, || { + let n = calls.get() + 1; + calls.set(n); + if n >= 3 { + Ok(()) + } else { + Err(format!("font subsystem not ready (attempt {n})")) + } + }); + assert_eq!(result, Ok(3)); + assert_eq!(calls.get(), 3); +} + +#[test] +fn gives_up_after_max_attempts_returning_last_error() { + let calls = Cell::new(0); + let result = poll_until_ready(4, Duration::ZERO, || { + let n = calls.get() + 1; + calls.set(n); + Err(format!("boom {n}")) + }); + assert_eq!(result, Err("boom 4".to_string())); + assert_eq!(calls.get(), 4, "must attempt exactly max_attempts times"); +} + +#[test] +fn zero_max_attempts_never_probes() { + let calls = Cell::new(0); + let result = poll_until_ready(0, Duration::ZERO, || { + calls.set(calls.get() + 1); + Ok(()) + }); + assert!(result.is_err()); + assert_eq!(calls.get(), 0); +}