From 57aa01a284995ee60a595af3082bffd91cb85aae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 01:25:17 +0000 Subject: [PATCH 1/2] fix(window): let haru open on Windows again, and say why when it cannot The last release asked wgpu for DX12 alone on Windows. eframe builds wgpu without its DX12 backend, so that left no backend at all: creating the surface failed, run_native returned an error, and main printed it to a stderr that a windows-subsystem build does not have. Double-clicking haru did nothing. Running both builds under Wine shows it: main exits with "Failed to create surface for any enabled backend", this opens the window. The backend is wgpu's own choice again (Vulkan, then OpenGL, with WGPU_BACKEND still overriding it), as it was in v0.6.5. The opaque window and vsync from the same change stay. Every way main gives up now goes through failure::report, which still prints, appends to haru.log in haru's data folder, and on Windows shows a message box through rfd. Panics are logged by a hook, and one that ends the event loop is caught around run_native and shown after the window is gone, so a crash is no longer silent either. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QJnt7JtdtiqnVCP9GYqUhK --- Cargo.lock | 63 ++++++++++++++----- Cargo.toml | 1 + crates/haru/Cargo.toml | 6 ++ crates/haru/src/failure.rs | 122 +++++++++++++++++++++++++++++++++++++ crates/haru/src/main.rs | 59 ++++++++---------- 5 files changed, 205 insertions(+), 46 deletions(-) create mode 100644 crates/haru/src/failure.rs diff --git a/Cargo.lock b/Cargo.lock index f4d0809..f988312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -560,6 +569,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1031,8 +1042,10 @@ version = "0.1.0" dependencies = [ "eframe", "egui", + "haru-core", "haru-ui", "image", + "rfd", ] [[package]] @@ -1730,7 +1743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -1746,6 +1759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -1758,7 +1772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1770,7 +1784,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1782,7 +1796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1817,7 +1831,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -1829,7 +1843,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -1848,7 +1862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -1882,7 +1896,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -1895,7 +1909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1907,7 +1921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -1930,7 +1944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -1950,7 +1964,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1962,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2287,6 +2301,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -3793,7 +3828,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.13.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases 0.2.2", diff --git a/Cargo.toml b/Cargo.toml index bddf7cd..ba5b7cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ image = { version = "0.25", default-features = false, features = [ "png", ] } qrcode = { version = "0.14", default-features = false } +rfd = { version = "0.15", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "sync"] } diff --git a/crates/haru/Cargo.toml b/crates/haru/Cargo.toml index ea0f426..80a55bc 100644 --- a/crates/haru/Cargo.toml +++ b/crates/haru/Cargo.toml @@ -14,8 +14,14 @@ path = "src/main.rs" [dependencies] eframe = { workspace = true } egui = { workspace = true } +haru-core = { workspace = true } haru-ui = { workspace = true } image = { workspace = true } +# The message box that says why haru stopped, since a release build on +# Windows has no console to print to. +[target.'cfg(windows)'.dependencies] +rfd = { workspace = true } + [lints] workspace = true diff --git a/crates/haru/src/failure.rs b/crates/haru/src/failure.rs new file mode 100644 index 0000000..7ab4d09 --- /dev/null +++ b/crates/haru/src/failure.rs @@ -0,0 +1,122 @@ +//! Saying why haru stopped, somewhere a person will see it. +//! +//! A release build on Windows has no console, so everything haru printed on +//! the way out went nowhere: a window that could not open, or a panic, looked +//! exactly like double-clicking and nothing happening. Every way `main` gives +//! up now goes through `report`, which still prints, also appends to a log in +//! haru's data folder, and on Windows puts up a message box. + +use std::io::Write as _; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::Mutex; + +/// The last panic, kept for `main` to report once the event loop has unwound. +static LAST_PANIC: Mutex> = Mutex::new(None); + +/// Log every panic, and keep the last one for `panicked`. +/// +/// Rust's own hook still runs first, so a console, when there is one, shows +/// what it always did. The message box is left to `main`: putting one up from +/// inside the hook would run a modal loop while winit is still unwinding. +pub fn record_panics() { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default(info); + let thread = std::thread::current(); + let said = format!( + "haru crashed in thread {}: {info}", + thread.name().unwrap_or("unnamed") + ); + let _ = log(&said); + if let Ok(mut last) = LAST_PANIC.lock() { + *last = Some(said); + } + })); +} + +/// Report the panic that ended the event loop. +pub fn panicked() -> ExitCode { + let said = LAST_PANIC + .lock() + .ok() + .and_then(|mut last| last.take()) + .unwrap_or_else(|| "haru crashed".to_owned()); + // Already logged by the hook. + show(&said, log_path().as_deref()); + ExitCode::FAILURE +} + +/// Say why haru could not go on, everywhere it might be seen. +pub fn report(message: &str) -> ExitCode { + eprintln!("haru: {message}"); + let logged = log(message).ok(); + show(message, logged.as_deref()); + ExitCode::FAILURE +} + +/// Why eframe could not open the window, in words that point at the fix. +pub fn window_error(error: &eframe::Error) -> String { + match error { + eframe::Error::Wgpu(_) => format!( + "haru could not find a graphics driver it can draw with (Vulkan or OpenGL). \ + Updating the graphics driver usually fixes this.\n\n({error})" + ), + error => format!("haru could not open its window.\n\n({error})"), + } +} + +fn log_path() -> Option { + Some(haru_core::data_home()?.join("haru").join("haru.log")) +} + +/// Append to haru's log, returning where it went. +fn log(message: &str) -> std::io::Result { + let path = log_path().ok_or_else(|| std::io::Error::other("no data folder"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or(0); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path)?; + writeln!(file, "[unix time {seconds}] {message}")?; + Ok(path) +} + +#[cfg(windows)] +fn show(message: &str, logged: Option<&std::path::Path>) { + let description = match logged { + Some(path) => format!("{message}\n\nThis was also saved to {}", path.display()), + None => message.to_owned(), + }; + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("haru") + .set_description(description) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +// Elsewhere haru is started from a terminal often enough, and a desktop +// launcher keeps what it printed in the journal or Console. +#[cfg(not(windows))] +const fn show(_message: &str, _logged: Option<&std::path::Path>) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_graphics_driver_is_named_as_such() { + let said = window_error(&eframe::Error::Wgpu( + eframe::egui_wgpu::WgpuError::NoSuitableAdapterFound, + )); + assert!(said.contains("graphics driver"), "{said}"); + assert!(said.contains("no suitable adapter"), "{said}"); + } +} diff --git a/crates/haru/src/main.rs b/crates/haru/src/main.rs index 1d01713..573856b 100644 --- a/crates/haru/src/main.rs +++ b/crates/haru/src/main.rs @@ -3,6 +3,8 @@ // somewhere while developing. #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] +mod failure; + use std::process::ExitCode; use haru_ui::{Haru, Tab}; @@ -76,13 +78,12 @@ fn parse(arguments: &[String]) -> Result { } fn main() -> ExitCode { + failure::record_panics(); + let arguments: Vec = std::env::args().skip(1).collect(); let opened = match parse(&arguments) { Ok(opened) => opened, - Err(message) => { - eprintln!("haru: {message}"); - return ExitCode::FAILURE; - } + Err(message) => return failure::report(&message), }; let mut viewport = egui::ViewportBuilder::default() @@ -97,30 +98,36 @@ fn main() -> ExitCode { let options = eframe::NativeOptions { viewport, + // The backend is left to wgpu (Vulkan, then OpenGL; `WGPU_BACKEND` + // overrides it). Asking for DX12 alone found no adapter at all, + // because eframe builds wgpu without its DX12 backend, and the window + // never opened. wgpu_options: eframe::egui_wgpu::WgpuConfiguration { present_mode: present_mode(), desired_maximum_frame_latency: Some(2), - supported_backends: backends(), ..eframe::egui_wgpu::WgpuConfiguration::default() }, ..eframe::NativeOptions::default() }; - match eframe::run_native( - "haru", - options, - Box::new(move |cc| { - haru_ui::theme::apply(&cc.egui_ctx); - Ok(Box::new(App { - haru: Haru::opening_on_item(opened.tab, opened.search, opened.item), - })) - }), - ) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("haru: {error}"); - ExitCode::FAILURE - } + // winit carries a panic in the event loop out of `run_native`, so catching + // it here reports it after the window is gone rather than inside it. + let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + eframe::run_native( + "haru", + options, + Box::new(move |cc| { + haru_ui::theme::apply(&cc.egui_ctx); + Ok(Box::new(App { + haru: Haru::opening_on_item(opened.tab, opened.search, opened.item), + })) + }), + ) + })); + match ran { + Ok(Ok(())) => ExitCode::SUCCESS, + Ok(Err(error)) => failure::report(&failure::window_error(&error)), + Err(_) => failure::panicked(), } } @@ -135,18 +142,6 @@ fn present_mode() -> eframe::wgpu::PresentMode { } } -// Left to itself, wgpu takes the first adapter that answers among Vulkan, DX12 -// and WGL, so which renderer haru got depended on the driver, and the Vulkan -// and GL paths are where egui draws wrong on Windows. DX12 is on every Windows -// 10 machine haru supports. `WGPU_BACKEND` still overrides this for testing. -fn backends() -> eframe::wgpu::Backends { - if cfg!(windows) { - eframe::wgpu::util::backend_bits_from_env().unwrap_or(eframe::wgpu::Backends::DX12) - } else { - eframe::egui_wgpu::WgpuConfiguration::default().supported_backends - } -} - struct App { haru: Haru, } From 635c13d18c2cdcbdda16f836839d710b0c15277d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 01:26:59 +0000 Subject: [PATCH 2/2] ci(release): move a draft's tag target when it is rebuilt Re-running Release for a version whose draft already exists replaced the binaries but left the draft's target where the first run put it. A draft has no tag until Publish, which makes it from that target, so publishing a rebuilt draft would tag the commit before the change while shipping binaries built after it, and the next changelog would list the change again. The update path now sets --target to the commit being built whenever the release is still a draft. Published releases are untouched. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QJnt7JtdtiqnVCP9GYqUhK --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbdfd47..f3991dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,12 +146,19 @@ jobs: # --prerelease on its own only sets the flag on a still-unpublished # draft. Nothing here goes the other way: a release that is already # published is never pushed back to a draft. + # + # A draft has no tag yet: Publish makes it from the draft's target. + # Rebuilding a draft from a newer commit has to move the target + # too, or Publish tags the commit before the change while the + # binaries were built after it. + target="" + if [ "$state" = "--draft" ]; then target="--target $GITHUB_SHA"; fi if [ "${{ inputs.prerelease }}" = "true" ] && [ "$state" = "--draft" ]; then echo "release $V is a draft and a prerelease was asked for — publishing it" state="--prerelease --draft=false" fi echo "release $V exists ($state) — updating in place" - gh release edit "$V" $state --title "$V" --notes-file RELEASE_NOTES.md + gh release edit "$V" $state $target --title "$V" --notes-file RELEASE_NOTES.md # Cleared by name first: a run cancelled mid-upload leaves a # half-created asset, and the API then answers 400 for a fresh # upload of that name rather than replacing it.