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:
1 parent
a2b03d3014
commit
e52399911c
19 files changed
+1362
-265
No files matched your search
Generated
+1
@@ -1272,6 +1272,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hardware-id",
|
"hardware-id",
|
||||||
"hex",
|
"hex",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ aes-gcm = "0.10.3"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
flate2 = "1.0"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
time = { version = "0.3", features = ["local-offset"] }
|
time = { version = "0.3", features = ["local-offset"] }
|
||||||
|
|||||||
@@ -742,7 +742,9 @@ pub async fn get_ezpp_launcher_update_files(
|
|||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
let data = fs::read(&file_path).await.map_err(|e| e.to_string())?;
|
let data = fs::read(&file_path).await.map_err(|e| e.to_string())?;
|
||||||
let hash = format!("{:x}", md5::compute(&data));
|
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());
|
files_to_download.push(file.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2911,3 +2918,29 @@ pub fn config_clear(state: State<AppState>) -> Result<(), ConfigError> {
|
|||||||
pub fn config_all(state: State<AppState>) -> Result<Vec<(String, Value)>, ConfigError> {
|
pub fn config_all(state: State<AppState>) -> Result<Vec<(String, Value)>, ConfigError> {
|
||||||
state.config.lock().unwrap().all()
|
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())?
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ use crate::{
|
|||||||
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||||
presence_connect, presence_disconnect, presence_is_connected, presence_update_activity,
|
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,
|
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,
|
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,
|
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
||||||
valid_osu_folder,
|
valid_osu_folder,
|
||||||
@@ -212,6 +212,7 @@ pub fn run() {
|
|||||||
log_frontend,
|
log_frontend,
|
||||||
set_logging_enabled,
|
set_logging_enabled,
|
||||||
open_logs_folder,
|
open_logs_folder,
|
||||||
|
reveal_window,
|
||||||
logging::list_log_bundles,
|
logging::list_log_bundles,
|
||||||
logging::read_log_bundle
|
logging::read_log_bundle
|
||||||
])
|
])
|
||||||
|
|||||||
+466
-58
@@ -54,6 +54,41 @@ pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), St
|
|||||||
Ok(())
|
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
|
/// Allow theme media to start without a click. WKWebView otherwise blocks
|
||||||
/// autoplay for anything with an audio track, which makes a theme's background
|
/// autoplay for anything with an audio track, which makes a theme's background
|
||||||
/// video sit frozen until the user interacts with the launcher.
|
/// 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 {
|
pub struct WineRuntime {
|
||||||
prefix: PathBuf,
|
prefix: PathBuf,
|
||||||
wine_bin: PathBuf,
|
wine_bin: PathBuf,
|
||||||
@@ -239,27 +275,50 @@ impl WineRuntime {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a Windows executable through the wrapper's Wine runtime.
|
/// Convert a host path inside this Wine prefix into a Windows path (e.g. `C:\osu!\osu!.exe`).
|
||||||
pub fn spawn(&self, exe: &Path, args: &[&str]) -> Result<Child, String> {
|
pub fn to_windows_path(&self, path: &Path) -> String {
|
||||||
let loader = self.loader_for(exe);
|
let path_str = path.to_string_lossy();
|
||||||
if !loader.is_file() {
|
if path_str.contains('\\')
|
||||||
return Err(format!(
|
|| (path_str.len() >= 2 && path_str.chars().nth(1) == Some(':'))
|
||||||
"Wine loader not found at {}. The wrapper may be incomplete.",
|
{
|
||||||
loader.display()
|
return path_str.into_owned();
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut command = Command::new(&loader);
|
let drive_c = self.prefix.join("drive_c");
|
||||||
command.arg(exe).args(args);
|
let canon_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||||
command.current_dir(exe.parent().unwrap_or(&self.prefix));
|
let canon_drive_c = fs::canonicalize(&drive_c).unwrap_or(drive_c);
|
||||||
command
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
|
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("WINEPREFIX", &self.prefix);
|
||||||
command.env("WINEDEBUG", "-all");
|
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");
|
command.env("FREETYPE_PROPERTIES", "truetype:interpreter-version=35");
|
||||||
|
|
||||||
let mut library_paths = Vec::new();
|
let mut library_paths = Vec::new();
|
||||||
@@ -333,6 +392,29 @@ impl WineRuntime {
|
|||||||
if let Ok(path) = std::env::join_paths(paths) {
|
if let Ok(path) = std::env::join_paths(paths) {
|
||||||
command.env("PATH", path);
|
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
|
let executable_name = exe
|
||||||
.file_name()
|
.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.
|
/// Stop every process that belongs to this Wine prefix.
|
||||||
pub async fn kill_prefix(&self) {
|
pub async fn kill_prefix(&self) {
|
||||||
let wineserver = self.wine_bin.join("wineserver");
|
let wineserver = self.wine_bin.join("wineserver");
|
||||||
@@ -357,19 +472,11 @@ impl WineRuntime {
|
|||||||
|
|
||||||
let mut cmd = Command::new(wineserver);
|
let mut cmd = Command::new(wineserver);
|
||||||
cmd.arg("-k")
|
cmd.arg("-k")
|
||||||
.env("WINEPREFIX", &self.prefix)
|
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null());
|
.stderr(Stdio::null());
|
||||||
|
|
||||||
if let Some(app) = &self.app_bundle {
|
self.apply_wine_environment(&mut cmd);
|
||||||
if let Ok(fallback) = std::env::join_paths([
|
|
||||||
app.join("Contents/Frameworks"),
|
|
||||||
app.join("Frameworks"),
|
|
||||||
]) {
|
|
||||||
cmd.env("DYLD_FALLBACK_LIBRARY_PATH", fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = cmd.status().await;
|
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(®_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::<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.
|
/// Run osu! (`-devserver ez-pp.farm`) and wait until the game exits.
|
||||||
pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> {
|
pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> {
|
||||||
let folder = Path::new(osu_folder);
|
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}");
|
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
|
||||||
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
|
let mut game = runtime
|
||||||
.spawn(&folder.join("osu!.exe"), &["-devserver", "ez-pp.farm"])
|
.spawn(&osu_exe, &["-devserver", "ez-pp.farm"])
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
log::error!(target: "osu", "Could not start osu!: {error}");
|
log::error!(target: "osu", "Could not start osu!: {error}");
|
||||||
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);
|
crate::logging::capture_osu_output(&mut game);
|
||||||
|
|
||||||
if patch {
|
if patch {
|
||||||
if let Err(error) = start_patcher(&runtime, folder).await {
|
let runtime_clone = runtime.clone();
|
||||||
log::warn!(target: "patcher", "Could not start the patcher: {error}");
|
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 {
|
match &wait_result {
|
||||||
Ok(status) => log::info!(target: "osu", "osu! exited with {status}"),
|
Ok(status) => log::info!(target: "osu", "osu! loader exited with {status}"),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!(target: "osu", "Could not wait for osu!: {error}");
|
log::warn!(target: "osu", "Could not wait for osu!: {error}");
|
||||||
return Err(format!("Failed to 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(())
|
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!(
|
log::info!(
|
||||||
target: "patcher",
|
target: "patcher",
|
||||||
@@ -464,38 +799,67 @@ async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), S
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait until the Wine-hosted osu! process exists, then give it time to create
|
/// Wait until osu! creates its game window (matching Windows and Linux launch logic).
|
||||||
/// its game window. Wine does not expose the window title to the launcher here,
|
/// Polls window titles and waits until a non-empty, non-updater window appears.
|
||||||
/// so the process is the best signal available.
|
async fn wait_for_osu_game() -> bool {
|
||||||
async fn wait_for_osu_game() {
|
let mut sys = sysinfo::System::new_all();
|
||||||
let mut system = sysinfo::System::new_all();
|
let deadline = Instant::now() + StdDuration::from_secs(60);
|
||||||
let deadline = Instant::now() + StdDuration::from_secs(30);
|
|
||||||
let mut detected = false;
|
|
||||||
|
|
||||||
while Instant::now() < deadline {
|
loop {
|
||||||
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
if Instant::now() > deadline {
|
||||||
if system
|
log::warn!(
|
||||||
.processes()
|
target: "patcher",
|
||||||
.values()
|
"Timed out waiting for osu! game window for patcher"
|
||||||
.any(crate::utils::is_osu_game_process)
|
);
|
||||||
{
|
return false;
|
||||||
detected = true;
|
}
|
||||||
break;
|
|
||||||
|
// 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;
|
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.
|
/// 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]]))
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+397
-2
@@ -353,8 +353,197 @@ pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn get_window_title_by_pid(_pid: Pid) -> String {
|
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
||||||
String::new()
|
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)]
|
#[cfg(windows)]
|
||||||
@@ -513,3 +702,209 @@ pub fn encrypt_password(password: &str, entropy: &str) -> Result<String, String>
|
|||||||
|
|
||||||
Ok(base64_string)
|
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<T>()`
|
||||||
|
/// 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<Native.ModuleEntry32>()`
|
||||||
|
/// 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<bool, String> {
|
||||||
|
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<PBI>
|
||||||
|
// [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<PBI> (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<ModuleEntry32>() -> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2238,7 +2238,7 @@
|
|||||||
element.width = 340;
|
element.width = 340;
|
||||||
element.height = 64;
|
element.height = 64;
|
||||||
} else if (widgetKind === 'launch-button') {
|
} else if (widgetKind === 'launch-button') {
|
||||||
element.width = 210;
|
element.width = 256;
|
||||||
element.height = 48;
|
element.height = 48;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2290,9 +2290,9 @@
|
|||||||
) as ThemeV2WidgetElement;
|
) as ThemeV2WidgetElement;
|
||||||
launchBtn.widget = 'launch-button';
|
launchBtn.widget = 'launch-button';
|
||||||
launchBtn.name = 'Launch Button';
|
launchBtn.name = 'Launch Button';
|
||||||
launchBtn.x = 740;
|
launchBtn.x = 720;
|
||||||
launchBtn.y = 575;
|
launchBtn.y = 575;
|
||||||
launchBtn.width = 210;
|
launchBtn.width = 256;
|
||||||
launchBtn.height = 48;
|
launchBtn.height = 48;
|
||||||
newElements.push(launchBtn);
|
newElements.push(launchBtn);
|
||||||
existing = launchBtn;
|
existing = launchBtn;
|
||||||
|
|||||||
@@ -2322,7 +2322,7 @@
|
|||||||
element.width = 140;
|
element.width = 140;
|
||||||
element.height = 42;
|
element.height = 42;
|
||||||
} else if (val === 'primary') {
|
} else if (val === 'primary') {
|
||||||
element.width = 210;
|
element.width = 256;
|
||||||
element.height = 48;
|
element.height = 48;
|
||||||
}
|
}
|
||||||
} else if (element.widget === 'header') {
|
} else if (element.widget === 'header') {
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
set = DEFAULT_THEME_ICON_SET,
|
set = DEFAULT_THEME_ICON_SET,
|
||||||
size = '1em',
|
size = '1em',
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
style = '',
|
||||||
}: {
|
}: {
|
||||||
name: ThemeIconName;
|
name: ThemeIconName;
|
||||||
set?: ThemeIconSet;
|
set?: ThemeIconSet;
|
||||||
size?: number | string;
|
size?: number | string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
style?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const icon = $derived(getThemeIconBody(name, set));
|
const icon = $derived(getThemeIconBody(name, set));
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
class={className}
|
class={className}
|
||||||
|
{style}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||||
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
|
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
|
import DownloadButton from '$lib/components/ui/download-button/DownloadButton.svelte';
|
||||||
import { currentUserInfo } from '$lib/data';
|
import { currentUserInfo } from '$lib/data';
|
||||||
import { launching as launchingStore } from '$lib/global';
|
import { launching as launchingStore } from '$lib/global';
|
||||||
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
launching = undefined,
|
launching = undefined,
|
||||||
launchProgress = -1,
|
launchProgress = -1,
|
||||||
launchText = 'Launch',
|
launchText = 'Launch',
|
||||||
|
launchSubtext = undefined,
|
||||||
launchDisabled = false,
|
launchDisabled = false,
|
||||||
oninteraction,
|
oninteraction,
|
||||||
onselect,
|
onselect,
|
||||||
@@ -103,6 +105,7 @@
|
|||||||
launching?: boolean;
|
launching?: boolean;
|
||||||
launchProgress?: number;
|
launchProgress?: number;
|
||||||
launchText?: string;
|
launchText?: string;
|
||||||
|
launchSubtext?: string;
|
||||||
launchDisabled?: boolean;
|
launchDisabled?: boolean;
|
||||||
oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void;
|
oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void;
|
||||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||||
@@ -1613,14 +1616,25 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if isActionLaunch && isLaunching}
|
{#if isActionLaunch && isLaunching}
|
||||||
<ThemeIcon name="loader" set={iconSet} size="1.1em" class="animate-spin" />
|
{#if launchProgress >= 0}
|
||||||
|
<span class="text-[11px] font-bold">{Math.round(launchProgress)}%</span>
|
||||||
|
{:else}
|
||||||
|
<ThemeIcon name="loader" set={iconSet} size="1.1em" class="animate-spin" />
|
||||||
|
{/if}
|
||||||
{:else if element.icon}
|
{:else if element.icon}
|
||||||
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
|
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if isActionLaunch && isLaunching}
|
{#if isActionLaunch && isLaunching}
|
||||||
<span class="truncate">
|
<div class="flex flex-col items-center justify-center min-w-0">
|
||||||
{launchProgress > 0 ? `${launchText} (${Math.round(launchProgress)}%)` : launchText}
|
<span class="truncate">
|
||||||
</span>
|
{launchProgress >= 0
|
||||||
|
? `${launchText} (${Math.round(launchProgress)}%)`
|
||||||
|
: launchText}
|
||||||
|
</span>
|
||||||
|
{#if launchSubtext}
|
||||||
|
<span class="text-[10px] opacity-75 truncate max-w-[140px]">{launchSubtext}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if isMorphEnabled(element)}
|
{:else if isMorphEnabled(element)}
|
||||||
<TextMorph
|
<TextMorph
|
||||||
text={String(interpolateThemeText(element.label, variables) ?? '')}
|
text={String(interpolateThemeText(element.label, variables) ?? '')}
|
||||||
@@ -1852,7 +1866,7 @@
|
|||||||
)}
|
)}
|
||||||
{@const btnCircumference = 2 * Math.PI * btnRadius}
|
{@const btnCircumference = 2 * Math.PI * btnRadius}
|
||||||
{@const strokeOffset =
|
{@const strokeOffset =
|
||||||
launchProgress === -1
|
launchProgress < 0
|
||||||
? btnCircumference * 0.7
|
? btnCircumference * 0.7
|
||||||
: btnCircumference -
|
: btnCircumference -
|
||||||
(Math.max(0, Math.min(100, launchProgress)) / 100) * btnCircumference}
|
(Math.max(0, Math.min(100, launchProgress)) / 100) * btnCircumference}
|
||||||
@@ -1870,12 +1884,16 @@
|
|||||||
onbuttonaction?.({ type: 'launch' });
|
onbuttonaction?.({ type: 'launch' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={isLaunching ? launchText : 'Launch osu!'}
|
title={isLaunching
|
||||||
|
? launchSubtext
|
||||||
|
? `${launchText} - ${launchSubtext}`
|
||||||
|
: launchText
|
||||||
|
: 'Launch osu!'}
|
||||||
>
|
>
|
||||||
{#if isLaunching}
|
{#if isLaunching}
|
||||||
<!-- Rotating progress ring SVG -->
|
<!-- Rotating progress ring SVG -->
|
||||||
<svg
|
<svg
|
||||||
class="absolute inset-0 h-full w-full {launchProgress === -1
|
class="absolute inset-0 h-full w-full {launchProgress < 0
|
||||||
? 'animate-spin'
|
? 'animate-spin'
|
||||||
: '-rotate-90'}"
|
: '-rotate-90'}"
|
||||||
viewBox="0 0 {element.width} {element.height}"
|
viewBox="0 0 {element.width} {element.height}"
|
||||||
@@ -1902,7 +1920,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{#if launchProgress > 0}
|
{#if launchProgress >= 0}
|
||||||
<span
|
<span
|
||||||
class="relative z-10 text-[11px] font-black tracking-tight text-primary-foreground"
|
class="relative z-10 text-[11px] font-black tracking-tight text-primary-foreground"
|
||||||
>
|
>
|
||||||
@@ -1910,9 +1928,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
name="loader"
|
name="ellipsis"
|
||||||
set={iconSet}
|
set={iconSet}
|
||||||
class="relative z-10 size-6 animate-spin text-primary-foreground"
|
class="relative z-10 size-4 animate-pulse text-primary-foreground"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1934,61 +1952,53 @@
|
|||||||
onbuttonaction?.({ type: 'launch' });
|
onbuttonaction?.({ type: 'launch' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={isLaunching ? launchText : 'Launch osu!'}
|
title={isLaunching
|
||||||
|
? launchSubtext
|
||||||
|
? `${launchText} - ${launchSubtext}`
|
||||||
|
: launchText
|
||||||
|
: 'Launch osu!'}
|
||||||
>
|
>
|
||||||
{#if isLaunching}
|
{#if isLaunching}
|
||||||
<ThemeIcon
|
{#if launchProgress >= 0}
|
||||||
name="loader"
|
<span class="text-[11px] font-black">{Math.round(launchProgress)}%</span>
|
||||||
set={iconSet}
|
{:else}
|
||||||
class="size-3.5 animate-spin fill-current"
|
<ThemeIcon
|
||||||
/>
|
name="loader"
|
||||||
<span>{launchProgress > 0 ? `${Math.round(launchProgress)}%` : launchText}</span
|
set={iconSet}
|
||||||
>
|
class="size-3.5 animate-spin fill-current"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col items-center justify-center min-w-0">
|
||||||
|
<span class="truncate">{launchText}</span>
|
||||||
|
{#if launchSubtext}
|
||||||
|
<span class="text-[9px] opacity-75 truncate max-w-[140px]"
|
||||||
|
>{launchSubtext}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ThemeIcon name="launch" set={iconSet} class="size-3.5 fill-current" />
|
<ThemeIcon name="launch" set={iconSet} class="size-3.5 fill-current" />
|
||||||
<span>Launch</span>
|
<span>Launch</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<div class="flex h-full w-full items-center justify-center">
|
||||||
type="button"
|
<DownloadButton
|
||||||
class="relative flex h-full w-full items-center justify-center gap-2.5 rounded-full bg-primary px-5 font-bold text-primary-foreground shadow-lg shadow-primary/30 transition-all {isLaunching
|
downloading={isLaunching}
|
||||||
? 'cursor-wait brightness-95'
|
progress={launchProgress}
|
||||||
: launchDisabled
|
text={isLaunching ? launchText : 'Launch'}
|
||||||
? 'cursor-not-allowed opacity-60'
|
subtext={launchSubtext}
|
||||||
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]'}"
|
disabled={launchDisabled}
|
||||||
disabled={launchDisabled || isLaunching}
|
{iconSet}
|
||||||
onclick={(e) => {
|
class="h-full w-full justify-center"
|
||||||
e.stopPropagation();
|
onClick={() => {
|
||||||
if (!launchDisabled && !isLaunching) {
|
if (interactive) return;
|
||||||
onbuttonaction?.({ type: 'launch' });
|
if (!launchDisabled && !isLaunching) {
|
||||||
}
|
onbuttonaction?.({ type: 'launch' });
|
||||||
}}
|
}
|
||||||
title={isLaunching ? launchText : 'Launch osu!'}
|
}}
|
||||||
>
|
/>
|
||||||
<div class="flex size-7 items-center justify-center rounded-full bg-black/20">
|
</div>
|
||||||
{#if isLaunching}
|
|
||||||
<ThemeIcon
|
|
||||||
name="loader"
|
|
||||||
set={iconSet}
|
|
||||||
class="size-3.5 animate-spin text-primary-foreground"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<ThemeIcon
|
|
||||||
name="launch"
|
|
||||||
set={iconSet}
|
|
||||||
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-bold tracking-wide">
|
|
||||||
{isLaunching
|
|
||||||
? launchProgress > 0
|
|
||||||
? `${launchText} (${Math.round(launchProgress)}%)`
|
|
||||||
: launchText
|
|
||||||
: 'Launch'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if element.widget === 'player-card'}
|
{:else if element.widget === 'player-card'}
|
||||||
{@const playerStats =
|
{@const playerStats =
|
||||||
|
|||||||
@@ -9,23 +9,60 @@
|
|||||||
subtext?: string;
|
subtext?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
iconSet?: ThemeIconSet;
|
iconSet?: ThemeIconSet;
|
||||||
|
class?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
} = $props();
|
} = $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 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
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="group flex items-center h-12 rounded-full bg-primary disabled:bg-primary/70 shadow-lg shadow-primary/25 transition-all not-disabled:hover:shadow-primary/40 not-disabled:hover:brightness-110 not-disabled:active:scale-[0.98] cursor-pointer w-fit"
|
bind:clientHeight={buttonHeight}
|
||||||
|
bind:clientWidth={buttonWidth}
|
||||||
|
class="group flex items-center rounded-full bg-primary disabled:bg-primary/70 shadow-lg shadow-primary/25 transition-all not-disabled:hover:shadow-primary/40 not-disabled:hover:brightness-110 not-disabled:active:scale-[0.98] cursor-pointer {prop.class ??
|
||||||
|
'h-12 w-fit'}"
|
||||||
disabled={prop.disabled}
|
disabled={prop.disabled}
|
||||||
onclick={prop.onClick}
|
onclick={prop.onClick}
|
||||||
>
|
>
|
||||||
<div class="relative size-12 shrink-0 flex items-center justify-center">
|
<div class="relative h-full aspect-square shrink-0 flex items-center justify-center">
|
||||||
{#if prop.downloading}
|
{#if prop.downloading}
|
||||||
{#if prop.progress === -1}
|
{#if prop.progress === -1}
|
||||||
<svg class="absolute inset-0 animate-spin" viewBox="0 0 48 48">
|
<svg
|
||||||
|
class="absolute inset-0 h-full w-full animate-spin pointer-events-none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
<circle class="stroke-black/10" cx="24" cy="24" r={radius} fill="none" stroke-width="3" />
|
<circle class="stroke-black/10" cx="24" cy="24" r={radius} fill="none" stroke-width="3" />
|
||||||
<circle
|
<circle
|
||||||
class="stroke-pink-950"
|
class="stroke-pink-950"
|
||||||
@@ -40,7 +77,10 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 48 48">
|
<svg
|
||||||
|
class="absolute inset-0 h-full w-full -rotate-90 pointer-events-none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
<circle
|
<circle
|
||||||
cx="24"
|
cx="24"
|
||||||
cy="24"
|
cy="24"
|
||||||
@@ -64,35 +104,56 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="size-9 rounded-full bg-black/30 flex items-center justify-center relative z-10">
|
<div
|
||||||
|
class="aspect-square rounded-full bg-black/30 flex items-center justify-center relative z-10 max-h-[72%] max-w-[72%]"
|
||||||
|
style="width: {circleSize}px; height: {circleSize}px;"
|
||||||
|
>
|
||||||
{#if prop.downloading}
|
{#if prop.downloading}
|
||||||
{#if prop.progress === -1}
|
{#if prop.progress === -1}
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
name="ellipsis"
|
name="ellipsis"
|
||||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||||
class="size-4 text-primary-foreground fill-current animate-pulse"
|
size={iconSize}
|
||||||
|
class="text-primary-foreground fill-current animate-pulse max-w-[50%] max-h-[50%]"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-[10px] font-bold text-primary-foreground">
|
<span
|
||||||
{prop.progress.toFixed()}
|
class="font-bold text-primary-foreground leading-none"
|
||||||
|
style="font-size: {percentFontSize}px;"
|
||||||
|
>
|
||||||
|
{prop.progress.toFixed()}%
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
name="launch"
|
name="launch"
|
||||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||||
class="size-4 text-primary-foreground fill-current ml-0.5"
|
size={iconSize}
|
||||||
|
class="text-primary-foreground fill-current max-w-[50%] max-h-[50%]"
|
||||||
|
style="margin-left: {iconNudge}px;"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pr-6 pl-1 text-center w-52">
|
<div
|
||||||
<p class="text-sm font-bold text-primary-foreground leading-tight">
|
class="text-center flex-1 min-w-0 max-w-full {prop.class ? '' : 'w-48'}"
|
||||||
|
style="padding-right: {Math.max(8, Math.round(baseDim * 0.25))}px; padding-left: {Math.max(
|
||||||
|
4,
|
||||||
|
Math.round(baseDim * 0.08)
|
||||||
|
)}px;"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="font-bold text-primary-foreground leading-tight truncate"
|
||||||
|
style="font-size: {labelFontSize}px;"
|
||||||
|
>
|
||||||
{prop.text}
|
{prop.text}
|
||||||
</p>
|
</p>
|
||||||
{#if prop.subtext}
|
{#if prop.subtext}
|
||||||
<p class="text-[10px] text-primary-foreground/70 leading-tight">
|
<p
|
||||||
|
class="text-primary-foreground/70 leading-tight truncate"
|
||||||
|
style="font-size: {subtextFontSize}px;"
|
||||||
|
>
|
||||||
{prop.subtext}
|
{prop.subtext}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,21 +11,34 @@
|
|||||||
const SPEED_THRESHOLD = 9;
|
const SPEED_THRESHOLD = 9;
|
||||||
const REQUIRED_CONSECUTIVE = 3;
|
const REQUIRED_CONSECUTIVE = 3;
|
||||||
|
|
||||||
let mouseX = $state(0);
|
let currentX = 0;
|
||||||
let mouseY = $state(0);
|
let currentY = 0;
|
||||||
|
let targetX = 0;
|
||||||
|
let targetY = 0;
|
||||||
|
let prevAppliedX = -1;
|
||||||
|
let prevAppliedY = -1;
|
||||||
|
let hasMoved = false;
|
||||||
|
|
||||||
let lastMouseX = 0;
|
let lastMouseX = 0;
|
||||||
let lastMouseY = 0;
|
let lastMouseY = 0;
|
||||||
let isMouseDown = $state(false);
|
let isMouseDown = false;
|
||||||
let isHoveringInteractive = $state(false);
|
let isHoveringInteractive = false;
|
||||||
let dragStartX = 0;
|
let dragStartX = 0;
|
||||||
let dragStartY = 0;
|
let dragStartY = 0;
|
||||||
let degrees = $state(0);
|
let targetDegrees = 0;
|
||||||
let applyRotation = $state(false);
|
let currentDegrees = 0;
|
||||||
|
let applyRotation = false;
|
||||||
|
|
||||||
let lastVelocityTime = 0;
|
let lastVelocityTime = 0;
|
||||||
let consecutiveHighSpeed = 0;
|
let consecutiveHighSpeed = 0;
|
||||||
let velocityAccumX = 0;
|
let velocityAccumX = 0;
|
||||||
let velocityAccumY = 0;
|
let velocityAccumY = 0;
|
||||||
|
let rotateSpringAnim: ReturnType<typeof animate> | null = null;
|
||||||
|
|
||||||
|
let cursor: HTMLElement;
|
||||||
|
let cursorRotateWrapper: HTMLDivElement;
|
||||||
|
let cursorInner: HTMLImageElement;
|
||||||
|
let cursorAdditive: HTMLImageElement;
|
||||||
|
|
||||||
function isInteractive(el: Element | null): boolean {
|
function isInteractive(el: Element | null): boolean {
|
||||||
while (el) {
|
while (el) {
|
||||||
@@ -44,15 +57,36 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dampContinuously(current: number, target: number, halfLife: number, dt: number): number {
|
||||||
|
if (halfLife <= 0 || dt <= 0) return target;
|
||||||
|
return target + (current - target) * Math.pow(0.5, dt / halfLife);
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
mouseX = e.clientX;
|
targetX = e.clientX;
|
||||||
mouseY = e.clientY;
|
targetY = e.clientY;
|
||||||
|
|
||||||
const deltaX = e.pageX - window.pageXOffset - dragStartX;
|
if (!hasMoved) {
|
||||||
const deltaY = e.pageY - window.pageYOffset - dragStartY;
|
currentX = targetX;
|
||||||
|
currentY = targetY;
|
||||||
|
prevAppliedX = targetX;
|
||||||
|
prevAppliedY = targetY;
|
||||||
|
lastMouseX = targetX;
|
||||||
|
lastMouseY = targetY;
|
||||||
|
hasMoved = true;
|
||||||
|
if (cursor) {
|
||||||
|
cursor.style.opacity = '1';
|
||||||
|
cursor.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
velocityAccumX += mouseX - lastMouseX;
|
if (cursor) {
|
||||||
velocityAccumY += mouseY - lastMouseY;
|
cursor.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
velocityAccumX += targetX - lastMouseX;
|
||||||
|
velocityAccumY += targetY - lastMouseY;
|
||||||
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const dt = now - lastVelocityTime;
|
const dt = now - lastVelocityTime;
|
||||||
@@ -74,48 +108,33 @@
|
|||||||
lastVelocityTime = now;
|
lastVelocityTime = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newDegrees = (Math.atan2(-deltaX, deltaY) * 180) / Math.PI + 24.3;
|
|
||||||
|
|
||||||
let diff = (newDegrees - degrees) % 360;
|
|
||||||
if (diff < -180) diff += 360;
|
|
||||||
if (diff > 180) diff -= 360;
|
|
||||||
|
|
||||||
if (isMouseDown && applyRotation) {
|
if (isMouseDown && applyRotation) {
|
||||||
degrees += diff;
|
const deltaX = targetX - dragStartX;
|
||||||
} else {
|
const deltaY = targetY - dragStartY;
|
||||||
degrees = 0;
|
const newDegrees = (Math.atan2(-deltaX, deltaY) * 180) / Math.PI + 24.3;
|
||||||
|
|
||||||
|
let diff = (newDegrees - targetDegrees) % 360;
|
||||||
|
if (diff < -180) diff += 360;
|
||||||
|
if (diff > 180) diff -= 360;
|
||||||
|
|
||||||
|
targetDegrees += diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = document.elementFromPoint(mouseX, mouseY);
|
const el = document.elementFromPoint(targetX, targetY);
|
||||||
isHoveringInteractive = isInteractive(el);
|
const hovering = isInteractive(el);
|
||||||
|
if (hovering !== isHoveringInteractive) {
|
||||||
if (!isMouseDown) {
|
isHoveringInteractive = hovering;
|
||||||
animate(cursorAdditive, {
|
if (!isMouseDown) {
|
||||||
opacity: isHoveringInteractive ? 1 : 0,
|
animate(cursorAdditive, {
|
||||||
duration: 800,
|
opacity: isHoveringInteractive ? 1 : 0,
|
||||||
ease: (t: number) => (t - 1) ** 5 + 1,
|
duration: 800,
|
||||||
});
|
ease: (t: number) => (t - 1) ** 5 + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
animate(cursor, {
|
lastMouseX = targetX;
|
||||||
duration: smoothCursor ? $cursorSmoothness : 0,
|
lastMouseY = targetY;
|
||||||
translateX: mouseX,
|
|
||||||
translateY: mouseY,
|
|
||||||
ease: (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
||||||
});
|
|
||||||
|
|
||||||
animate(cursor, {
|
|
||||||
duration: isMouseDown ? 200 : 1500,
|
|
||||||
rotate: degrees,
|
|
||||||
transformOrigin: '0px 0px 0',
|
|
||||||
ease: (t: number) =>
|
|
||||||
isMouseDown
|
|
||||||
? (t - 1) ** 7 + 1
|
|
||||||
: Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
|
||||||
});
|
|
||||||
|
|
||||||
lastMouseX = mouseX;
|
|
||||||
lastMouseY = mouseY;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent) => {
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
@@ -128,6 +147,12 @@
|
|||||||
lastVelocityTime = performance.now();
|
lastVelocityTime = performance.now();
|
||||||
|
|
||||||
isMouseDown = true;
|
isMouseDown = true;
|
||||||
|
|
||||||
|
if (rotateSpringAnim) {
|
||||||
|
rotateSpringAnim.pause();
|
||||||
|
rotateSpringAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
animate(cursorInner, {
|
animate(cursorInner, {
|
||||||
scale: 0.9,
|
scale: 0.9,
|
||||||
duration: 800,
|
duration: 800,
|
||||||
@@ -143,6 +168,7 @@
|
|||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
isMouseDown = false;
|
isMouseDown = false;
|
||||||
|
const hadRotation = applyRotation || Math.abs(currentDegrees) > 0.1;
|
||||||
applyRotation = false;
|
applyRotation = false;
|
||||||
consecutiveHighSpeed = 0;
|
consecutiveHighSpeed = 0;
|
||||||
velocityAccumX = 0;
|
velocityAccumX = 0;
|
||||||
@@ -154,21 +180,86 @@
|
|||||||
ease: (t: number) => Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
ease: (t: number) => Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
||||||
});
|
});
|
||||||
animate(cursorAdditive, {
|
animate(cursorAdditive, {
|
||||||
opacity: 0,
|
opacity: isHoveringInteractive ? 1 : 0,
|
||||||
duration: 500,
|
duration: 500,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
ease: (t: number) => (t - 1) ** 5 + 1,
|
ease: (t: number) => (t - 1) ** 5 + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hadRotation && cursorRotateWrapper) {
|
||||||
|
rotateSpringAnim = animate(cursorRotateWrapper, {
|
||||||
|
rotate: 0,
|
||||||
|
duration: 1200,
|
||||||
|
ease: (t: number) => Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
||||||
|
onComplete: () => {
|
||||||
|
currentDegrees = 0;
|
||||||
|
targetDegrees = 0;
|
||||||
|
rotateSpringAnim = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let cursor: HTMLElement;
|
const handleMouseLeave = () => {
|
||||||
let cursorInner: HTMLDivElement;
|
if (cursor) cursor.style.opacity = '0';
|
||||||
let cursorAdditive: HTMLImageElement;
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (cursor && hasMoved) cursor.style.opacity = '1';
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.documentElement.classList.add('hiddenCursor');
|
document.documentElement.classList.add('hiddenCursor');
|
||||||
|
document.addEventListener('mouseleave', handleMouseLeave);
|
||||||
|
document.addEventListener('mouseenter', handleMouseEnter);
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
let lastFrameTime = performance.now();
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const dt = Math.min(Math.max(now - lastFrameTime, 0), 64);
|
||||||
|
lastFrameTime = now;
|
||||||
|
|
||||||
|
if (hasMoved && cursor) {
|
||||||
|
if (!smoothCursor) {
|
||||||
|
currentX = targetX;
|
||||||
|
currentY = targetY;
|
||||||
|
} else {
|
||||||
|
// Continuous exponential damping half-life in ms (mathematically invariant across refresh rates).
|
||||||
|
const halfLife = $cursorSmoothness > 50 ? 14 : Math.max(1, $cursorSmoothness || 14);
|
||||||
|
currentX = dampContinuously(currentX, targetX, halfLife, dt);
|
||||||
|
currentY = dampContinuously(currentY, targetY, halfLife, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(targetX - currentX) < 0.05) currentX = targetX;
|
||||||
|
if (Math.abs(targetY - currentY) < 0.05) currentY = targetY;
|
||||||
|
|
||||||
|
if (currentX !== prevAppliedX || currentY !== prevAppliedY) {
|
||||||
|
cursor.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;
|
||||||
|
prevAppliedX = currentX;
|
||||||
|
prevAppliedY = currentY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMouseDown && applyRotation && cursorRotateWrapper) {
|
||||||
|
const rotFactor = 1 - Math.pow(0.5, dt / 25);
|
||||||
|
currentDegrees += (targetDegrees - currentDegrees) * rotFactor;
|
||||||
|
cursorRotateWrapper.style.transform = `rotate(${currentDegrees}deg)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(tick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.documentElement.classList.remove('hiddenCursor');
|
document.documentElement.classList.remove('hiddenCursor');
|
||||||
|
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
document.removeEventListener('mouseenter', handleMouseEnter);
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
if (rotateSpringAnim) {
|
||||||
|
rotateSpringAnim.pause();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -179,8 +270,8 @@
|
|||||||
onmouseup={handleMouseUp}
|
onmouseup={handleMouseUp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="h-7 w-7 fixed pointer-events-none z-[9999]" bind:this={cursor}>
|
<div class="h-7 w-7 fixed top-0 left-0 pointer-events-none z-[9999] opacity-0" bind:this={cursor}>
|
||||||
<div class="relative">
|
<div class="relative origin-top-left" bind:this={cursorRotateWrapper}>
|
||||||
<img class="absolute top-0 left-0" src={cursor_default} bind:this={cursorInner} alt="cursor" />
|
<img class="absolute top-0 left-0" src={cursor_default} bind:this={cursorInner} alt="cursor" />
|
||||||
<img
|
<img
|
||||||
class="absolute top-0 left-0 opacity-0"
|
class="absolute top-0 left-0 opacity-0"
|
||||||
|
|||||||
+33
-12
@@ -197,7 +197,7 @@ const armAutoplayRetry = (node: HTMLMediaElement) => {
|
|||||||
document.addEventListener('keydown', retry, true);
|
document.addEventListener('keydown', retry, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const playThemeMedia = (node: HTMLMediaElement) => {
|
export const playThemeMedia = (node: HTMLMediaElement) => {
|
||||||
void node
|
void node
|
||||||
.play()
|
.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -498,10 +498,10 @@ export function themeMediaSuspension(node: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause background videos while the launcher is hidden or unfocused, then
|
* Manage background video playback across document visibility changes.
|
||||||
* resume only the videos this helper paused itself. This keeps muted theme
|
* Videos continue playing when the launcher window loses focus. Muted background
|
||||||
* videos from decoding in the background and prevents surprise audio when the
|
* videos pause only when the document is hidden/minimized to save GPU decoding
|
||||||
* window comes back.
|
* cycles without interrupting theme music or audio.
|
||||||
*/
|
*/
|
||||||
export function themeVideoLifecycle(node: HTMLVideoElement) {
|
export function themeVideoLifecycle(node: HTMLVideoElement) {
|
||||||
let pausedByLifecycle = false;
|
let pausedByLifecycle = false;
|
||||||
@@ -513,26 +513,29 @@ export function themeVideoLifecycle(node: HTMLVideoElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resume = () => {
|
const resume = () => {
|
||||||
if (!pausedByLifecycle) return;
|
if (!pausedByLifecycle && !node.autoplay && !node.loop) return;
|
||||||
pausedByLifecycle = false;
|
pausedByLifecycle = false;
|
||||||
playThemeMedia(node);
|
playThemeMedia(node);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
if (document.visibilityState === 'visible' && document.hasFocus()) resume();
|
if (!node.isConnected) {
|
||||||
else pause();
|
document.removeEventListener('visibilitychange', sync);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
resume();
|
||||||
|
} else if (node.muted || node.volume === 0) {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', sync);
|
document.addEventListener('visibilitychange', sync);
|
||||||
window.addEventListener('focus', sync);
|
|
||||||
window.addEventListener('blur', sync);
|
|
||||||
sync();
|
sync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
document.removeEventListener('visibilitychange', sync);
|
document.removeEventListener('visibilitychange', sync);
|
||||||
window.removeEventListener('focus', sync);
|
|
||||||
window.removeEventListener('blur', sync);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -564,8 +567,14 @@ export function themeMediaSource(node: HTMLMediaElement, source: string) {
|
|||||||
*/
|
*/
|
||||||
export function smoothThemeMedia(node: HTMLElement) {
|
export function smoothThemeMedia(node: HTMLElement) {
|
||||||
const lastSource = new WeakMap<HTMLMediaElement, string>();
|
const lastSource = new WeakMap<HTMLMediaElement, string>();
|
||||||
|
const videoLifecycles = new WeakMap<HTMLVideoElement, () => void>();
|
||||||
|
|
||||||
const prepare = (media: HTMLMediaElement) => {
|
const prepare = (media: HTMLMediaElement) => {
|
||||||
|
if (media instanceof HTMLVideoElement && !videoLifecycles.has(media)) {
|
||||||
|
const lifecycle = themeVideoLifecycle(media);
|
||||||
|
videoLifecycles.set(media, () => lifecycle.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
const source =
|
const source =
|
||||||
media.getAttribute('src') ??
|
media.getAttribute('src') ??
|
||||||
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
|
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
|
||||||
@@ -595,6 +604,18 @@ export function smoothThemeMedia(node: HTMLElement) {
|
|||||||
for (const added of mutation.addedNodes) {
|
for (const added of mutation.addedNodes) {
|
||||||
if (added instanceof HTMLElement) scan(added);
|
if (added instanceof HTMLElement) scan(added);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const removed of mutation.removedNodes) {
|
||||||
|
if (removed instanceof HTMLVideoElement) {
|
||||||
|
videoLifecycles.get(removed)?.();
|
||||||
|
videoLifecycles.delete(removed);
|
||||||
|
} else if (removed instanceof HTMLElement) {
|
||||||
|
removed.querySelectorAll?.<HTMLVideoElement>('video').forEach((video) => {
|
||||||
|
videoLifecycles.get(video)?.();
|
||||||
|
videoLifecycles.delete(video);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1296,8 +1296,9 @@ const drawWidgetElement = async (context: PreviewContext, element: ThemeV2Widget
|
|||||||
fillRoundedRect(ctx, element.x, element.y, element.width, element.height, radius, accent);
|
fillRoundedRect(ctx, element.x, element.y, element.width, element.height, radius, accent);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
const circleR = Math.max(12, element.height * 0.32);
|
const baseDim = Math.min(element.width, element.height);
|
||||||
const circleX = element.x + circleR + 10;
|
const circleR = Math.round(baseDim * 0.35);
|
||||||
|
const circleX = element.x + baseDim / 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(circleX, centerY, circleR, 0, Math.PI * 2);
|
ctx.arc(circleX, centerY, circleR, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
|
||||||
@@ -1307,7 +1308,12 @@ const drawWidgetElement = async (context: PreviewContext, element: ThemeV2Widget
|
|||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = accentText;
|
ctx.fillStyle = accentText;
|
||||||
ctx.font = canvasFont(context, bodyFont, Math.min(14, element.height * 0.33), 700);
|
const availableTextW = Math.max(50, element.width - baseDim);
|
||||||
|
const textFontSize = Math.max(
|
||||||
|
10,
|
||||||
|
Math.min(Math.round(baseDim * 0.32), Math.round((availableTextW * 0.85) / 6))
|
||||||
|
);
|
||||||
|
ctx.font = canvasFont(context, bodyFont, textFontSize, 700);
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
const textCenterX = circleX + circleR + (element.x + element.width - (circleX + circleR)) / 2;
|
const textCenterX = circleX + circleR + (element.x + element.width - (circleX + circleR)) / 2;
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
|
|||||||
...base,
|
...base,
|
||||||
type,
|
type,
|
||||||
widget: 'launch-button',
|
widget: 'launch-button',
|
||||||
width: 220,
|
width: 256,
|
||||||
height: 60,
|
height: 48,
|
||||||
},
|
},
|
||||||
index
|
index
|
||||||
)! as ThemeV2WidgetElement;
|
)! as ThemeV2WidgetElement;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { writable } from 'svelte/store';
|
|||||||
|
|
||||||
export const customCursor = writable<boolean>(false);
|
export const customCursor = writable<boolean>(false);
|
||||||
export const cursorSmoothening = writable<boolean>(false);
|
export const cursorSmoothening = writable<boolean>(false);
|
||||||
export const cursorSmoothness = writable<number>(180);
|
export const cursorSmoothness = writable<number>(14);
|
||||||
export const reduceAnimations = writable<boolean>(false);
|
export const reduceAnimations = writable<boolean>(false);
|
||||||
export const patch = writable<boolean>(true);
|
export const patch = writable<boolean>(true);
|
||||||
export const loggingEnabled = writable<boolean>(true);
|
export const loggingEnabled = writable<boolean>(true);
|
||||||
|
|||||||
+92
-25
@@ -1,4 +1,5 @@
|
|||||||
import { custom_theme_audio_playing } from './global';
|
import { custom_theme_audio_playing } from './global';
|
||||||
|
import { playThemeMedia } from './themeMedia';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { type ClassValue, clsx } from 'clsx';
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import * as crypto from 'crypto-js';
|
import * as crypto from 'crypto-js';
|
||||||
@@ -149,50 +150,116 @@ export function setGlobalVolume(volume: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeFadeCancel: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
export function fadeGlobalVolume(
|
export function fadeGlobalVolume(
|
||||||
volumeFrom: number,
|
volumeFrom: number,
|
||||||
volumeTo: number,
|
volumeTo: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
onDone?: () => void
|
onDone?: () => void
|
||||||
) {
|
): Promise<void> {
|
||||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
if (activeFadeCancel) {
|
||||||
|
activeFadeCancel();
|
||||||
const startTime = performance.now();
|
activeFadeCancel = undefined;
|
||||||
|
|
||||||
function animate() {
|
|
||||||
const currentTime = performance.now();
|
|
||||||
const elapsedTime = currentTime - startTime;
|
|
||||||
const progress = Math.min(elapsedTime / duration, 1);
|
|
||||||
|
|
||||||
const interpolatedVolume = volumeFrom + (volumeTo - volumeFrom) * progress;
|
|
||||||
|
|
||||||
mediaElements.forEach((element) => {
|
|
||||||
element.volume = interpolatedVolume;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
if (onDone) onDone();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
animate();
|
return new Promise((resolve) => {
|
||||||
|
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||||
|
if (mediaElements.length === 0 || duration <= 0) {
|
||||||
|
if (mediaElements.length > 0) {
|
||||||
|
mediaElements.forEach((element) => {
|
||||||
|
element.volume = Math.min(1, Math.max(0, volumeTo));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onDone) onDone();
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
activeFadeCancel = () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
if (cancelled) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = performance.now();
|
||||||
|
const elapsedTime = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsedTime / duration, 1);
|
||||||
|
|
||||||
|
const interpolatedVolume = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, volumeFrom + (volumeTo - volumeFrom) * progress)
|
||||||
|
);
|
||||||
|
|
||||||
|
mediaElements.forEach((element) => {
|
||||||
|
element.volume = interpolatedVolume;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
activeFadeCancel = undefined;
|
||||||
|
if (onDone) onDone();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pauseGlobalMedia = () => {
|
export const pauseGlobalMedia = () => {
|
||||||
|
if (activeFadeCancel) {
|
||||||
|
activeFadeCancel();
|
||||||
|
activeFadeCancel = undefined;
|
||||||
|
}
|
||||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||||
mediaElements.forEach((element) => element.pause());
|
mediaElements.forEach((element) => {
|
||||||
|
try {
|
||||||
|
element.pause();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resumeGlobalMedia = () => {
|
export const resumeGlobalMedia = () => {
|
||||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||||
mediaElements.forEach((element) => element.play());
|
mediaElements.forEach((element) => {
|
||||||
|
try {
|
||||||
|
if (element instanceof HTMLVideoElement && element.error) {
|
||||||
|
element.load();
|
||||||
|
}
|
||||||
|
playThemeMedia(element);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not resume global media:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setCurrentTimeGlobalMedia = (time: number) => {
|
export const setCurrentTimeGlobalMedia = (time: number) => {
|
||||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||||
mediaElements.forEach((element) => (element.currentTime = time));
|
mediaElements.forEach((element) => {
|
||||||
|
try {
|
||||||
|
if (Number.isFinite(element.duration) && element.duration > 0) {
|
||||||
|
element.currentTime = Math.min(Math.max(0, time), element.duration);
|
||||||
|
} else {
|
||||||
|
element.currentTime = Math.max(0, time);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore if media is not seekable
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export function trackMediaContainer(node: HTMLElement) {
|
export function trackMediaContainer(node: HTMLElement) {
|
||||||
|
|||||||
@@ -832,6 +832,16 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const revealLauncherWindow = async () => {
|
||||||
|
try {
|
||||||
|
await invoke('reveal_window');
|
||||||
|
} catch {
|
||||||
|
await getCurrentWindow().show();
|
||||||
|
await getCurrentWindow().unminimize();
|
||||||
|
await getCurrentWindow().setFocus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const launch = async (skipUpdateCheck = false) => {
|
const launch = async (skipUpdateCheck = false) => {
|
||||||
if ($launching) return;
|
if ($launching) return;
|
||||||
launchInfo = 'Checking if osu! is already running...';
|
launchInfo = 'Checking if osu! is already running...';
|
||||||
@@ -1084,10 +1094,9 @@
|
|||||||
await new Promise((res) => setTimeout(res, 1500));
|
await new Promise((res) => setTimeout(res, 1500));
|
||||||
launchInfo = 'Launching osu!...';
|
launchInfo = 'Launching osu!...';
|
||||||
|
|
||||||
fadeGlobalVolume($custom_theme_volume, 0, 2000, pauseGlobalMedia);
|
await fadeGlobalVolume($custom_theme_volume, 0, 800);
|
||||||
|
pauseGlobalMedia();
|
||||||
/* await replaceUIFiles(osuPath, false); */
|
await new Promise((res) => setTimeout(res, 200));
|
||||||
await new Promise((res) => setTimeout(res, 1000));
|
|
||||||
await getCurrentWindow().hide();
|
await getCurrentWindow().hide();
|
||||||
|
|
||||||
let presenceUpdater: number | undefined = undefined;
|
let presenceUpdater: number | undefined = undefined;
|
||||||
@@ -1199,11 +1208,12 @@
|
|||||||
|
|
||||||
if (otd_run) await stopOpenTabletDriver();
|
if (otd_run) await stopOpenTabletDriver();
|
||||||
|
|
||||||
|
await revealLauncherWindow();
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
setCurrentTimeGlobalMedia(0);
|
setCurrentTimeGlobalMedia(0);
|
||||||
resumeGlobalMedia();
|
resumeGlobalMedia();
|
||||||
fadeGlobalVolume(0, $custom_theme_volume, 2000);
|
fadeGlobalVolume(0, $custom_theme_volume, 1500);
|
||||||
|
|
||||||
await getCurrentWindow().show();
|
|
||||||
if (presenceUpdater) {
|
if (presenceUpdater) {
|
||||||
window.clearInterval(presenceUpdater);
|
window.clearInterval(presenceUpdater);
|
||||||
try {
|
try {
|
||||||
@@ -1245,7 +1255,9 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup = false;
|
cleanup = false;
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
await getCurrentWindow().show();
|
await revealLauncherWindow();
|
||||||
|
resumeGlobalMedia();
|
||||||
|
fadeGlobalVolume(0, $custom_theme_volume, 1000);
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
sileo.error({
|
sileo.error({
|
||||||
title: 'Hmmm...',
|
title: 'Hmmm...',
|
||||||
@@ -4036,7 +4048,14 @@
|
|||||||
: $osuInstallationPath === ''
|
: $osuInstallationPath === ''
|
||||||
? 'No osu! path'
|
? 'No osu! path'
|
||||||
: 'Launch'}
|
: 'Launch'}
|
||||||
launchDisabled={$osuInstallationPath === '' || $serverConnectionFails > 1 || $launching}
|
launchSubtext={$launching && !cleanup
|
||||||
|
? launchInfo
|
||||||
|
: $osuInstallationPath === ''
|
||||||
|
? 'osu! path is not set!'
|
||||||
|
: $serverConnectionFails > 1
|
||||||
|
? 'No connection...'
|
||||||
|
: undefined}
|
||||||
|
launchDisabled={$osuInstallationPath === '' || $serverConnectionFails > 1}
|
||||||
oninteraction={triggerThemeScriptElement}
|
oninteraction={triggerThemeScriptElement}
|
||||||
onbuttonaction={handleThemeButtonAction}
|
onbuttonaction={handleThemeButtonAction}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import { git } from '$lib/api/git';
|
import { git } from '$lib/api/git';
|
||||||
import { config } from '$lib/config';
|
import { config } from '$lib/config';
|
||||||
import { currentUserInfo } from '$lib/data';
|
import { currentUserInfo } from '$lib/data';
|
||||||
import { estimateFrameRate } from '$lib/displayUtils';
|
|
||||||
import {
|
import {
|
||||||
beatmapSets,
|
beatmapSets,
|
||||||
currentLoadingInfo,
|
currentLoadingInfo,
|
||||||
@@ -30,12 +29,7 @@
|
|||||||
} from '$lib/osuUtil';
|
} from '$lib/osuUtil';
|
||||||
import type { EZPPUser } from '$lib/types';
|
import type { EZPPUser } from '$lib/types';
|
||||||
import { currentUser } from '$lib/userAuthentication';
|
import { currentUser } from '$lib/userAuthentication';
|
||||||
import {
|
import { osuInstallationPath, preferredMode, preferredType } from '$lib/userSettings';
|
||||||
cursorSmoothness,
|
|
||||||
osuInstallationPath,
|
|
||||||
preferredMode,
|
|
||||||
preferredType,
|
|
||||||
} from '$lib/userSettings';
|
|
||||||
import Launch from './Launch.svelte';
|
import Launch from './Launch.svelte';
|
||||||
import SetupWizard from './SetupWizard.svelte';
|
import SetupWizard from './SetupWizard.svelte';
|
||||||
import { animate, utils } from 'animejs';
|
import { animate, utils } from 'animejs';
|
||||||
@@ -65,21 +59,7 @@
|
|||||||
}, 450);
|
}, 450);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateCursorSmoothness = async () => {
|
|
||||||
const refreshRate = await estimateFrameRate();
|
|
||||||
const hzMin = 60;
|
|
||||||
const hzMax = 500;
|
|
||||||
const durationMin = 350;
|
|
||||||
const durationMax = 600;
|
|
||||||
const clamped = Math.min(Math.max(refreshRate, hzMin), hzMax);
|
|
||||||
const duration =
|
|
||||||
durationMin + ((clamped - hzMin) / (hzMax - hzMin)) * (durationMax - durationMin);
|
|
||||||
cursorSmoothness.set(Math.round(duration));
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepare = async () => {
|
const prepare = async () => {
|
||||||
await calculateCursorSmoothness();
|
|
||||||
|
|
||||||
const username = await config.value<string>('username').get('');
|
const username = await config.value<string>('username').get('');
|
||||||
const password = await config.value<string>('password').get('');
|
const password = await config.value<string>('password').get('');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user