1510 lines
53 KiB
Rust
1510 lines
53 KiB
Rust
//! macOS support for osu! stable running inside a Wine wrapper.
|
|
//!
|
|
//! osu! stable is a Windows application, so on macOS it has to run through a
|
|
//! Wine-based wrapper. The launcher supports Wineskin wrappers (the common
|
|
//! `osu!.app` wine skin), Whisky bottles and CrossOver bottles.
|
|
|
|
use std::ffi::OsStr;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::time::{Duration as StdDuration, Instant};
|
|
use tokio::process::{Child, Command};
|
|
use tokio::time::{Duration, sleep};
|
|
|
|
use crate::utils::is_valid_osu_folder;
|
|
|
|
/// Round the borderless launcher window and make its corners transparent.
|
|
///
|
|
/// macOS does not round undecorated windows on its own, so the webview has to
|
|
/// be clipped with the content view's layer. The window itself is made
|
|
/// transparent so the area outside that clip shows the desktop instead of a
|
|
/// square background.
|
|
pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), String> {
|
|
use objc2::msg_send;
|
|
use objc2::runtime::AnyObject;
|
|
|
|
let ns_window = window.ns_window().map_err(|error| error.to_string())? as *mut AnyObject;
|
|
if ns_window.is_null() {
|
|
return Err("The native window is not available".into());
|
|
}
|
|
|
|
unsafe {
|
|
let _: () = msg_send![ns_window, setOpaque: false];
|
|
let clear: *mut AnyObject = msg_send![objc2::class!(NSColor), clearColor];
|
|
let _: () = msg_send![ns_window, setBackgroundColor: clear];
|
|
let _: () = msg_send![ns_window, setHasShadow: true];
|
|
|
|
let content_view: *mut AnyObject = msg_send![ns_window, contentView];
|
|
if content_view.is_null() {
|
|
return Err("The native content view is not available".into());
|
|
}
|
|
|
|
let _: () = msg_send![content_view, setWantsLayer: true];
|
|
let layer: *mut AnyObject = msg_send![content_view, layer];
|
|
if layer.is_null() {
|
|
return Err("The native content view has no layer".into());
|
|
}
|
|
|
|
let _: () = msg_send![layer, setCornerRadius: radius];
|
|
let _: () = msg_send![layer, setMasksToBounds: true];
|
|
let _: () = msg_send![ns_window, invalidateShadow];
|
|
}
|
|
|
|
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.
|
|
pub fn allow_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> {
|
|
use objc2::msg_send;
|
|
use objc2::runtime::AnyObject;
|
|
|
|
unsafe extern "C" {
|
|
fn object_getInstanceVariable(
|
|
obj: *mut AnyObject,
|
|
name: *const std::ffi::c_char,
|
|
out_value: *mut *mut std::ffi::c_void,
|
|
) -> *const std::ffi::c_void;
|
|
}
|
|
|
|
window
|
|
.with_webview(|webview| unsafe {
|
|
let webview = webview.inner() as *mut AnyObject;
|
|
if webview.is_null() {
|
|
return;
|
|
}
|
|
|
|
// -[WKWebView configuration] returns a copy. To affect live playback,
|
|
// access the internal `_configuration` ivar directly when available.
|
|
let mut raw_config: *mut std::ffi::c_void = std::ptr::null_mut();
|
|
let ivar_name = c"_configuration";
|
|
let ivar = object_getInstanceVariable(webview, ivar_name.as_ptr(), &mut raw_config);
|
|
let configuration: *mut AnyObject = if !ivar.is_null() && !raw_config.is_null() {
|
|
log::debug!(target: "launcher", "Accessing live WKWebView._configuration ivar");
|
|
raw_config as *mut AnyObject
|
|
} else {
|
|
msg_send![webview, configuration]
|
|
};
|
|
|
|
if configuration.is_null() {
|
|
return;
|
|
}
|
|
|
|
// WKAudiovisualMediaTypesNone. This is a public WKWebView setting.
|
|
let _: () =
|
|
msg_send![configuration, setMediaTypesRequiringUserActionForPlayback: 0usize];
|
|
let current: usize = msg_send![configuration, mediaTypesRequiringUserActionForPlayback];
|
|
log::debug!(
|
|
target: "launcher",
|
|
"Theme media autoplay policy: {current}"
|
|
);
|
|
|
|
// Configure webpage preferences for unconditional media autoplay
|
|
let default_preferences: *mut AnyObject = msg_send![configuration, defaultWebpagePreferences];
|
|
if !default_preferences.is_null() {
|
|
let sel = objc2::sel!(_setAutoplayPolicy:);
|
|
let responds: bool = msg_send![default_preferences, respondsToSelector: sel];
|
|
if responds {
|
|
// 1 = _WKWebsiteAutoplayPolicyAllow (allow autoplay unconditionally)
|
|
let _: () = msg_send![default_preferences, _setAutoplayPolicy: 1isize];
|
|
log::debug!(target: "launcher", "Set WKWebpagePreferences._setAutoplayPolicy to Allow (1)");
|
|
}
|
|
}
|
|
|
|
// Configure WKPreferences
|
|
let preferences: *mut AnyObject = msg_send![configuration, preferences];
|
|
if !preferences.is_null() {
|
|
let sel_audio = objc2::sel!(_setRequiresUserGestureForAudioPlayback:);
|
|
let responds: bool = msg_send![preferences, respondsToSelector: sel_audio];
|
|
if responds {
|
|
let _: () = msg_send![preferences, _setRequiresUserGestureForAudioPlayback: false];
|
|
}
|
|
|
|
let sel_video = objc2::sel!(_setRequiresUserGestureForVideoPlayback:);
|
|
let responds: bool = msg_send![preferences, respondsToSelector: sel_video];
|
|
if responds {
|
|
let _: () = msg_send![preferences, _setRequiresUserGestureForVideoPlayback: false];
|
|
}
|
|
|
|
let sel_inline = objc2::sel!(_setAllowsInlineMediaPlayback:);
|
|
let responds: bool = msg_send![preferences, respondsToSelector: sel_inline];
|
|
if responds {
|
|
let _: () = msg_send![preferences, _setAllowsInlineMediaPlayback: true];
|
|
}
|
|
|
|
let sel_inline_req = objc2::sel!(_setInlineMediaPlaybackRequiresPlaysInlineAttribute:);
|
|
let responds: bool = msg_send![preferences, respondsToSelector: sel_inline_req];
|
|
if responds {
|
|
let _: () = msg_send![preferences, _setInlineMediaPlaybackRequiresPlaysInlineAttribute: false];
|
|
}
|
|
|
|
let sel_invis = objc2::sel!(_setInvisibleMediaAutoplayNotPermitted:);
|
|
let responds: bool = msg_send![preferences, respondsToSelector: sel_invis];
|
|
if responds {
|
|
let _: () = msg_send![preferences, _setInvisibleMediaAutoplayNotPermitted: false];
|
|
}
|
|
}
|
|
|
|
let sel_media_load = objc2::sel!(_setMediaDataLoadsAutomatically:);
|
|
let responds: bool = msg_send![configuration, respondsToSelector: sel_media_load];
|
|
if responds {
|
|
let _: () = msg_send![configuration, _setMediaDataLoadsAutomatically: true];
|
|
}
|
|
|
|
let sel_audio_action = objc2::sel!(_setRequiresUserActionForAudioPlayback:);
|
|
let responds: bool = msg_send![configuration, respondsToSelector: sel_audio_action];
|
|
if responds {
|
|
let _: () = msg_send![configuration, _setRequiresUserActionForAudioPlayback: false];
|
|
}
|
|
})
|
|
.map_err(|error| format!("Could not enable theme media autoplay: {error}"))
|
|
}
|
|
|
|
/// Programmatically unlock media autoplay with unmuted sound using WebKit's
|
|
/// user gesture SPI and media resumption APIs.
|
|
pub fn unlock_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> {
|
|
use objc2::msg_send;
|
|
use objc2::runtime::AnyObject;
|
|
use std::ffi::CString;
|
|
|
|
window
|
|
.with_webview(|webview| unsafe {
|
|
let webview = webview.inner() as *mut AnyObject;
|
|
if webview.is_null() {
|
|
return;
|
|
}
|
|
|
|
// 1. Resume all media playback
|
|
let sel_resume = objc2::sel!(_resumeAllMediaPlayback);
|
|
if msg_send![webview, respondsToSelector: sel_resume] {
|
|
let _: () = msg_send![webview, _resumeAllMediaPlayback];
|
|
} else {
|
|
let sel_resume_pub = objc2::sel!(resumeAllMediaPlayback:);
|
|
if msg_send![webview, respondsToSelector: sel_resume_pub] {
|
|
let _: () = msg_send![
|
|
webview,
|
|
resumeAllMediaPlayback: std::ptr::null_mut::<AnyObject>()
|
|
];
|
|
}
|
|
}
|
|
|
|
// 2. Evaluate unmuted media play script with explicit user gesture SPI
|
|
let sel_eval = objc2::sel!(
|
|
_evaluateJavaScript:withSourceURL:inFrame:inContentWorld:withUserGesture:completionHandler:
|
|
);
|
|
if msg_send![webview, respondsToSelector: sel_eval] {
|
|
let script_str = match CString::new(
|
|
"document.querySelectorAll('video, audio').forEach(function(el) { \
|
|
el.muted = false; \
|
|
var p = el.play(); \
|
|
if (p !== undefined) p.catch(function() {}); \
|
|
});",
|
|
) {
|
|
Ok(s) => s,
|
|
Err(_) => return,
|
|
};
|
|
|
|
let ns_script: *mut AnyObject =
|
|
msg_send![objc2::class!(NSString), stringWithUTF8String: script_str.as_ptr()];
|
|
let page_world: *mut AnyObject =
|
|
msg_send![objc2::class!(WKContentWorld), pageWorld];
|
|
|
|
let _: () = msg_send![
|
|
webview,
|
|
_evaluateJavaScript: ns_script,
|
|
withSourceURL: std::ptr::null_mut::<AnyObject>(),
|
|
inFrame: std::ptr::null_mut::<AnyObject>(),
|
|
inContentWorld: page_world,
|
|
withUserGesture: true,
|
|
completionHandler: std::ptr::null_mut::<AnyObject>()
|
|
];
|
|
log::debug!(target: "launcher", "Unlocked theme media autoplay with user gesture SPI");
|
|
}
|
|
})
|
|
.map_err(|error| format!("Could not unlock theme media autoplay: {error}"))
|
|
}
|
|
|
|
/// Show a native open panel that accepts `.app` bundles as well as folders.
|
|
///
|
|
/// AppKit treats application bundles as packages (directories) by default, so a
|
|
/// regular folder picker descends into `osu!.app` instead of selecting it. This
|
|
/// panel turns that behaviour off, letting the user pick the wrapper itself,
|
|
/// while still allowing a plain Wine prefix folder to be selected.
|
|
pub fn pick_osu_installation() -> Result<Option<PathBuf>, String> {
|
|
use objc2::msg_send;
|
|
use objc2::rc::autoreleasepool;
|
|
use objc2::runtime::AnyObject;
|
|
use std::ffi::{CStr, CString};
|
|
|
|
unsafe fn ns_string(value: &str) -> Result<*mut AnyObject, String> {
|
|
let value = CString::new(value).map_err(|_| "The path contains a NUL byte".to_string())?;
|
|
let string: *mut AnyObject =
|
|
unsafe { msg_send![objc2::class!(NSString), stringWithUTF8String: value.as_ptr()] };
|
|
if string.is_null() {
|
|
return Err("Could not create the native dialog text".to_string());
|
|
}
|
|
Ok(string)
|
|
}
|
|
|
|
autoreleasepool(|_| unsafe {
|
|
let panel: *mut AnyObject = msg_send![objc2::class!(NSOpenPanel), openPanel];
|
|
if panel.is_null() {
|
|
return Err("Could not create the native file picker".to_string());
|
|
}
|
|
|
|
let _: () = msg_send![panel, setCanChooseFiles: true];
|
|
let _: () = msg_send![panel, setCanChooseDirectories: true];
|
|
// Keep `.app` bundles selectable instead of navigating into them.
|
|
let _: () = msg_send![panel, setTreatsFilePackagesAsDirectories: false];
|
|
let _: () = msg_send![panel, setAllowsMultipleSelection: false];
|
|
let _: () = msg_send![panel, setCanCreateDirectories: false];
|
|
|
|
let title = ns_string("Select osu!.app")?;
|
|
let _: () = msg_send![panel, setTitle: title];
|
|
let message = ns_string("Pick your osu!.app wrapper, or a Wine prefix folder.")?;
|
|
let _: () = msg_send![panel, setMessage: message];
|
|
let prompt = ns_string("Select")?;
|
|
let _: () = msg_send![panel, setPrompt: prompt];
|
|
|
|
let applications = ns_string("/Applications")?;
|
|
let directory_url: *mut AnyObject =
|
|
msg_send![objc2::class!(NSURL), fileURLWithPath: applications];
|
|
if !directory_url.is_null() {
|
|
let _: () = msg_send![panel, setDirectoryURL: directory_url];
|
|
}
|
|
|
|
// NSModalResponseOK
|
|
let response: i64 = msg_send![panel, runModal];
|
|
if response != 1 {
|
|
return Ok(None);
|
|
}
|
|
|
|
let url: *mut AnyObject = msg_send![panel, URL];
|
|
if url.is_null() {
|
|
return Ok(None);
|
|
}
|
|
let path: *mut AnyObject = msg_send![url, path];
|
|
if path.is_null() {
|
|
return Ok(None);
|
|
}
|
|
let utf8: *const std::os::raw::c_char = msg_send![path, UTF8String];
|
|
if utf8.is_null() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let path = CStr::from_ptr(utf8).to_string_lossy().into_owned();
|
|
Ok(Some(PathBuf::from(path)))
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct WineRuntime {
|
|
prefix: PathBuf,
|
|
wine_bin: PathBuf,
|
|
wine_root: Option<PathBuf>,
|
|
app_bundle: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct WineDiagnostics {
|
|
pub prefix: PathBuf,
|
|
pub wine_bin: PathBuf,
|
|
pub app_bundle: Option<PathBuf>,
|
|
pub net8_installed: bool,
|
|
pub wine_version: Option<String>,
|
|
pub prefix_arch: Option<String>,
|
|
}
|
|
|
|
impl WineDiagnostics {
|
|
/// Inspect the wrapper without starting it, for the launcher's diagnostics panel.
|
|
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
|
|
let runtime = WineRuntime::for_osu_folder(osu_folder)?;
|
|
let net8_installed = [
|
|
runtime
|
|
.prefix
|
|
.join("drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"),
|
|
runtime
|
|
.prefix
|
|
.join("drive_c/Program Files (x86)/dotnet/shared/Microsoft.WindowsDesktop.App"),
|
|
]
|
|
.iter()
|
|
.any(|path| {
|
|
fs::read_dir(path)
|
|
.ok()
|
|
.map(|entries| {
|
|
entries.filter_map(|entry| entry.ok()).any(|entry| {
|
|
entry
|
|
.file_name()
|
|
.to_str()
|
|
.map(|name| name.starts_with("8."))
|
|
.unwrap_or(false)
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
Ok(Self {
|
|
prefix: runtime.prefix.clone(),
|
|
wine_bin: runtime.wine_bin.clone(),
|
|
app_bundle: app_bundle_for(&runtime.prefix),
|
|
net8_installed,
|
|
wine_version: wine_version_for(&runtime.wine_bin),
|
|
prefix_arch: prefix_architecture(&runtime.prefix),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl WineRuntime {
|
|
/// Build a runtime description for an osu! folder inside a Wine prefix.
|
|
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
|
|
if !osu_folder.join("osu!.exe").is_file() {
|
|
return Err(format!(
|
|
"osu!.exe was not found in {}",
|
|
osu_folder.display()
|
|
));
|
|
}
|
|
|
|
let prefix = prefix_for(osu_folder).ok_or_else(|| {
|
|
"This osu! installation is not inside a Wine prefix. On macOS, choose the osu! folder inside your Wineskin, Whisky or CrossOver wrapper.".to_string()
|
|
})?;
|
|
|
|
let app = app_bundle_for(&prefix);
|
|
let (wine_bin, wine_root) = find_wine_bin(&prefix, app.as_deref()).ok_or_else(|| {
|
|
"Could not find a Wine runtime for this osu! installation. Make sure the Wineskin/Whisky/CrossOver wrapper is still installed.".to_string()
|
|
})?;
|
|
|
|
Ok(Self {
|
|
prefix,
|
|
wine_bin,
|
|
wine_root,
|
|
app_bundle: app,
|
|
})
|
|
}
|
|
|
|
/// 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 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")))
|
|
&& 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");
|
|
// 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();
|
|
|
|
if let Some(app) = &self.app_bundle {
|
|
for dir in [
|
|
app.join("Contents/Frameworks"),
|
|
app.join("Contents/Frameworks/wswine.bundle/lib"),
|
|
app.join("Contents/Frameworks/wswine.bundle/lib64"),
|
|
app.join("Contents/Frameworks/wswine.bundle/lib32on64"),
|
|
app.join("Contents/SharedSupport/wine/lib"),
|
|
app.join("Contents/SharedSupport/wine/lib64"),
|
|
app.join("Contents/SharedSupport/wine/lib32on64"),
|
|
app.join("Frameworks"),
|
|
] {
|
|
if dir.is_dir() {
|
|
library_paths.push(dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
for dir in [
|
|
self.prefix.join("Frameworks"),
|
|
self.prefix.join("lib"),
|
|
self.prefix.join("lib64"),
|
|
self.prefix.join("lib32on64"),
|
|
] {
|
|
if dir.is_dir() {
|
|
library_paths.push(dir);
|
|
}
|
|
}
|
|
|
|
if let Some(wine_root) = &self.wine_root {
|
|
for dir in [
|
|
wine_root.join("lib"),
|
|
wine_root.join("lib64"),
|
|
wine_root.join("lib32on64"),
|
|
wine_root.clone(),
|
|
] {
|
|
if dir.is_dir() {
|
|
library_paths.push(dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
for system_dir in [
|
|
PathBuf::from("/opt/local/lib"),
|
|
PathBuf::from("/usr/lib"),
|
|
PathBuf::from("/usr/libexec"),
|
|
PathBuf::from("/usr/lib/system"),
|
|
PathBuf::from("/opt/X11/lib"),
|
|
] {
|
|
if system_dir.is_dir() {
|
|
library_paths.push(system_dir);
|
|
}
|
|
}
|
|
|
|
if let Some(existing) = std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH") {
|
|
library_paths.extend(std::env::split_paths(&existing));
|
|
}
|
|
|
|
if let Ok(library_path) = std::env::join_paths(library_paths) {
|
|
command.env("DYLD_FALLBACK_LIBRARY_PATH", &library_path);
|
|
command.env("WINESKIN_LIB_PATH_FOR_FALLBACK", &library_path);
|
|
}
|
|
|
|
let mut paths = vec![self.wine_bin.clone()];
|
|
if let Some(existing) = std::env::var_os("PATH") {
|
|
paths.extend(std::env::split_paths(&existing));
|
|
}
|
|
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()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| "the Windows application".to_string());
|
|
|
|
command.spawn().map_err(|error| {
|
|
format!(
|
|
"Failed to start {executable_name} through Wine ({}): {}",
|
|
loader.display(),
|
|
error
|
|
)
|
|
})
|
|
}
|
|
|
|
/// 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");
|
|
if !wineserver.is_file() {
|
|
return;
|
|
}
|
|
|
|
let mut cmd = Command::new(wineserver);
|
|
cmd.arg("-k")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
|
|
self.apply_wine_environment(&mut cmd);
|
|
|
|
let _ = cmd.status().await;
|
|
}
|
|
|
|
fn loader_for(&self, exe: &Path) -> PathBuf {
|
|
let is_64_bit = pe_machine(exe) == Some(0x8664);
|
|
let candidates: &[&str] = if is_64_bit {
|
|
&["wine64", "wine"]
|
|
} else {
|
|
&["wine32on64", "wine64", "wine"]
|
|
};
|
|
|
|
candidates
|
|
.iter()
|
|
.map(|candidate| self.wine_bin.join(candidate))
|
|
.find(|candidate| candidate.is_file())
|
|
.unwrap_or_else(|| self.wine_bin.join(candidates[0]))
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
&& let Ok(content) = fs::read_to_string(&cfg_path)
|
|
&& 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()
|
|
&& let Ok(content) = fs::read_to_string(&bat_path)
|
|
&& 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()
|
|
&& let Ok(content) = fs::read_to_string(®_path)
|
|
&& 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()
|
|
&& let Ok(content) = fs::read_to_string(®_path)
|
|
&& content.contains("Apple Braille") {
|
|
let cleaned: String = content
|
|
.lines()
|
|
.filter(|line| !line.contains("Apple Braille"))
|
|
.collect::<Vec<_>>()
|
|
.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,
|
|
game_optimization: bool,
|
|
) -> Result<(), String> {
|
|
if game_optimization {
|
|
log::info!(target: "osu", "Game optimization active on macOS");
|
|
}
|
|
let folder = Path::new(osu_folder);
|
|
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
|
|
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(&osu_exe, &["-devserver", "ez-pp.farm"])
|
|
.map_err(|error| {
|
|
log::error!(target: "osu", "Could not start osu!: {error}");
|
|
error
|
|
})?;
|
|
|
|
if let Some(pid) = game.id() {
|
|
log::info!(target: "osu", "osu! started through Wine (pid {pid})");
|
|
}
|
|
crate::logging::capture_osu_output(&mut game);
|
|
|
|
if patch {
|
|
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
|
|
&& 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 &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(())
|
|
}
|
|
|
|
/// Start the Windows patcher through the same Wine prefix as osu!.
|
|
async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), String> {
|
|
let patcher_exe = osu_folder
|
|
.join("EZPPLauncher")
|
|
.join("patcher")
|
|
.join("osu!.patcher.exe");
|
|
|
|
if !patcher_exe.is_file() {
|
|
return Err(format!(
|
|
"Patcher was requested but {} does not exist",
|
|
patcher_exe.display()
|
|
));
|
|
}
|
|
|
|
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",
|
|
"Starting the patcher from {}",
|
|
patcher_exe.display()
|
|
);
|
|
|
|
let mut patcher = runtime.spawn(&patcher_exe, &[])?;
|
|
if let Some(pid) = patcher.id() {
|
|
log::info!(target: "patcher", "Patcher started through Wine (pid {pid})");
|
|
}
|
|
crate::logging::capture_patcher_output(&mut patcher);
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
match patcher.wait().await {
|
|
Ok(status) => log::info!(target: "patcher", "Patcher exited with {status}"),
|
|
Err(error) => log::warn!(target: "patcher", "Could not wait for the patcher: {error}"),
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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);
|
|
|
|
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 process in sys.processes().values() {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Run `osu!.exe -repair`, wait for the updater and close the game it starts.
|
|
pub async fn run_osu_updater(osu_folder: &str) -> Result<(), String> {
|
|
let folder = Path::new(osu_folder);
|
|
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
|
|
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
|
|
error
|
|
})?;
|
|
let mut updater = runtime
|
|
.spawn(&folder.join("osu!.exe"), &["-repair"])
|
|
.map_err(|error| {
|
|
log::error!(target: "osu", "Could not start the osu! updater: {error}");
|
|
error
|
|
})?;
|
|
|
|
if let Some(pid) = updater.id() {
|
|
log::info!(target: "osu", "osu! updater started through Wine (pid {pid})");
|
|
}
|
|
crate::logging::capture_osu_output(&mut updater);
|
|
|
|
let mut system = sysinfo::System::new_all();
|
|
let deadline = Instant::now() + StdDuration::from_secs(300);
|
|
|
|
loop {
|
|
if updater
|
|
.try_wait()
|
|
.map_err(|error| format!("Failed to check the osu! updater: {}", error))?
|
|
.is_some()
|
|
{
|
|
log::info!(target: "osu", "The osu! updater exited");
|
|
break;
|
|
}
|
|
|
|
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
|
if system
|
|
.processes()
|
|
.values()
|
|
.any(crate::utils::is_osu_game_process)
|
|
{
|
|
// The updater relaunched osu! to finish the update. Close it so the
|
|
// launcher can start the game itself with the correct server.
|
|
log::info!(target: "osu", "The updater relaunched osu!; closing the Wine prefix");
|
|
runtime.kill_prefix().await;
|
|
break;
|
|
}
|
|
|
|
if Instant::now() >= deadline {
|
|
log::warn!(target: "osu", "Timed out waiting for the osu! updater; closing the Wine prefix");
|
|
runtime.kill_prefix().await;
|
|
break;
|
|
}
|
|
|
|
sleep(Duration::from_millis(500)).await;
|
|
}
|
|
|
|
match updater.wait().await {
|
|
Ok(status) => log::info!(target: "osu", "osu! updater exited with {status}"),
|
|
Err(error) => log::warn!(target: "osu", "Could not wait for the osu! updater: {error}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve a user-selected path (osu! folder, `.app` bundle, prefix or drive_c)
|
|
/// to a valid osu! installation folder.
|
|
pub fn resolve_osu_folder(folder: &Path) -> Option<PathBuf> {
|
|
if is_valid_osu_folder(folder) {
|
|
return Some(folder.to_path_buf());
|
|
}
|
|
|
|
let mut prefixes = Vec::new();
|
|
|
|
if folder.file_name() == Some(OsStr::new("drive_c"))
|
|
&& let Some(parent) = canonical_prefix(folder) {
|
|
prefixes.push(parent);
|
|
}
|
|
|
|
if folder.join("drive_c").is_dir()
|
|
&& let Some(parent) = canonical_prefix(&folder.join("drive_c")) {
|
|
prefixes.push(parent);
|
|
}
|
|
|
|
if folder.extension() == Some(OsStr::new("app")) || folder.join("Contents").is_dir() {
|
|
for drive_c in [
|
|
folder.join("Contents/Resources/drive_c"),
|
|
folder.join("Contents/SharedSupport/drive_c"),
|
|
folder.join("Contents/SharedSupport/prefix/drive_c"),
|
|
] {
|
|
if drive_c.is_dir()
|
|
&& let Some(parent) = canonical_prefix(&drive_c) {
|
|
prefixes.push(parent);
|
|
}
|
|
}
|
|
|
|
let mut drive_dirs = Vec::new();
|
|
find_drive_c_dirs(folder, 5, &mut drive_dirs);
|
|
prefixes.extend(
|
|
drive_dirs
|
|
.iter()
|
|
.filter_map(|drive_c| drive_c.parent().map(Path::to_path_buf)),
|
|
);
|
|
}
|
|
|
|
if let Some(prefix) = prefix_for(folder) {
|
|
prefixes.push(prefix);
|
|
}
|
|
|
|
prefixes.sort();
|
|
prefixes.dedup();
|
|
|
|
prefixes
|
|
.into_iter()
|
|
.find_map(|prefix| find_osu_folder_in_prefix(&prefix))
|
|
}
|
|
|
|
/// Auto-detect an osu! installation in the usual macOS wrapper locations.
|
|
pub fn find_osu_installation() -> Option<String> {
|
|
for app in app_bundles() {
|
|
if let Some(folder) = resolve_osu_folder(&app) {
|
|
return Some(folder.to_string_lossy().into_owned());
|
|
}
|
|
}
|
|
|
|
for prefix in bottle_prefixes() {
|
|
if let Some(folder) = find_osu_folder_in_prefix(&prefix) {
|
|
return Some(folder.to_string_lossy().into_owned());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn find_osu_folder_in_prefix(prefix: &Path) -> Option<PathBuf> {
|
|
let drive_c = prefix.join("drive_c");
|
|
if !drive_c.is_dir() {
|
|
return None;
|
|
}
|
|
|
|
if let Some(folder) = common_osu_folders(&drive_c)
|
|
.into_iter()
|
|
.find(|folder| is_valid_osu_folder(folder))
|
|
{
|
|
return Some(folder);
|
|
}
|
|
|
|
let mut candidates = Vec::new();
|
|
collect_osu_folders(&drive_c, 0, 6, &mut candidates);
|
|
candidates
|
|
.into_iter()
|
|
.find(|folder| is_valid_osu_folder(folder))
|
|
}
|
|
|
|
fn common_osu_folders(drive_c: &Path) -> Vec<PathBuf> {
|
|
let mut folders = vec![
|
|
drive_c.join("osu!"),
|
|
drive_c.join("Program Files").join("osu!"),
|
|
drive_c.join("Program Files (x86)").join("osu!"),
|
|
];
|
|
|
|
if let Ok(users) = fs::read_dir(drive_c.join("users")) {
|
|
for user in users.flatten() {
|
|
let user_path = user.path();
|
|
if user_path.is_dir() {
|
|
folders.push(user_path.join("AppData").join("Local").join("osu!"));
|
|
folders.push(user_path.join("AppData").join("Roaming").join("osu!"));
|
|
}
|
|
}
|
|
}
|
|
|
|
folders
|
|
}
|
|
|
|
fn collect_osu_folders(dir: &Path, depth: usize, max_depth: usize, out: &mut Vec<PathBuf>) {
|
|
if depth > max_depth {
|
|
return;
|
|
}
|
|
|
|
if dir.join("osu!.exe").is_file() {
|
|
out.push(dir.to_path_buf());
|
|
return;
|
|
}
|
|
|
|
let Ok(entries) = fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
|
continue;
|
|
};
|
|
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let name = path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_ascii_lowercase();
|
|
if matches!(
|
|
name.as_str(),
|
|
"windows" | "programdata" | "$recycle.bin" | "system volume information"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
collect_osu_folders(&path, depth + 1, max_depth, out);
|
|
}
|
|
}
|
|
|
|
fn find_drive_c_dirs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
|
if depth == 0 {
|
|
return;
|
|
}
|
|
|
|
let Ok(entries) = fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
|
continue;
|
|
};
|
|
|
|
if metadata.file_type().is_symlink() {
|
|
// Wineskin wrappers often add a convenience drive_c symlink at
|
|
// the app root. Resolve it to the real prefix so WINEPREFIX points
|
|
// at Contents/Resources instead of the bundle itself.
|
|
if path.file_name() == Some(OsStr::new("drive_c"))
|
|
&& let Ok(canonical) = fs::canonicalize(&path) {
|
|
out.push(canonical);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if !metadata.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
if path.file_name() == Some(OsStr::new("drive_c")) {
|
|
out.push(path);
|
|
continue;
|
|
}
|
|
|
|
// Do not descend into nested apps (for example Wineskin.app).
|
|
if path.extension() == Some(OsStr::new("app")) {
|
|
continue;
|
|
}
|
|
|
|
find_drive_c_dirs(&path, depth - 1, out);
|
|
}
|
|
}
|
|
|
|
fn prefix_for(osu_folder: &Path) -> Option<PathBuf> {
|
|
let drive_c = osu_folder
|
|
.ancestors()
|
|
.find(|ancestor| ancestor.file_name() == Some(OsStr::new("drive_c")))?;
|
|
canonical_prefix(drive_c)
|
|
}
|
|
|
|
fn canonical_prefix(drive_c: &Path) -> Option<PathBuf> {
|
|
let canonical = fs::canonicalize(drive_c).ok()?;
|
|
canonical.parent().map(Path::to_path_buf)
|
|
}
|
|
|
|
fn app_bundle_for(prefix: &Path) -> Option<PathBuf> {
|
|
prefix
|
|
.ancestors()
|
|
.find(|ancestor| ancestor.extension() == Some(OsStr::new("app")))
|
|
.map(Path::to_path_buf)
|
|
}
|
|
|
|
fn home_dir() -> Option<PathBuf> {
|
|
std::env::var_os("HOME").map(PathBuf::from)
|
|
}
|
|
|
|
fn app_bundles() -> Vec<PathBuf> {
|
|
let mut roots = vec![PathBuf::from("/Applications")];
|
|
if let Some(home) = home_dir() {
|
|
roots.push(home.join("Applications"));
|
|
}
|
|
|
|
let mut apps = Vec::new();
|
|
for root in roots {
|
|
let Ok(entries) = fs::read_dir(root) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension() == Some(OsStr::new("app")) {
|
|
apps.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
apps.sort();
|
|
apps
|
|
}
|
|
|
|
fn bottle_prefixes() -> Vec<PathBuf> {
|
|
let Some(home) = home_dir() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let bottle_roots = [
|
|
home.join("Library/Containers/com.isaacmarovitz.Whisky/Bottles"),
|
|
home.join("Library/Application Support/CrossOver/Bottles"),
|
|
home.join("Library/Application Support/Whisky/Bottles"),
|
|
];
|
|
|
|
let mut prefixes = Vec::new();
|
|
for root in bottle_roots {
|
|
let Ok(entries) = fs::read_dir(root) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.join("drive_c").is_dir() {
|
|
prefixes.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
prefixes.sort();
|
|
prefixes
|
|
}
|
|
|
|
fn find_wine_bin(prefix: &Path, app: Option<&Path>) -> Option<(PathBuf, Option<PathBuf>)> {
|
|
let mut candidates: Vec<(PathBuf, Option<PathBuf>)> = Vec::new();
|
|
|
|
if let Some(app) = app {
|
|
for root in [
|
|
app.join("Contents/SharedSupport/wine"),
|
|
app.join("Contents/Resources/WineskinEngine.bundle/Wine"),
|
|
app.join("Contents/Frameworks/wswine.bundle"),
|
|
app.join("Wineskin.app/Contents/Resources"),
|
|
] {
|
|
push_wine_root(&mut candidates, root);
|
|
}
|
|
|
|
let wineskin_res = app.join("Wineskin.app/Contents/Resources");
|
|
if has_wine_loader(&wineskin_res) {
|
|
candidates.push((wineskin_res.clone(), Some(wineskin_res)));
|
|
}
|
|
}
|
|
|
|
for root in [
|
|
prefix.join("WineskinEngine.bundle/Wine"),
|
|
prefix.join("Wine"),
|
|
] {
|
|
push_wine_root(&mut candidates, root);
|
|
}
|
|
|
|
if let Some(home) = home_dir() {
|
|
for root in [
|
|
home.join("Applications/Whisky.app/Contents/Resources/wine"),
|
|
home.join("Applications/Whisky.app/Contents/Libraries/Wine"),
|
|
] {
|
|
push_wine_root(&mut candidates, root);
|
|
}
|
|
}
|
|
|
|
for root in [
|
|
PathBuf::from("/Applications/Whisky.app/Contents/Resources/wine"),
|
|
PathBuf::from("/Applications/Whisky.app/Contents/Libraries/Wine"),
|
|
PathBuf::from("/Applications/CrossOver.app/Contents/SharedSupport/CrossOver"),
|
|
] {
|
|
push_wine_root(&mut candidates, root);
|
|
}
|
|
|
|
for bin in [
|
|
PathBuf::from("/opt/homebrew/bin"),
|
|
PathBuf::from("/usr/local/bin"),
|
|
] {
|
|
if has_wine_loader(&bin) {
|
|
candidates.push((bin, None));
|
|
}
|
|
}
|
|
|
|
for binary in ["wine64", "wine32on64", "wine"] {
|
|
if let Some(path) = find_in_path(binary)
|
|
&& let Some(bin) = path.parent() {
|
|
candidates.push((bin.to_path_buf(), None));
|
|
break;
|
|
}
|
|
}
|
|
|
|
candidates.into_iter().find(|(bin, _)| has_wine_loader(bin))
|
|
}
|
|
|
|
fn push_wine_root(candidates: &mut Vec<(PathBuf, Option<PathBuf>)>, root: PathBuf) {
|
|
let bin = root.join("bin");
|
|
if has_wine_loader(&bin) {
|
|
candidates.push((bin, Some(root)));
|
|
}
|
|
}
|
|
|
|
fn has_wine_loader(bin: &Path) -> bool {
|
|
["wine32on64", "wine64", "wine"]
|
|
.iter()
|
|
.any(|loader| bin.join(loader).is_file())
|
|
}
|
|
|
|
fn wine_version_for(bin: &Path) -> Option<String> {
|
|
let loader = ["wine64", "wine32on64", "wine"]
|
|
.iter()
|
|
.map(|name| bin.join(name))
|
|
.find(|path| path.is_file())?;
|
|
let output = std::process::Command::new(loader)
|
|
.arg("--version")
|
|
.env("WINEDEBUG", "-all")
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
(!version.is_empty()).then_some(version)
|
|
}
|
|
|
|
fn prefix_architecture(prefix: &Path) -> Option<String> {
|
|
if prefix.join("drive_c/windows/syswow64").is_dir() {
|
|
Some("win64".to_string())
|
|
} else if prefix.join("drive_c/windows/system32").is_dir() {
|
|
Some("win32".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn find_in_path(binary: &str) -> Option<PathBuf> {
|
|
let path = std::env::var_os("PATH")?;
|
|
std::env::split_paths(&path)
|
|
.map(|dir| dir.join(binary))
|
|
.find(|candidate| candidate.is_file())
|
|
}
|
|
|
|
fn pe_machine(exe: &Path) -> Option<u16> {
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
|
|
let mut file = fs::File::open(exe).ok()?;
|
|
let mut signature = [0u8; 2];
|
|
file.read_exact(&mut signature).ok()?;
|
|
if &signature != b"MZ" {
|
|
return None;
|
|
}
|
|
|
|
file.seek(SeekFrom::Start(0x3c)).ok()?;
|
|
let mut offset = [0u8; 4];
|
|
file.read_exact(&mut offset).ok()?;
|
|
file.seek(SeekFrom::Start(u32::from_le_bytes(offset) as u64))
|
|
.ok()?;
|
|
|
|
let mut header = [0u8; 6];
|
|
file.read_exact(&mut header).ok()?;
|
|
if &header[..4] != b"PE\0\0" {
|
|
return None;
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|