From e52399911c5dab4da6bd14e0d712d8bb83ca3213 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Sun, 1 Nov 2026 10:52:39 +0100 Subject: [PATCH] fix(macos): resolve osu updater loop and patcher wine compatibility - Remove host Apple Braille font entries from Wine prefix registry to prevent cold-start GDI+ AccessViolationException. - Purge .require_update, help.txt, and _pending prior to osu! launch so the game launches directly with -devserver ez-pp.farm instead of entering the updater. - Reset _UpdateFailCount in osu!.cfg and ensure execute.bat points to ez-pp.farm. - Update ensure_patcher_compatibility to patch osu!.patcher.exe and embedded ManagedInjector.Lib for .NET 4.0 compatibility under Wine. - Include theme editor, cursor, and download button refinements on themes-v2 branch. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 35 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/macos.rs | 524 ++++++++++++++++-- src-tauri/src/utils.rs | 399 ++++++++++++- .../components/theme/ThemeEditorWindow.svelte | 6 +- .../theme/ThemeElementInspector.svelte | 2 +- src/lib/components/theme/ThemeIcon.svelte | 3 + .../components/theme/ThemeV2Elements.svelte | 124 +++-- .../ui/download-button/DownloadButton.svelte | 89 ++- .../components/ui/osu-cursor/OsuCursor.svelte | 201 +++++-- src/lib/themeMedia.ts | 45 +- src/lib/themePreview.ts | 12 +- src/lib/themeV2.ts | 4 +- src/lib/userSettings.ts | 2 +- src/lib/utils.ts | 117 +++- src/screens/Launch.svelte | 37 +- src/screens/Loading.svelte | 22 +- 19 files changed, 1362 insertions(+), 265 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7c83a93..055826e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1272,6 +1272,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "byteorder", + "flate2", "futures-util", "hardware-id", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4554801..d0a8734 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,6 +56,7 @@ aes-gcm = "0.10.3" base64 = "0.22.1" sha2 = "0.11.0" thiserror = "2.0.18" +flate2 = "1.0" tauri-plugin-log = "2" log = "0.4" time = { version = "0.3", features = ["local-offset"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ead99a0..a40107f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -742,7 +742,9 @@ pub async fn get_ezpp_launcher_update_files( if file_path.exists() { let data = fs::read(&file_path).await.map_err(|e| e.to_string())?; let hash = format!("{:x}", md5::compute(&data)); - if hash.to_lowercase() != file.md5.to_lowercase() { + let matches = hash.eq_ignore_ascii_case(&file.md5) + || (file.name == "osu!.patcher.exe" && crate::utils::is_compatible_patcher(&data)); + if !matches { files_to_download.push(file.clone()); } } else { @@ -834,6 +836,11 @@ pub async fn download_ezpp_launcher_update_files( } } + let patcher_exe = osu_path.join("EZPPLauncher").join("patcher").join("osu!.patcher.exe"); + if patcher_exe.is_file() { + let _ = crate::utils::ensure_patcher_compatibility(&patcher_exe); + } + Ok(()) } @@ -2911,3 +2918,29 @@ pub fn config_clear(state: State) -> Result<(), ConfigError> { pub fn config_all(state: State) -> Result, ConfigError> { state.config.lock().unwrap().all() } + +#[tauri::command] +pub async fn reveal_window(window: tauri::WebviewWindow) -> Result<(), String> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let win = window.clone(); + window + .run_on_main_thread(move || { + let _ = win.show(); + let _ = win.unminimize(); + let _ = win.set_focus(); + + #[cfg(target_os = "macos")] + { + let res = crate::macos::activate_and_focus_window(&win); + let _ = tx.send(res); + return; + } + + #[cfg(not(target_os = "macos"))] + let _ = tx.send(Ok(())); + }) + .map_err(|e| e.to_string())?; + + rx.await + .map_err(|_| "Main thread channel closed".to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 653e764..cf6cce4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,7 +30,7 @@ use crate::{ open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation, presence_connect, presence_disconnect, presence_is_connected, presence_update_activity, presence_update_button, presence_update_status, presence_update_user, read_theme_info, replace_ui_files, - resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater, + resolve_osu_folder, restore_theme_backup, reveal_window, run_open_tablet_driver, run_osu, run_osu_updater, save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values, stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url, valid_osu_folder, @@ -212,6 +212,7 @@ pub fn run() { log_frontend, set_logging_enabled, open_logs_folder, + reveal_window, logging::list_log_bundles, logging::read_log_bundle ]) diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index f4c8982..ff0d77c 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -54,6 +54,41 @@ pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), St Ok(()) } +/// Activate the application, bring it to the foreground, and focus the given window. +pub fn activate_and_focus_window(window: &tauri::WebviewWindow) -> Result<(), String> { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + + let ns_window = window.ns_window().map_err(|error| error.to_string())? as *mut AnyObject; + if !ns_window.is_null() { + unsafe { + let ns_app: *mut AnyObject = msg_send![objc2::class!(NSApplication), sharedApplication]; + if !ns_app.is_null() { + let _: () = msg_send![ns_app, activateIgnoringOtherApps: true]; + } + let _: () = msg_send![ns_window, makeKeyAndOrderFront: ns_window]; + } + } + + Ok(()) +} + +/// Make this application active, bringing it to the front over other background apps. +pub fn activate_app() { + use objc2::msg_send; + use objc2::runtime::AnyObject; + unsafe { + let ns_app: *mut AnyObject = msg_send![objc2::class!(NSApplication), sharedApplication]; + if !ns_app.is_null() { + let _: () = msg_send![ns_app, activateIgnoringOtherApps: true]; + } + } +} + /// Allow theme media to start without a click. WKWebView otherwise blocks /// autoplay for anything with an audio track, which makes a theme's background /// video sit frozen until the user interacts with the launcher. @@ -156,6 +191,7 @@ pub fn pick_osu_installation() -> Result, String> { }) } +#[derive(Clone, Debug)] pub struct WineRuntime { prefix: PathBuf, wine_bin: PathBuf, @@ -239,27 +275,50 @@ impl WineRuntime { }) } - /// Spawn a Windows executable through the wrapper's Wine runtime. - pub fn spawn(&self, exe: &Path, args: &[&str]) -> Result { - let loader = self.loader_for(exe); - if !loader.is_file() { - return Err(format!( - "Wine loader not found at {}. The wrapper may be incomplete.", - loader.display() - )); + /// Convert a host path inside this Wine prefix into a Windows path (e.g. `C:\osu!\osu!.exe`). + pub fn to_windows_path(&self, path: &Path) -> String { + let path_str = path.to_string_lossy(); + if path_str.contains('\\') + || (path_str.len() >= 2 && path_str.chars().nth(1) == Some(':')) + { + return path_str.into_owned(); } - let mut command = Command::new(&loader); - command.arg(exe).args(args); - command.current_dir(exe.parent().unwrap_or(&self.prefix)); - command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + let drive_c = self.prefix.join("drive_c"); + let canon_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let canon_drive_c = fs::canonicalize(&drive_c).unwrap_or(drive_c); + if let Ok(rel) = canon_path.strip_prefix(&canon_drive_c) { + return format!("C:\\{}", rel.display()).replace('/', "\\"); + } + if let Ok(rel) = path.strip_prefix(&self.prefix.join("drive_c")) { + return format!("C:\\{}", rel.display()).replace('/', "\\"); + } + if let Some(drive_c_ancestor) = canon_path + .ancestors() + .find(|a| a.file_name() == Some(OsStr::new("drive_c"))) + { + if let Ok(rel) = canon_path.strip_prefix(drive_c_ancestor) { + return format!("C:\\{}", rel.display()).replace('/', "\\"); + } + } + + // If outside drive_c, Wine maps the Unix root filesystem to Z:\ + let path_display = canon_path.display().to_string(); + if path_display.starts_with('/') { + format!("Z:{}", path_display).replace('/', "\\") + } else { + path_display + } + } + + fn apply_wine_environment(&self, command: &mut Command) { command.env("WINEPREFIX", &self.prefix); command.env("WINEDEBUG", "-all"); - command.env("WINEDLLOVERRIDES", "mscoree,mshtml="); + // DO NOT disable mscoree! osu! and its patcher are .NET / CLR assemblies that require mscoree.dll. + // Disabling mscoree causes Wine to fail with status 0xc0000135 (STATUS_DLL_NOT_FOUND, exit code 53). + // Only suppress gecko html engine installer prompts. + command.env("WINEDLLOVERRIDES", "mshtml="); command.env("FREETYPE_PROPERTIES", "truetype:interpreter-version=35"); let mut library_paths = Vec::new(); @@ -333,6 +392,29 @@ impl WineRuntime { if let Ok(path) = std::env::join_paths(paths) { command.env("PATH", path); } + } + + /// Spawn a Windows executable through the wrapper's Wine runtime. + pub fn spawn(&self, exe: &Path, args: &[&str]) -> Result { + let loader = self.loader_for(exe); + if !loader.is_file() { + return Err(format!( + "Wine loader not found at {}. The wrapper may be incomplete.", + loader.display() + )); + } + + let win_exe = self.to_windows_path(exe); + + let mut command = Command::new(&loader); + command.arg(&win_exe).args(args); + command.current_dir(exe.parent().unwrap_or(&self.prefix)); + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + self.apply_wine_environment(&mut command); let executable_name = exe .file_name() @@ -348,6 +430,39 @@ impl WineRuntime { }) } + /// Spawn a Windows executable in the background (detached) through Wine. + pub fn spawn_detached(&self, exe: &Path, args: &[&str]) -> Result<(), String> { + let loader = self.loader_for(exe); + if !loader.is_file() { + return Err(format!( + "Wine loader not found at {}. The wrapper may be incomplete.", + loader.display() + )); + } + + let win_exe = self.to_windows_path(exe); + + let mut command = Command::new(&loader); + command.arg(&win_exe).args(args); + command.current_dir(exe.parent().unwrap_or(&self.prefix)); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + self.apply_wine_environment(&mut command); + + command.spawn().map_err(|error| { + format!( + "Failed to start detached process through Wine ({}): {}", + loader.display(), + error + ) + })?; + + Ok(()) + } + /// Stop every process that belongs to this Wine prefix. pub async fn kill_prefix(&self) { let wineserver = self.wine_bin.join("wineserver"); @@ -357,19 +472,11 @@ impl WineRuntime { let mut cmd = Command::new(wineserver); cmd.arg("-k") - .env("WINEPREFIX", &self.prefix) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - if let Some(app) = &self.app_bundle { - if let Ok(fallback) = std::env::join_paths([ - app.join("Contents/Frameworks"), - app.join("Frameworks"), - ]) { - cmd.env("DYLD_FALLBACK_LIBRARY_PATH", fallback); - } - } + self.apply_wine_environment(&mut cmd); let _ = cmd.status().await; } @@ -390,6 +497,100 @@ impl WineRuntime { } } +/// Prepare the osu! and Wine prefix environment before launching the game. +/// +/// 1. Removes stale update files left behind by previous crashes (`.require_update`, +/// `help.txt`, `_pending`), which otherwise force `osu!.exe` to start in updater mode +/// and launch the game without command-line arguments. +/// 2. Resets `_UpdateFailCount` in `osu!.cfg` and `UpdatePending` in user configs. +/// 3. Ensures `execute.bat` points to `ez-pp.farm` rather than external servers. +/// 4. Removes any loose `ManagedInjector.Lib.dll` in the patcher folder to avoid overriding +/// the embedded patched assembly. +/// 5. Cleans Apple Braille font entries from `user.reg` and `system.reg` in the Wine prefix, +/// preventing a fatal FreeType/GDI+ AccessViolationException (exit code 5) on macOS. +pub async fn prepare_osu_environment(folder: &Path, runtime: &WineRuntime) { + clean_wine_font_registry(runtime).await; + + // 1. Remove stale update trigger files left behind by crashes + let _ = fs::remove_file(folder.join(".require_update")); + let _ = fs::remove_file(folder.join("help.txt")); + let _ = fs::remove_dir_all(folder.join("_pending")); + + // 2. Remove loose ManagedInjector.Lib.dll in patcher folder if present + let _ = fs::remove_file(folder.join("EZPPLauncher/patcher/ManagedInjector.Lib.dll")); + + // 3. Reset update fail count in osu!.cfg + let cfg_path = folder.join("osu!.cfg"); + if cfg_path.is_file() { + if let Ok(content) = fs::read_to_string(&cfg_path) { + if content.contains("_UpdateFailCount") { + let updated = content + .lines() + .map(|line| { + if line.trim_start().starts_with("_UpdateFailCount") { + "_UpdateFailCount = 0" + } else { + line + } + }) + .collect::>() + .join("\n"); + let _ = fs::write(&cfg_path, updated); + } + } + } + + // 4. Update execute.bat if present to use ez-pp.farm + let bat_path = folder.join("execute.bat"); + if bat_path.is_file() { + if let Ok(content) = fs::read_to_string(&bat_path) { + if content.contains("lucoa.best") { + let updated = content.replace("lucoa.best", "ez-pp.farm"); + let _ = fs::write(&bat_path, updated); + } + } + } +} + +async fn clean_wine_font_registry(runtime: &WineRuntime) { + let mut needs_clean = false; + for reg_name in &["user.reg", "system.reg"] { + let reg_path = runtime.prefix.join(reg_name); + if reg_path.is_file() { + if let Ok(content) = fs::read_to_string(®_path) { + if content.contains("Apple Braille") { + needs_clean = true; + break; + } + } + } + } + + if needs_clean { + runtime.kill_prefix().await; + for reg_name in &["user.reg", "system.reg"] { + let reg_path = runtime.prefix.join(reg_name); + if reg_path.is_file() { + if let Ok(content) = fs::read_to_string(®_path) { + if content.contains("Apple Braille") { + let cleaned: String = content + .lines() + .filter(|line| !line.contains("Apple Braille")) + .collect::>() + .join("\n") + "\n"; + let _ = fs::write(®_path, cleaned); + log::info!( + target: "osu", + "Removed Apple Braille font entries from {}", + reg_path.display() + ); + } + } + } + } + } +} + /// Run osu! (`-devserver ez-pp.farm`) and wait until the game exits. pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> { let folder = Path::new(osu_folder); @@ -397,8 +598,41 @@ pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> { log::error!(target: "osu", "Could not prepare the Wine runtime: {error}"); error })?; + + prepare_osu_environment(folder, &runtime).await; + + // In Wineskin wrappers, bridge.exe bridges Discord RPC / IPC pipes. + // Start bridge.exe if present and not already running. + let bridge_exe = folder.join("bridge.exe"); + if bridge_exe.is_file() { + let is_running = { + let mut system = sysinfo::System::new_all(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + system.processes().values().any(|process| { + process + .name() + .to_string_lossy() + .to_ascii_lowercase() + .contains("bridge.exe") + || process + .cmd() + .iter() + .any(|arg| arg.to_string_lossy().to_ascii_lowercase().contains("bridge.exe")) + }) + }; + if !is_running { + if let Err(error) = runtime.spawn_detached(&bridge_exe, &[]) { + log::warn!(target: "osu", "Could not start bridge.exe: {error}"); + } else { + // Give bridge.exe and the Wine prefix environment a moment to initialize. + sleep(Duration::from_millis(600)).await; + } + } + } + + let osu_exe = folder.join("osu!.exe"); let mut game = runtime - .spawn(&folder.join("osu!.exe"), &["-devserver", "ez-pp.farm"]) + .spawn(&osu_exe, &["-devserver", "ez-pp.farm"]) .map_err(|error| { log::error!(target: "osu", "Could not start osu!: {error}"); error @@ -410,19 +644,111 @@ pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> { crate::logging::capture_osu_output(&mut game); if patch { - if let Err(error) = start_patcher(&runtime, folder).await { - log::warn!(target: "patcher", "Could not start the patcher: {error}"); + let runtime_clone = runtime.clone(); + let folder_buf = folder.to_path_buf(); + tauri::async_runtime::spawn(async move { + if let Err(error) = start_patcher(&runtime_clone, &folder_buf).await { + log::warn!(target: "patcher", "Could not start the patcher: {error}"); + } + }); + } + + // Wine cold startup handling: + // If Wine starts cold, concurrent background service and font cache initialization + // in Wine can cause osu! to exit quickly with status 5 (STATUS_ACCESS_VIOLATION). + // Once the prefix is initialized, an automatic retry starts the game cleanly. + let start_time = Instant::now(); + let mut wait_result = game.wait().await; + + if let Ok(status) = &wait_result { + if status.code() == Some(5) && start_time.elapsed() < StdDuration::from_secs(6) { + log::warn!( + target: "osu", + "osu! exited early with status 5 during Wine cold start; retrying now that Wine runtime is initialized..." + ); + let _ = fs::remove_file(folder.join(".require_update")); + sleep(Duration::from_millis(500)).await; + + let mut retry_game = runtime + .spawn(&osu_exe, &["-devserver", "ez-pp.farm"]) + .map_err(|error| { + log::error!(target: "osu", "Could not restart osu!: {error}"); + error + })?; + + if let Some(pid) = retry_game.id() { + log::info!(target: "osu", "osu! restarted through Wine (pid {pid})"); + } + crate::logging::capture_osu_output(&mut retry_game); + + wait_result = retry_game.wait().await; } } - match game.wait().await { - Ok(status) => log::info!(target: "osu", "osu! exited with {status}"), + match &wait_result { + Ok(status) => log::info!(target: "osu", "osu! loader exited with {status}"), Err(error) => { log::warn!(target: "osu", "Could not wait for osu!: {error}"); return Err(format!("Failed to wait for osu!: {}", error)); } } + // Process lifecycle handling: + // When osu! starts, it frequently acts as an updater/bootstrap process that closes + // with exit status 0 after spawning the real, detached game process (`osu!.exe`). + // If an osu! game process is running or appears shortly, we keep waiting until the + // entire game session is finished before returning control to the launcher. + let initial_session_duration = start_time.elapsed(); + let mut system = sysinfo::System::new_all(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + let mut game_active = system + .processes() + .values() + .any(crate::utils::is_osu_game_process); + + // If no osu! game process is immediately detected and the initial process + // exited very quickly (< 15 seconds), it may have been the bootstrap updater + // that hasn't finished spawning the detached game process yet. + if !game_active && initial_session_duration < StdDuration::from_secs(15) { + let grace_deadline = Instant::now() + StdDuration::from_secs(8); + while Instant::now() < grace_deadline { + sleep(Duration::from_millis(400)).await; + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + if system + .processes() + .values() + .any(crate::utils::is_osu_game_process) + { + game_active = true; + break; + } + } + } + + if game_active { + log::info!( + target: "osu", + "osu! game process active; waiting for game session to finish..." + ); + + loop { + sleep(Duration::from_millis(1000)).await; + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + if !system + .processes() + .values() + .any(crate::utils::is_osu_game_process) + { + log::info!(target: "osu", "osu! game session finished"); + break; + } + } + } else { + log::info!(target: "osu", "No active osu! game process found after loader exit"); + } + + runtime.kill_prefix().await; + Ok(()) } @@ -440,7 +766,16 @@ async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), S )); } - wait_for_osu_game().await; + if !wait_for_osu_game().await { + return Err("Could not detect the osu! process for patcher".into()); + } + + if let Err(error) = crate::utils::ensure_patcher_compatibility(&patcher_exe) { + log::warn!( + target: "patcher", + "Could not ensure patcher compatibility: {error}" + ); + } log::info!( target: "patcher", @@ -464,38 +799,67 @@ async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), S Ok(()) } -/// Wait until the Wine-hosted osu! process exists, then give it time to create -/// its game window. Wine does not expose the window title to the launcher here, -/// so the process is the best signal available. -async fn wait_for_osu_game() { - let mut system = sysinfo::System::new_all(); - let deadline = Instant::now() + StdDuration::from_secs(30); - let mut detected = false; +/// Wait until osu! creates its game window (matching Windows and Linux launch logic). +/// Polls window titles and waits until a non-empty, non-updater window appears. +async fn wait_for_osu_game() -> bool { + let mut sys = sysinfo::System::new_all(); + let deadline = Instant::now() + StdDuration::from_secs(60); - while Instant::now() < deadline { - system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - if system - .processes() - .values() - .any(crate::utils::is_osu_game_process) - { - detected = true; - break; + loop { + if Instant::now() > deadline { + log::warn!( + target: "patcher", + "Timed out waiting for osu! game window for patcher" + ); + return false; + } + + // On macOS, Wine's window server process (wine64-preloader) owns the native + // macOS windows, which may not match the client wine32on64 PID directly. + // Scanning Wine/osu! windows directly via get_window_title_by_pid(Pid::from(0)) + // reliably detects both named and bounds-identified game and updater windows. + let title = crate::utils::get_window_title_by_pid(sysinfo::Pid::from(0)); + if !title.is_empty() { + if !title.contains("updater") { + log::info!( + target: "patcher", + "Detected osu! window \"{title}\" via Wine WindowServer scan; ready for patcher" + ); + return true; + } else { + log::debug!( + target: "patcher", + "Detected osu! updater window \"{title}\" via Wine WindowServer scan; waiting for game..." + ); + } + } + + sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + + for (_pid, process) in sys.processes() { + if crate::utils::is_osu_process(process) || process.name() == "osu!.exe" { + let pid = process.pid(); + let title = crate::utils::get_window_title_by_pid(pid); + + if !title.is_empty() { + if !title.contains("updater") { + log::info!( + target: "patcher", + "Detected osu! window \"{title}\" (pid {pid}); ready for patcher" + ); + return true; + } else { + log::debug!( + target: "patcher", + "Detected osu! updater window \"{title}\" (pid {pid}); waiting for game..." + ); + } + } + } } sleep(Duration::from_millis(500)).await; } - - if !detected { - log::warn!( - target: "patcher", - "Could not detect the osu! process; starting the patcher anyway" - ); - } - - // Wine gives no reliable window-ready signal, so leave the game a moment - // to finish initializing before the patcher attaches to it. - sleep(Duration::from_secs(8)).await; } /// Run `osu!.exe -repair`, wait for the updater and close the game it starts. @@ -965,3 +1329,47 @@ fn pe_machine(exe: &Path) -> Option { Some(u16::from_le_bytes([header[4], header[5]])) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_windows_path_inside_drive_c() { + let runtime = WineRuntime { + prefix: PathBuf::from("/Users/test/wine_prefix"), + wine_bin: PathBuf::from("/opt/wine/bin"), + wine_root: None, + app_bundle: None, + }; + + let exe = PathBuf::from("/Users/test/wine_prefix/drive_c/osu!/osu!.exe"); + assert_eq!(runtime.to_windows_path(&exe), "C:\\osu!\\osu!.exe"); + } + + #[test] + fn test_to_windows_path_already_windows() { + let runtime = WineRuntime { + prefix: PathBuf::from("/Users/test/wine_prefix"), + wine_bin: PathBuf::from("/opt/wine/bin"), + wine_root: None, + app_bundle: None, + }; + + let exe = Path::new("C:\\osu!\\osu!.exe"); + assert_eq!(runtime.to_windows_path(exe), "C:\\osu!\\osu!.exe"); + } + + #[test] + fn test_to_windows_path_outside_drive_c() { + let runtime = WineRuntime { + prefix: PathBuf::from("/Users/test/wine_prefix"), + wine_bin: PathBuf::from("/opt/wine/bin"), + wine_root: None, + app_bundle: None, + }; + + let exe = PathBuf::from("/Applications/other.exe"); + assert_eq!(runtime.to_windows_path(&exe), "Z:\\Applications\\other.exe"); + } +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index e80eca6..c5be048 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -353,8 +353,197 @@ pub fn get_window_title_by_pid(target_pid: Pid) -> String { } #[cfg(target_os = "macos")] -pub fn get_window_title_by_pid(_pid: Pid) -> String { - String::new() +pub fn get_window_title_by_pid(target_pid: Pid) -> String { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use std::ffi::CStr; + + #[repr(C)] + struct CGPoint { + x: f64, + y: f64, + } + + #[repr(C)] + struct CGSize { + width: f64, + height: f64, + } + + #[repr(C)] + struct CGRect { + origin: CGPoint, + size: CGSize, + } + + unsafe extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> *mut AnyObject; + fn CGRectMakeWithDictionaryRepresentation( + dict: *const AnyObject, + rect: *mut CGRect, + ) -> bool; + } + + unsafe { + // kCGWindowListOptionAll = 0, kCGNullWindowID = 0 + let window_list: *mut AnyObject = CGWindowListCopyWindowInfo(0, 0); + if window_list.is_null() { + return String::new(); + } + + let count: usize = msg_send![window_list, count]; + let pid_key: *mut AnyObject = msg_send![ + objc2::class!(NSString), + stringWithUTF8String: c"kCGWindowOwnerPID".as_ptr() + ]; + let name_key: *mut AnyObject = msg_send![ + objc2::class!(NSString), + stringWithUTF8String: c"kCGWindowName".as_ptr() + ]; + let owner_key: *mut AnyObject = msg_send![ + objc2::class!(NSString), + stringWithUTF8String: c"kCGWindowOwnerName".as_ptr() + ]; + let bounds_key: *mut AnyObject = msg_send![ + objc2::class!(NSString), + stringWithUTF8String: c"kCGWindowBounds".as_ptr() + ]; + let layer_key: *mut AnyObject = msg_send![ + objc2::class!(NSString), + stringWithUTF8String: c"kCGWindowLayer".as_ptr() + ]; + + let target_u32 = target_pid.as_u32(); + + // Pass 1: Try exact target_pid match. + // Pass 2: If target_pid produced no onscreen windows, check Wine/osu! owner windows + // (handles Wine 32-on-64 architecture where wine32on64 delegates window creation to wine64-preloader). + for fallback_pass in [false, true] { + let mut found_game_title = String::new(); + let mut found_updater_title = String::new(); + let mut has_game_window = false; + let mut has_updater_window = false; + + for i in 0..count { + let dict: *mut AnyObject = msg_send![window_list, objectAtIndex: i]; + if dict.is_null() { + continue; + } + + let pid_num: *mut AnyObject = msg_send![dict, objectForKey: pid_key]; + if pid_num.is_null() { + continue; + } + let pid_val: u32 = msg_send![pid_num, unsignedIntValue]; + + let is_match = if !fallback_pass { + target_u32 != 0 && pid_val == target_u32 + } else { + let owner_str: *mut AnyObject = msg_send![dict, objectForKey: owner_key]; + if !owner_str.is_null() { + let utf8: *const std::ffi::c_char = msg_send![owner_str, UTF8String]; + if !utf8.is_null() { + if let Ok(owner) = CStr::from_ptr(utf8).to_str() { + let lower = owner.to_lowercase(); + lower.contains("wine") || lower.contains("osu") + } else { + false + } + } else { + false + } + } else { + false + } + }; + + if !is_match { + continue; + } + + // Check layer: valid application window layers are 0..=50 + // (e.g. 0 for normal windows, 3 for floating/focused, 27 for Wine fullscreen/borderless shields) + let layer_num: *mut AnyObject = msg_send![dict, objectForKey: layer_key]; + let layer: i32 = if !layer_num.is_null() { + msg_send![layer_num, intValue] + } else { + 0 + }; + if layer < 0 || layer > 50 { + continue; + } + + // 1. Check kCGWindowName if available + let name_str: *mut AnyObject = msg_send![dict, objectForKey: name_key]; + if !name_str.is_null() { + let utf8: *const std::ffi::c_char = msg_send![name_str, UTF8String]; + if !utf8.is_null() { + if let Ok(s) = CStr::from_ptr(utf8).to_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + if trimmed.to_lowercase().contains("updater") { + found_updater_title = trimmed.to_string(); + } else { + found_game_title = trimmed.to_string(); + break; + } + } + } + } + } + + // 2. Check window bounds + let bounds_dict: *mut AnyObject = msg_send![dict, objectForKey: bounds_key]; + if !bounds_dict.is_null() { + let mut rect = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: 0.0, + height: 0.0, + }, + }; + if CGRectMakeWithDictionaryRepresentation(bounds_dict, &mut rect) { + // Ignore menu bar overlays (height <= 50) and tiny dummy windows (<= 100x100) + if rect.size.height > 50.0 && rect.size.width > 100.0 { + // Ignore Wine's default 500x500 dummy desktop surface + let is_wine_default_desktop = (rect.size.width - 500.0).abs() < 2.0 + && (rect.size.height - 500.0).abs() < 2.0; + + if !is_wine_default_desktop { + if rect.size.width >= 640.0 && rect.size.height >= 480.0 { + has_game_window = true; + break; + } else if rect.size.width >= 200.0 && rect.size.height >= 80.0 { + has_updater_window = true; + } + } + } + } + } + } + + let has_result = !found_game_title.is_empty() + || has_game_window + || !found_updater_title.is_empty() + || has_updater_window; + + if has_result { + let _: () = msg_send![window_list, release]; + if !found_game_title.is_empty() { + return found_game_title; + } else if has_game_window { + return "osu!".to_string(); + } else if !found_updater_title.is_empty() { + return found_updater_title; + } else { + return "osu! updater".to_string(); + } + } + } + + let _: () = msg_send![window_list, release]; + String::new() + } } #[cfg(windows)] @@ -513,3 +702,209 @@ pub fn encrypt_password(password: &str, entropy: &str) -> Result Ok(base64_string) } + +/// Check if the patcher binary has already been patched for .NET 4.0 compatibility under Wine. +pub fn is_compatible_patcher(data: &[u8]) -> bool { + let has_pbi_patch = data + .windows(6) + .any(|w| w == [0x1f, 0x18, 0x00, 0x00, 0x00, 0x00]); + let has_devserver_patch = data + .windows(7) + .any(|w| w == [0x26, 0x26, 0x2b, 0x0e, 0x00, 0x00, 0x00]); + has_pbi_patch && has_devserver_patch +} + +/// Ensure `osu!.patcher.exe` is compatible with .NET Framework 4.0 runtime (used by Wine). +/// +/// In .NET 4.0 (the runtime provided by Wine prefixes for osu! stable), generic `Marshal.SizeOf()` +/// does not exist (added in .NET 4.5.1), causing a `MissingMethodException` when `GetCommandLine()` +/// queries `PROCESS_BASIC_INFORMATION`. Additionally: +/// 1. `Console.ReadKey()` crashes with `InvalidOperationException` when stdio is redirected without a console. +/// 2. `GetOsuPid()` checks for `" -devserver "` via PEB reading, which fails or is unreliable under Wine. +/// 3. Costura embeds `ManagedInjector.Lib` which also calls generic `Marshal.SizeOf()` +/// and parses remote PE export tables through generic marshal APIs. +/// This function patches the binary in-place so it runs reliably under Wine. +pub fn ensure_patcher_compatibility(patcher_exe: &Path) -> Result { + if !patcher_exe.is_file() { + return Ok(false); + } + + // Clean up loose ManagedInjector.Lib.dll if present, as CLR loads loose assemblies before Costura resources. + if let Some(parent) = patcher_exe.parent() { + let loose = parent.join("ManagedInjector.Lib.dll"); + if loose.is_file() { + let _ = fs::remove_file(&loose); + } + } + + let mut data = fs::read(patcher_exe) + .map_err(|e| format!("Failed to read {}: {e}", patcher_exe.display()))?; + let mut modified = false; + + // Pattern 1 in GetCommandLine: ldloc.0, ldc.i4.0, ldloca.s 1, ldloc.1, call Marshal.SizeOf + // [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b] + let sizeof_pattern = [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b]; + if let Some(pos) = data + .windows(sizeof_pattern.len()) + .position(|w| w == sizeof_pattern) + { + // Replace ldloc.1 (0x07) and call Marshal.SizeOf (0x28, 0x01, 0x00, 0x00, 0x2b) with ldc.i4.s 24 (0x1f, 0x18) + 4 nops + let target = pos + 4; + data[target..target + 6].copy_from_slice(&[0x1f, 0x18, 0x00, 0x00, 0x00, 0x00]); + modified = true; + } + + // Pattern 2 in Main catch handler: call Console.Write, call Console.ReadKey, pop, leave.s + // [0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde] + let readkey_pattern = [ + 0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde, + ]; + if let Some(pos) = data + .windows(readkey_pattern.len()) + .position(|w| w == readkey_pattern) + { + // Replace call Console.ReadKey() (5 bytes) + pop (1 byte) with 6 nops + let target = pos + 5; + data[target..target + 6].copy_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + modified = true; + } + + // Pattern 3 in GetOsuPid: devserver check bypass + // [0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b] + let dev_pattern = [ + 0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b, + ]; + if let Some(pos) = data + .windows(dev_pattern.len()) + .position(|w| w == dev_pattern) + { + // Replace callvirt Contains + brtrue.s with pop, pop, br.s + 3 nops + let target = pos + 5; + data[target..target + 7].copy_from_slice(&[0x26, 0x26, 0x2b, 0x0e, 0x00, 0x00, 0x00]); + modified = true; + } + + // Pattern 4: Costura embedded ManagedInjector.Lib resource at offset 0x1f00 (length 10020) + if data.len() >= 0x1f00 + 10020 && data.get(0x1efc..0x1f00) == Some(&[0x24, 0x27, 0x00, 0x00]) { + use flate2::read::DeflateDecoder; + use flate2::write::DeflateEncoder; + use flate2::Compression; + use std::io::{Read, Write}; + + let stream = &data[0x1f00..0x1f00 + 10020]; + let mut decoder = DeflateDecoder::new(stream); + let mut decomp = Vec::new(); + if decoder.read_to_end(&mut decomp).is_ok() { + let mut inner_modified = false; + + // 4a. NativeHelper.GetModules: Marshal.SizeOf() -> ldc.i4 548 + let module_pattern = [0x12, 0x04, 0x28, 0x08, 0x00, 0x00, 0x2b]; + if let Some(pos) = decomp.windows(module_pattern.len()).position(|w| w == module_pattern) { + decomp[pos + 2..pos + 7].copy_from_slice(&[0x20, 0x24, 0x02, 0x00, 0x00]); + inner_modified = true; + } + + // 4b. InjectableProcess.GetArchitecture: return NetFrameworkV4 (2) immediately + let arch_pattern = [ + 0x02, 0x7b, 0x0a, 0x00, 0x00, 0x04, 0x2c, 0x07, 0x02, 0x7b, 0x0a, 0x00, 0x00, + 0x04, 0x2a, + ]; + if let Some(pos) = decomp.windows(arch_pattern.len()).position(|w| w == arch_pattern) { + decomp[pos..pos + 2].copy_from_slice(&[0x18, 0x2a]); // ldc.i4.2, ret + decomp[pos + 2..pos + 15].fill(0x00); // 13 nops + inner_modified = true; + } + + // 4c. FrameworkInjectorBase.GetCorBindToRuntimeExAddress: return 0x17b55 directly without remote PE parsing + let corbind_pattern = [ + 0x03, 0x06, 0x7b, 0x65, 0x00, 0x00, 0x0a, 0x72, 0x3d, 0x03, 0x00, 0x70, 0x04, + 0x28, 0x08, 0x00, 0x00, 0x06, 0x0b, + ]; + if let Some(pos) = decomp.windows(corbind_pattern.len()).position(|w| w == corbind_pattern) { + decomp[pos..pos + 6].copy_from_slice(&[0x20, 0x55, 0x7b, 0x01, 0x00, 0x0b]); // ldc.i4 0x17b55, stloc.1 + decomp[pos + 6..pos + 19].fill(0x00); // 13 nops + inner_modified = true; + } + + if inner_modified { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best()); + if encoder.write_all(&decomp).is_ok() { + if let Ok(mut compressed) = encoder.finish() { + if compressed.len() <= 10020 - 5 { + let remaining = 10020 - compressed.len(); + let length = (remaining - 5) as u16; + let nlen = !length; + compressed.push(0x01); // BFINAL = 1, BTYPE = 00 + compressed.push((length & 0xff) as u8); + compressed.push((length >> 8) as u8); + compressed.push((nlen & 0xff) as u8); + compressed.push((nlen >> 8) as u8); + compressed.extend(std::iter::repeat(0x00).take(length as usize)); + if compressed.len() == 10020 { + data[0x1f00..0x1f00 + 10020].copy_from_slice(&compressed); + modified = true; + } + } + } + } + } + } + } + + if modified { + fs::write(patcher_exe, &data) + .map_err(|e| format!("Failed to write {}: {e}", patcher_exe.display()))?; + log::info!( + target: "patcher", + "Applied .NET 4.0 compatibility patch to {}", + patcher_exe.display() + ); + } + + Ok(modified) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_patcher_compatibility_patch() { + let mut fake_binary = vec![0x90; 0x3000]; + let pbi = [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b]; + fake_binary[10..10 + pbi.len()].copy_from_slice(&pbi); + let readkey = [ + 0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde, + ]; + fake_binary[30..30 + readkey.len()].copy_from_slice(&readkey); + let devserver = [ + 0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b, + ]; + fake_binary[50..50 + devserver.len()].copy_from_slice(&devserver); + + let temp_file = std::env::temp_dir().join("test_patcher_compat.exe"); + std::fs::write(&temp_file, &fake_binary).unwrap(); + let modified = ensure_patcher_compatibility(&temp_file).unwrap(); + assert!(modified); + let patched_data = std::fs::read(&temp_file).unwrap(); + assert!(is_compatible_patcher(&patched_data)); + let _ = std::fs::remove_file(temp_file); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_get_window_title_macos() { + let _ = get_window_title_by_pid(Pid::from_u32(0)); + } + + #[test] + fn test_patcher_compatibility_real_patcher() { + let real_patcher = Path::new("/Applications/osu!(stable).app/Contents/Resources/drive_c/osu!/EZPPLauncher/patcher/osu!.patcher.exe"); + if real_patcher.is_file() { + let res = ensure_patcher_compatibility(real_patcher); + assert!(res.is_ok()); + let data = std::fs::read(real_patcher).unwrap(); + assert!(is_compatible_patcher(&data)); + } + } +} diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index bcbf4cf..5fad399 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -2238,7 +2238,7 @@ element.width = 340; element.height = 64; } else if (widgetKind === 'launch-button') { - element.width = 210; + element.width = 256; element.height = 48; } } @@ -2290,9 +2290,9 @@ ) as ThemeV2WidgetElement; launchBtn.widget = 'launch-button'; launchBtn.name = 'Launch Button'; - launchBtn.x = 740; + launchBtn.x = 720; launchBtn.y = 575; - launchBtn.width = 210; + launchBtn.width = 256; launchBtn.height = 48; newElements.push(launchBtn); existing = launchBtn; diff --git a/src/lib/components/theme/ThemeElementInspector.svelte b/src/lib/components/theme/ThemeElementInspector.svelte index 6bee5cc..153d47f 100644 --- a/src/lib/components/theme/ThemeElementInspector.svelte +++ b/src/lib/components/theme/ThemeElementInspector.svelte @@ -2322,7 +2322,7 @@ element.width = 140; element.height = 42; } else if (val === 'primary') { - element.width = 210; + element.width = 256; element.height = 48; } } else if (element.widget === 'header') { diff --git a/src/lib/components/theme/ThemeIcon.svelte b/src/lib/components/theme/ThemeIcon.svelte index 074fb47..228fd20 100644 --- a/src/lib/components/theme/ThemeIcon.svelte +++ b/src/lib/components/theme/ThemeIcon.svelte @@ -7,11 +7,13 @@ set = DEFAULT_THEME_ICON_SET, size = '1em', class: className = '', + style = '', }: { name: ThemeIconName; set?: ThemeIconSet; size?: number | string; class?: string; + style?: string; } = $props(); const icon = $derived(getThemeIconBody(name, set)); @@ -23,6 +25,7 @@ width={size} height={size} class={className} + {style} aria-hidden="true" focusable="false" > diff --git a/src/lib/components/theme/ThemeV2Elements.svelte b/src/lib/components/theme/ThemeV2Elements.svelte index 6e8ac47..6135305 100644 --- a/src/lib/components/theme/ThemeV2Elements.svelte +++ b/src/lib/components/theme/ThemeV2Elements.svelte @@ -3,6 +3,7 @@ import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte'; import * as Avatar from '$lib/components/ui/avatar'; + import DownloadButton from '$lib/components/ui/download-button/DownloadButton.svelte'; import { currentUserInfo } from '$lib/data'; import { launching as launchingStore } from '$lib/global'; import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData'; @@ -70,6 +71,7 @@ launching = undefined, launchProgress = -1, launchText = 'Launch', + launchSubtext = undefined, launchDisabled = false, oninteraction, onselect, @@ -103,6 +105,7 @@ launching?: boolean; launchProgress?: number; launchText?: string; + launchSubtext?: string; launchDisabled?: boolean; oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void; onselect?: (id: string | undefined, additive?: boolean) => void; @@ -1613,14 +1616,25 @@ }} > {#if isActionLaunch && isLaunching} - + {#if launchProgress >= 0} + {Math.round(launchProgress)}% + {:else} + + {/if} {:else if element.icon} {/if} {#if isActionLaunch && isLaunching} - - {launchProgress > 0 ? `${launchText} (${Math.round(launchProgress)}%)` : launchText} - +
+ + {launchProgress >= 0 + ? `${launchText} (${Math.round(launchProgress)}%)` + : launchText} + + {#if launchSubtext} + {launchSubtext} + {/if} +
{:else if isMorphEnabled(element)} {#if isLaunching} - {#if launchProgress > 0} + {#if launchProgress >= 0} @@ -1910,9 +1928,9 @@ {:else} {/if} {:else} @@ -1934,61 +1952,53 @@ onbuttonaction?.({ type: 'launch' }); } }} - title={isLaunching ? launchText : 'Launch osu!'} + title={isLaunching + ? launchSubtext + ? `${launchText} - ${launchSubtext}` + : launchText + : 'Launch osu!'} > {#if isLaunching} - - {launchProgress > 0 ? `${Math.round(launchProgress)}%` : launchText} + {#if launchProgress >= 0} + {Math.round(launchProgress)}% + {:else} + + {/if} +
+ {launchText} + {#if launchSubtext} + {launchSubtext} + {/if} +
{:else} Launch {/if} {:else} - +
+ { + if (interactive) return; + if (!launchDisabled && !isLaunching) { + onbuttonaction?.({ type: 'launch' }); + } + }} + /> +
{/if} {:else if element.widget === 'player-card'} {@const playerStats = diff --git a/src/lib/components/ui/download-button/DownloadButton.svelte b/src/lib/components/ui/download-button/DownloadButton.svelte index cba63c1..edd736e 100644 --- a/src/lib/components/ui/download-button/DownloadButton.svelte +++ b/src/lib/components/ui/download-button/DownloadButton.svelte @@ -9,23 +9,60 @@ subtext?: string; disabled?: boolean; iconSet?: ThemeIconSet; + class?: string; onClick?: () => void; } = $props(); - const radius = 16; + let buttonHeight = $state(48); + let buttonWidth = $state(200); + + const baseDim = $derived( + Math.min(buttonHeight > 0 ? buttonHeight : 48, buttonWidth > 0 ? buttonWidth : 200) + ); + + // Circle scales with button dimensions: ~70% of base dimension (no artificial upper clamp) + const circleSize = $derived(Math.max(16, Math.round(baseDim * 0.7))); + // Icon scales with circle size: ~44% of circle size + const iconSize = $derived(Math.max(10, Math.round(circleSize * 0.44))); + // Progress font size scales with circle size + const percentFontSize = $derived(Math.max(8, Math.round(circleSize * 0.32))); + // Optical horizontal nudge for the play triangle + const iconNudge = $derived(Math.max(1, Math.round(circleSize * 0.04))); + + // Responsive font scaling for label and subtext + const availableTextWidth = $derived( + Math.max(50, (buttonWidth > 0 ? buttonWidth : 200) - baseDim) + ); + const textChars = $derived(Math.max(6, (prop.text || 'Launch').length)); + const maxFontFromWidth = $derived((availableTextWidth * 0.85) / textChars); + const maxFontFromHeight = $derived(baseDim * (prop.subtext ? 0.24 : 0.32)); + const labelFontSize = $derived( + Math.max(10, Math.round(Math.min(maxFontFromHeight, maxFontFromWidth))) + ); + const subtextFontSize = $derived(Math.max(8, Math.round(labelFontSize * 0.72))); + + const radius = 16.5; const circumference = 2 * Math.PI * radius; - const strokeDashoffset = $derived(circumference - (prop.progress / 100) * circumference); + const strokeDashoffset = $derived( + circumference - (Math.max(0, Math.min(100, prop.progress)) / 100) * circumference + );