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.
This commit is contained in:
HorizonCode committed 2026-11-01 10:52:39 +01:00
1 parent a2b03d3014
commit e52399911c
19 files changed
+1362 -265

No files matched your search

+466 -58
View File
@@ -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<Option<PathBuf>, 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<Child, 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()
));
/// 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<Child, 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::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::<Vec<_>>()
.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(&reg_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(&reg_path) {
if content.contains("Apple Braille") {
let cleaned: String = content
.lines()
.filter(|line| !line.contains("Apple Braille"))
.collect::<Vec<_>>()
.join("\n") + "\n";
let _ = fs::write(&reg_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<u16> {
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");
}
}