chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard
No files matched your search
@@ -78,6 +78,23 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||
|
||||
[[package]]
|
||||
name = "android_logger"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -1189,6 +1206,16 @@ dependencies = [
|
||||
"syn 2.0.113",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1248,9 +1275,16 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hardware-id",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"http-range",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"md5",
|
||||
"objc2 0.6.4",
|
||||
"once_cell",
|
||||
"open",
|
||||
"percent-encoding",
|
||||
"presenceforge",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
@@ -1265,9 +1299,11 @@ dependencies = [
|
||||
"tauri-plugin-cors-fetch",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"widestring",
|
||||
@@ -1304,6 +1340,15 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fern"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1993,6 +2038,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
@@ -2016,6 +2067,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -2766,6 +2818,15 @@ dependencies = [
|
||||
"syn 2.0.113",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
@@ -4995,6 +5056,27 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f2e3c40b77fd1eb7f8e1b6900fb036aee2da1ea2d3783c91255a82c636649d"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"fern",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"swift-rs",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -5216,7 +5298,9 @@ dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
|
||||
@@ -56,6 +56,19 @@ aes-gcm = "0.10.3"
|
||||
base64 = "0.22.1"
|
||||
sha2 = "0.11.0"
|
||||
thiserror = "2.0.18"
|
||||
tauri-plugin-log = "2"
|
||||
log = "0.4"
|
||||
time = { version = "0.3", features = ["local-offset"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
http-body-util = "0.1.3"
|
||||
http-range = "0.1.5"
|
||||
hyper = { version = "1.8.1", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.19", features = ["tokio"] }
|
||||
percent-encoding = "2.3.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.56.0"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"description": "Capabilities for the main launcher and theme editor windows",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"theme-editor"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@@ -7,36 +7,38 @@ use tauri_plugin_fs::FsExt;
|
||||
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
pub mod osudb;
|
||||
pub mod presence;
|
||||
pub mod state;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod theme_server;
|
||||
pub mod utils;
|
||||
use crate::{
|
||||
commands::{
|
||||
check_for_corruption, config_all, config_clear, config_delete, config_exists, config_get,
|
||||
config_set, download_and_extract_theme, download_ezpp_launcher_update,
|
||||
download_ezpp_launcher_update_files, encrypt_string, exit, extract_theme,
|
||||
download_ezpp_launcher_update_files, encrypt_string, exit, export_theme, extract_theme,
|
||||
find_osu_installation, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid,
|
||||
get_launcher_version, get_osu_release_stream, get_osu_skin, get_osu_version, get_platform,
|
||||
get_skins_count, has_net8, has_osuwinello, has_wmctrl, install_ezpp_launcher_update,
|
||||
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
||||
open_url_in_browser, opened_urls, presence_connect, presence_disconnect,
|
||||
log_frontend, open_logs_folder, open_theme_editor, open_theme_folder, open_url_in_browser,
|
||||
opened_urls, pick_osu_installation, presence_connect, presence_disconnect,
|
||||
presence_is_connected, presence_update_button, presence_update_status,
|
||||
presence_update_user, read_theme_info, replace_ui_files, run_open_tablet_driver, run_osu,
|
||||
run_osu_updater, set_osu_config_values, set_osu_user_config_values,
|
||||
stop_open_tablet_driver, valid_osu_folder,
|
||||
presence_update_user, read_theme_info, replace_ui_files, resolve_osu_folder,
|
||||
run_open_tablet_driver, run_osu, run_osu_updater, save_theme_project, set_logging_enabled,
|
||||
set_osu_config_values, set_osu_user_config_values, stop_open_tablet_driver,
|
||||
theme_asset_base, theme_editor_context, theme_preview_url, valid_osu_folder,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
// std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
let mut builder = tauri::Builder::default().plugin(logging::plugin());
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
@@ -67,6 +69,15 @@ pub fn run() {
|
||||
|
||||
let app = builder
|
||||
.setup(|app| {
|
||||
log::info!(
|
||||
target: "launcher",
|
||||
"EZPPLauncher {} starting on {} ({}) — logs: {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
logging::logs_directory().display()
|
||||
);
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
@@ -97,14 +108,35 @@ pub fn run() {
|
||||
app.manage(AppState {
|
||||
config: Mutex::new(cfg),
|
||||
opened_files: Mutex::new(opened_files),
|
||||
editor_theme_folder: Mutex::new(None),
|
||||
});
|
||||
|
||||
// WebKitGTK cannot play theme media through the custom asset
|
||||
// protocol, so Linux gets a real loopback HTTP server instead.
|
||||
#[cfg(target_os = "linux")]
|
||||
match commands::theme_root().and_then(theme_server::start) {
|
||||
Ok(_) => {}
|
||||
Err(error) => log::warn!(
|
||||
target: "launcher",
|
||||
"Could not start the theme asset server: {error}"
|
||||
),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
opened_urls,
|
||||
get_hwid,
|
||||
find_osu_installation,
|
||||
pick_osu_installation,
|
||||
resolve_osu_folder,
|
||||
valid_osu_folder,
|
||||
get_beatmapsets_count,
|
||||
get_skins_count,
|
||||
@@ -139,6 +171,13 @@ pub fn run() {
|
||||
read_theme_info,
|
||||
extract_theme,
|
||||
download_and_extract_theme,
|
||||
save_theme_project,
|
||||
export_theme,
|
||||
open_theme_folder,
|
||||
open_theme_editor,
|
||||
theme_editor_context,
|
||||
theme_asset_base,
|
||||
theme_preview_url,
|
||||
config_get,
|
||||
config_set,
|
||||
config_exists,
|
||||
@@ -148,7 +187,10 @@ pub fn run() {
|
||||
run_open_tablet_driver,
|
||||
stop_open_tablet_driver,
|
||||
is_open_tablet_driver_running,
|
||||
is_open_tablet_driver_path_valid
|
||||
is_open_tablet_driver_path_valid,
|
||||
log_frontend,
|
||||
set_logging_enabled,
|
||||
open_logs_folder
|
||||
])
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -158,6 +200,17 @@ pub fn run() {
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app, event| {
|
||||
// The content view can be replaced again while the webview is being
|
||||
// built, so re-apply the corner mask once the app is ready.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Ready = &event {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
presence::handle_run_event(app, &event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Rotating file logs for the launcher, osu! and the patcher.
|
||||
//!
|
||||
//! Each stream writes to `~/.ezpplauncher/logs`:
|
||||
//!
|
||||
//! - `launcher.log` receives everything, including frontend console output.
|
||||
//! - `osu.log` receives osu! and its updater, including stdout/stderr.
|
||||
//! - `patcher.log` receives patcher output.
|
||||
//!
|
||||
//! A stream's file is only created when it actually has a line to write, so a
|
||||
//! launch without osu!/patcher output never leaves empty files behind. When a
|
||||
//! new session starts, or any stream reaches 5 MiB, all three active files are
|
||||
//! bundled into one `logs_<date>_<time>.zip` and fresh files are started. The
|
||||
//! five most recent bundles are kept, so a support report only needs one file.
|
||||
//! `launcher.log` intentionally includes the osu!/patcher entries too. File
|
||||
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use log::Level;
|
||||
use tauri::Runtime;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
||||
use tokio::process::Child;
|
||||
|
||||
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
|
||||
const KEPT_LOG_BUNDLES: usize = 5;
|
||||
const MAX_LINE_CHARS: usize = 16_000;
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub fn set_logging_enabled(enabled: bool) {
|
||||
ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn logging_enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Read the persisted setting before the config database is initialized, so a
|
||||
/// disabled logger does not write a startup file before the frontend loads.
|
||||
fn initial_logging_enabled() -> bool {
|
||||
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
|
||||
return true;
|
||||
};
|
||||
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
|
||||
|
||||
if !db_path.exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(connection) =
|
||||
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Ok(raw) = connection.query_row(
|
||||
"SELECT value FROM config WHERE key = 'logging_enabled'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
serde_json::from_str::<bool>(&raw).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn logs_directory() -> PathBuf {
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
home.join(".ezpplauncher").join("logs")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Stream {
|
||||
Launcher,
|
||||
Osu,
|
||||
Patcher,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
const ALL: [Stream; 3] = [Stream::Launcher, Stream::Osu, Stream::Patcher];
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Stream::Launcher => 0,
|
||||
Stream::Osu => 1,
|
||||
Stream::Patcher => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(self) -> &'static str {
|
||||
match self {
|
||||
Stream::Launcher => "launcher.log",
|
||||
Stream::Osu => "osu.log",
|
||||
Stream::Patcher => "patcher.log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for the three log files. Rotating any stream closes and
|
||||
/// bundles all of them, so every bundle always holds the same session.
|
||||
struct LogSet {
|
||||
dir: PathBuf,
|
||||
state: Mutex<LogState>,
|
||||
}
|
||||
|
||||
struct LogState {
|
||||
started: bool,
|
||||
files: [Option<File>; 3],
|
||||
sizes: [u64; 3],
|
||||
}
|
||||
|
||||
impl LogSet {
|
||||
fn new(dir: PathBuf) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
state: Mutex::new(LogState {
|
||||
started: false,
|
||||
files: [None, None, None],
|
||||
sizes: [0; 3],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, stream: Stream, buf: &[u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
|
||||
if !state.started {
|
||||
state.started = true;
|
||||
bundle_active_logs(&self.dir)?;
|
||||
prune_bundles(&self.dir)?;
|
||||
}
|
||||
|
||||
let index = stream.index();
|
||||
|
||||
if state.files[index].is_none() {
|
||||
self.open_stream(&mut state, stream)?;
|
||||
}
|
||||
|
||||
if state.sizes[index] > 0
|
||||
&& state.sizes[index].saturating_add(buf.len() as u64) > MAX_LOG_SIZE
|
||||
{
|
||||
self.rotate(&mut state, stream)?;
|
||||
}
|
||||
|
||||
let file = state.files[index].as_mut().expect("stream log is open");
|
||||
file.write_all(buf)?;
|
||||
state.sizes[index] += buf.len() as u64;
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&self, stream: Stream) -> io::Result<()> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
|
||||
match state.files[stream.index()].as_mut() {
|
||||
Some(file) => file.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_stream(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
|
||||
fs::create_dir_all(&self.dir)?;
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.dir.join(stream.file_name()))?;
|
||||
let index = stream.index();
|
||||
state.sizes[index] = file.metadata()?.len();
|
||||
state.files[index] = Some(file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
|
||||
for file in state.files.iter_mut() {
|
||||
if let Some(mut file) = file.take() {
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
|
||||
bundle_active_logs(&self.dir)?;
|
||||
prune_bundles(&self.dir)?;
|
||||
|
||||
let index = stream.index();
|
||||
state.sizes[index] = 0;
|
||||
self.open_stream(state, stream)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionWriter {
|
||||
logs: Arc<LogSet>,
|
||||
stream: Stream,
|
||||
}
|
||||
|
||||
impl Write for SessionWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if !logging_enabled() {
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
self.logs.write(self.stream, buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if !logging_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.logs.flush(self.stream)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zip the active logs that have content and remove them afterwards.
|
||||
fn bundle_active_logs(dir: &Path) -> io::Result<()> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for stream in Stream::ALL {
|
||||
let path = dir.join(stream.file_name());
|
||||
|
||||
match path.metadata() {
|
||||
Ok(metadata) if metadata.len() > 0 => {
|
||||
entries.push((path, stream.file_name().to_string()));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(dir)?;
|
||||
let bundle_path = unique_bundle_path(dir);
|
||||
|
||||
let result = (|| -> io::Result<()> {
|
||||
let file = File::create(&bundle_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
for (path, name) in &entries {
|
||||
zip.start_file(name.clone(), options).map_err(|error| {
|
||||
io::Error::other(format!("Could not add {name} to the log bundle: {error}"))
|
||||
})?;
|
||||
let mut source = File::open(path)?;
|
||||
io::copy(&mut source, &mut zip)?;
|
||||
}
|
||||
|
||||
zip.finish().map_err(|error| {
|
||||
io::Error::other(format!("Could not finish the log bundle: {error}"))
|
||||
})?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&bundle_path);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
for (path, _) in entries {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_bundle_path(dir: &Path) -> PathBuf {
|
||||
let stamp = archive_timestamp();
|
||||
let mut path = dir.join(format!("logs_{stamp}.zip"));
|
||||
let mut suffix = 1;
|
||||
|
||||
while path.exists() {
|
||||
path = dir.join(format!("logs_{stamp}-{suffix}.zip"));
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
fn archive_timestamp() -> String {
|
||||
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
|
||||
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
|
||||
now.year(),
|
||||
u8::from(now.month()),
|
||||
now.day(),
|
||||
now.hour(),
|
||||
now.minute(),
|
||||
now.second()
|
||||
)
|
||||
}
|
||||
|
||||
fn prune_bundles(dir: &Path) -> io::Result<()> {
|
||||
let mut bundles = fs::read_dir(dir)?
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name()?.to_str()?;
|
||||
|
||||
if !file_name.starts_with("logs_") || !file_name.ends_with(".zip") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let modified = entry.metadata().ok()?.modified().ok()?;
|
||||
Some((path, modified))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
bundles.sort_by_key(|(_, modified)| *modified);
|
||||
|
||||
if bundles.len() > KEPT_LOG_BUNDLES {
|
||||
for (path, _) in bundles.iter().take(bundles.len() - KEPT_LOG_BUNDLES) {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_target(set: &Arc<LogSet>, stream: Stream) -> Target {
|
||||
let writer = SessionWriter {
|
||||
logs: Arc::clone(set),
|
||||
stream,
|
||||
};
|
||||
let dispatch = tauri_plugin_log::fern::Dispatch::new().chain(
|
||||
tauri_plugin_log::fern::Output::writer(Box::new(writer), "\n"),
|
||||
);
|
||||
|
||||
Target::new(TargetKind::Dispatch(dispatch))
|
||||
}
|
||||
|
||||
/// Build the log plugin. File targets are filtered by logging target so osu!
|
||||
/// and patcher output stay separate while still being copied to the main log.
|
||||
pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
|
||||
set_logging_enabled(initial_logging_enabled());
|
||||
|
||||
let logs_dir = logs_directory();
|
||||
let set = Arc::new(LogSet::new(logs_dir));
|
||||
|
||||
let launcher = file_target(&set, Stream::Launcher);
|
||||
let osu =
|
||||
file_target(&set, Stream::Osu).filter(|metadata| metadata.target().starts_with("osu"));
|
||||
let patcher = file_target(&set, Stream::Patcher)
|
||||
.filter(|metadata| metadata.target().starts_with("patcher"));
|
||||
|
||||
tauri_plugin_log::Builder::new()
|
||||
.targets([Target::new(TargetKind::Stdout), launcher, osu, patcher])
|
||||
.level(if cfg!(debug_assertions) {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
})
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||
pub fn capture_osu_output(child: &mut Child) {
|
||||
capture_output(child, "osu");
|
||||
}
|
||||
|
||||
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||
pub fn capture_patcher_output(child: &mut Child) {
|
||||
capture_output(child, "patcher");
|
||||
}
|
||||
|
||||
fn capture_output(child: &mut Child, target: &'static str) {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
stream_lines(stdout, "stdout", target);
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
stream_lines(stderr, "stderr", target);
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_lines<R>(reader: R, stream: &'static str, target: &'static str)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
let line = truncate(line.trim_end_matches(['\r', '\n']));
|
||||
if !line.is_empty() {
|
||||
log::log!(target: target, Level::Info, "[{stream}] {line}");
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
log::log!(
|
||||
target: target,
|
||||
Level::Warn,
|
||||
"Could not read {stream} output: {error}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn truncate(mut value: &str) -> String {
|
||||
if value.chars().count() <= MAX_LINE_CHARS {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
value = &value[..value
|
||||
.char_indices()
|
||||
.nth(MAX_LINE_CHARS)
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(value.len())];
|
||||
format!("{value}…")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ezpp-logging-{}-{name}-{id}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_line(logs: &Arc<LogSet>, stream: Stream, line: &str) {
|
||||
let mut writer = SessionWriter {
|
||||
logs: Arc::clone(logs),
|
||||
stream,
|
||||
};
|
||||
writer.write_all(line.as_bytes()).unwrap();
|
||||
writer.flush().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_file_only_after_first_line() {
|
||||
let dir = temp_dir("lazy");
|
||||
let logs = Arc::new(LogSet::new(dir.clone()));
|
||||
|
||||
assert!(!dir.join("osu.log").exists());
|
||||
|
||||
write_line(&logs, Stream::Osu, "[stdout] first\n");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("osu.log")).unwrap(),
|
||||
"[stdout] first\n"
|
||||
);
|
||||
assert!(!dir.join("launcher.log").exists());
|
||||
assert!(!dir.join("patcher.log").exists());
|
||||
|
||||
drop(logs);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundles_all_streams_when_a_new_session_starts() {
|
||||
let dir = temp_dir("bundle");
|
||||
|
||||
let previous = Arc::new(LogSet::new(dir.clone()));
|
||||
write_line(&previous, Stream::Launcher, "launcher previous\n");
|
||||
write_line(&previous, Stream::Osu, "osu previous\n");
|
||||
write_line(&previous, Stream::Patcher, "patcher previous\n");
|
||||
drop(previous);
|
||||
|
||||
let current = Arc::new(LogSet::new(dir.clone()));
|
||||
write_line(¤t, Stream::Launcher, "launcher current\n");
|
||||
|
||||
// Active files only contain the new session.
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("launcher.log")).unwrap(),
|
||||
"launcher current\n"
|
||||
);
|
||||
assert!(!dir.join("osu.log").exists());
|
||||
assert!(!dir.join("patcher.log").exists());
|
||||
|
||||
let bundles = fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zip"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(bundles.len(), 1);
|
||||
|
||||
let file = File::open(&bundles[0]).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(zip.len(), 3);
|
||||
|
||||
let mut names = (0..zip.len())
|
||||
.map(|index| zip.by_index(index).unwrap().name().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
assert_eq!(names, ["launcher.log", "osu.log", "patcher.log"]);
|
||||
|
||||
let mut launcher = String::new();
|
||||
zip.by_name("launcher.log")
|
||||
.unwrap()
|
||||
.read_to_string(&mut launcher)
|
||||
.unwrap();
|
||||
assert_eq!(launcher, "launcher previous\n");
|
||||
|
||||
drop(zip);
|
||||
drop(current);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
//! macOS support for osu! stable running inside a Wine wrapper.
|
||||
//!
|
||||
//! osu! stable is a Windows application, so on macOS it has to run through a
|
||||
//! Wine-based wrapper. The launcher supports Wineskin wrappers (the common
|
||||
//! `osu!.app` wine skin), Whisky bottles and CrossOver bottles.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration as StdDuration, Instant};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
use crate::utils::is_valid_osu_folder;
|
||||
|
||||
/// Round the borderless launcher window and make its corners transparent.
|
||||
///
|
||||
/// macOS does not round undecorated windows on its own, so the webview has to
|
||||
/// be clipped with the content view's layer. The window itself is made
|
||||
/// transparent so the area outside that clip shows the desktop instead of a
|
||||
/// square background.
|
||||
pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), String> {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::AnyObject;
|
||||
|
||||
let ns_window = window.ns_window().map_err(|error| error.to_string())? as *mut AnyObject;
|
||||
if ns_window.is_null() {
|
||||
return Err("The native window is not available".into());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let _: () = msg_send![ns_window, setOpaque: false];
|
||||
let clear: *mut AnyObject = msg_send![objc2::class!(NSColor), clearColor];
|
||||
let _: () = msg_send![ns_window, setBackgroundColor: clear];
|
||||
let _: () = msg_send![ns_window, setHasShadow: true];
|
||||
|
||||
let content_view: *mut AnyObject = msg_send![ns_window, contentView];
|
||||
if content_view.is_null() {
|
||||
return Err("The native content view is not available".into());
|
||||
}
|
||||
|
||||
let _: () = msg_send![content_view, setWantsLayer: true];
|
||||
let layer: *mut AnyObject = msg_send![content_view, layer];
|
||||
if layer.is_null() {
|
||||
return Err("The native content view has no layer".into());
|
||||
}
|
||||
|
||||
let _: () = msg_send![layer, setCornerRadius: radius];
|
||||
let _: () = msg_send![layer, setMasksToBounds: true];
|
||||
let _: () = msg_send![ns_window, invalidateShadow];
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show a native open panel that accepts `.app` bundles as well as folders.
|
||||
///
|
||||
/// AppKit treats application bundles as packages (directories) by default, so a
|
||||
/// regular folder picker descends into `osu!.app` instead of selecting it. This
|
||||
/// panel turns that behaviour off, letting the user pick the wrapper itself,
|
||||
/// while still allowing a plain Wine prefix folder to be selected.
|
||||
pub fn pick_osu_installation() -> Result<Option<PathBuf>, String> {
|
||||
use objc2::msg_send;
|
||||
use objc2::rc::autoreleasepool;
|
||||
use objc2::runtime::AnyObject;
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
unsafe fn ns_string(value: &str) -> Result<*mut AnyObject, String> {
|
||||
let value = CString::new(value).map_err(|_| "The path contains a NUL byte".to_string())?;
|
||||
let string: *mut AnyObject =
|
||||
unsafe { msg_send![objc2::class!(NSString), stringWithUTF8String: value.as_ptr()] };
|
||||
if string.is_null() {
|
||||
return Err("Could not create the native dialog text".to_string());
|
||||
}
|
||||
Ok(string)
|
||||
}
|
||||
|
||||
autoreleasepool(|_| unsafe {
|
||||
let panel: *mut AnyObject = msg_send![objc2::class!(NSOpenPanel), openPanel];
|
||||
if panel.is_null() {
|
||||
return Err("Could not create the native file picker".to_string());
|
||||
}
|
||||
|
||||
let _: () = msg_send![panel, setCanChooseFiles: true];
|
||||
let _: () = msg_send![panel, setCanChooseDirectories: true];
|
||||
// Keep `.app` bundles selectable instead of navigating into them.
|
||||
let _: () = msg_send![panel, setTreatsFilePackagesAsDirectories: false];
|
||||
let _: () = msg_send![panel, setAllowsMultipleSelection: false];
|
||||
let _: () = msg_send![panel, setCanCreateDirectories: false];
|
||||
|
||||
let title = ns_string("Select osu!.app")?;
|
||||
let _: () = msg_send![panel, setTitle: title];
|
||||
let message = ns_string("Pick your osu!.app wrapper, or a Wine prefix folder.")?;
|
||||
let _: () = msg_send![panel, setMessage: message];
|
||||
let prompt = ns_string("Select")?;
|
||||
let _: () = msg_send![panel, setPrompt: prompt];
|
||||
|
||||
let applications = ns_string("/Applications")?;
|
||||
let directory_url: *mut AnyObject =
|
||||
msg_send![objc2::class!(NSURL), fileURLWithPath: applications];
|
||||
if !directory_url.is_null() {
|
||||
let _: () = msg_send![panel, setDirectoryURL: directory_url];
|
||||
}
|
||||
|
||||
// NSModalResponseOK
|
||||
let response: i64 = msg_send![panel, runModal];
|
||||
if response != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let url: *mut AnyObject = msg_send![panel, URL];
|
||||
if url.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path: *mut AnyObject = msg_send![url, path];
|
||||
if path.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let utf8: *const std::os::raw::c_char = msg_send![path, UTF8String];
|
||||
if utf8.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let path = CStr::from_ptr(utf8).to_string_lossy().into_owned();
|
||||
Ok(Some(PathBuf::from(path)))
|
||||
})
|
||||
}
|
||||
|
||||
pub struct WineRuntime {
|
||||
prefix: PathBuf,
|
||||
wine_bin: PathBuf,
|
||||
wine_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl WineRuntime {
|
||||
/// Build a runtime description for an osu! folder inside a Wine prefix.
|
||||
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
|
||||
if !osu_folder.join("osu!.exe").is_file() {
|
||||
return Err(format!(
|
||||
"osu!.exe was not found in {}",
|
||||
osu_folder.display()
|
||||
));
|
||||
}
|
||||
|
||||
let prefix = prefix_for(osu_folder).ok_or_else(|| {
|
||||
"This osu! installation is not inside a Wine prefix. On macOS, choose the osu! folder inside your Wineskin, Whisky or CrossOver wrapper.".to_string()
|
||||
})?;
|
||||
|
||||
let app = app_bundle_for(&prefix);
|
||||
let (wine_bin, wine_root) = find_wine_bin(&prefix, app.as_deref()).ok_or_else(|| {
|
||||
"Could not find a Wine runtime for this osu! installation. Make sure the Wineskin/Whisky/CrossOver wrapper is still installed.".to_string()
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
prefix,
|
||||
wine_bin,
|
||||
wine_root,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 mut command = Command::new(&loader);
|
||||
command.arg(exe).args(args);
|
||||
command.current_dir(exe.parent().unwrap_or(&self.prefix));
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
command.env("WINEPREFIX", &self.prefix);
|
||||
command.env("WINEDEBUG", "-all");
|
||||
command.env("WINEDLLOVERRIDES", "mscoree,mshtml=");
|
||||
command.env("FREETYPE_PROPERTIES", "truetype:interpreter-version=35");
|
||||
|
||||
if let Some(wine_root) = &self.wine_root {
|
||||
let mut library_paths = vec![
|
||||
wine_root.join("lib"),
|
||||
wine_root.join("lib64"),
|
||||
wine_root.join("lib32on64"),
|
||||
wine_root.clone(),
|
||||
PathBuf::from("/opt/local/lib"),
|
||||
PathBuf::from("/usr/lib"),
|
||||
PathBuf::from("/usr/libexec"),
|
||||
PathBuf::from("/usr/lib/system"),
|
||||
PathBuf::from("/opt/X11/lib"),
|
||||
];
|
||||
if let Some(existing) = std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH") {
|
||||
library_paths.extend(std::env::split_paths(&existing));
|
||||
}
|
||||
if let Ok(library_path) = std::env::join_paths(library_paths) {
|
||||
command.env("DYLD_FALLBACK_LIBRARY_PATH", &library_path);
|
||||
command.env("WINESKIN_LIB_PATH_FOR_FALLBACK", &library_path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut paths = vec![self.wine_bin.clone()];
|
||||
if let Some(existing) = std::env::var_os("PATH") {
|
||||
paths.extend(std::env::split_paths(&existing));
|
||||
}
|
||||
if let Ok(path) = std::env::join_paths(paths) {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
|
||||
let executable_name = exe
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "the Windows application".to_string());
|
||||
|
||||
command.spawn().map_err(|error| {
|
||||
format!(
|
||||
"Failed to start {executable_name} through Wine ({}): {}",
|
||||
loader.display(),
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop every process that belongs to this Wine prefix.
|
||||
pub async fn kill_prefix(&self) {
|
||||
let wineserver = self.wine_bin.join("wineserver");
|
||||
if !wineserver.is_file() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = Command::new(wineserver)
|
||||
.arg("-k")
|
||||
.env("WINEPREFIX", &self.prefix)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
|
||||
fn loader_for(&self, exe: &Path) -> PathBuf {
|
||||
let is_64_bit = pe_machine(exe) == Some(0x8664);
|
||||
let candidates: &[&str] = if is_64_bit {
|
||||
&["wine64", "wine"]
|
||||
} else {
|
||||
&["wine32on64", "wine", "wine64"]
|
||||
};
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.map(|candidate| self.wine_bin.join(candidate))
|
||||
.find(|candidate| candidate.is_file())
|
||||
.unwrap_or_else(|| self.wine_bin.join(candidates[0]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run osu! (`-devserver ez-pp.farm`) and wait until the game exits.
|
||||
pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> {
|
||||
let folder = Path::new(osu_folder);
|
||||
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
|
||||
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
|
||||
error
|
||||
})?;
|
||||
let mut game = runtime
|
||||
.spawn(&folder.join("osu!.exe"), &["-devserver", "ez-pp.farm"])
|
||||
.map_err(|error| {
|
||||
log::error!(target: "osu", "Could not start osu!: {error}");
|
||||
error
|
||||
})?;
|
||||
|
||||
if let Some(pid) = game.id() {
|
||||
log::info!(target: "osu", "osu! started through Wine (pid {pid})");
|
||||
}
|
||||
crate::logging::capture_osu_output(&mut game);
|
||||
|
||||
if patch {
|
||||
if let Err(error) = start_patcher(&runtime, folder).await {
|
||||
log::warn!(target: "patcher", "Could not start the patcher: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
match game.wait().await {
|
||||
Ok(status) => log::info!(target: "osu", "osu! exited with {status}"),
|
||||
Err(error) => {
|
||||
log::warn!(target: "osu", "Could not wait for osu!: {error}");
|
||||
return Err(format!("Failed to wait for osu!: {}", error));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the Windows patcher through the same Wine prefix as osu!.
|
||||
async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), String> {
|
||||
let patcher_exe = osu_folder
|
||||
.join("EZPPLauncher")
|
||||
.join("patcher")
|
||||
.join("osu!.patcher.exe");
|
||||
|
||||
if !patcher_exe.is_file() {
|
||||
return Err(format!(
|
||||
"Patcher was requested but {} does not exist",
|
||||
patcher_exe.display()
|
||||
));
|
||||
}
|
||||
|
||||
wait_for_osu_game().await;
|
||||
|
||||
log::info!(
|
||||
target: "patcher",
|
||||
"Starting the patcher from {}",
|
||||
patcher_exe.display()
|
||||
);
|
||||
|
||||
let mut patcher = runtime.spawn(&patcher_exe, &[])?;
|
||||
if let Some(pid) = patcher.id() {
|
||||
log::info!(target: "patcher", "Patcher started through Wine (pid {pid})");
|
||||
}
|
||||
crate::logging::capture_patcher_output(&mut patcher);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match patcher.wait().await {
|
||||
Ok(status) => log::info!(target: "patcher", "Patcher exited with {status}"),
|
||||
Err(error) => log::warn!(target: "patcher", "Could not wait for the patcher: {error}"),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until the Wine-hosted osu! process exists, then give it time to create
|
||||
/// its game window. Wine does not expose the window title to the launcher here,
|
||||
/// so the process is the best signal available.
|
||||
async fn wait_for_osu_game() {
|
||||
let mut system = sysinfo::System::new_all();
|
||||
let deadline = Instant::now() + StdDuration::from_secs(30);
|
||||
let mut detected = false;
|
||||
|
||||
while Instant::now() < deadline {
|
||||
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
if system
|
||||
.processes()
|
||||
.values()
|
||||
.any(crate::utils::is_osu_game_process)
|
||||
{
|
||||
detected = true;
|
||||
break;
|
||||
}
|
||||
|
||||
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.
|
||||
pub async fn run_osu_updater(osu_folder: &str) -> Result<(), String> {
|
||||
let folder = Path::new(osu_folder);
|
||||
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
|
||||
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
|
||||
error
|
||||
})?;
|
||||
let mut updater = runtime
|
||||
.spawn(&folder.join("osu!.exe"), &["-repair"])
|
||||
.map_err(|error| {
|
||||
log::error!(target: "osu", "Could not start the osu! updater: {error}");
|
||||
error
|
||||
})?;
|
||||
|
||||
if let Some(pid) = updater.id() {
|
||||
log::info!(target: "osu", "osu! updater started through Wine (pid {pid})");
|
||||
}
|
||||
crate::logging::capture_osu_output(&mut updater);
|
||||
|
||||
let mut system = sysinfo::System::new_all();
|
||||
let deadline = Instant::now() + StdDuration::from_secs(300);
|
||||
|
||||
loop {
|
||||
if updater
|
||||
.try_wait()
|
||||
.map_err(|error| format!("Failed to check the osu! updater: {}", error))?
|
||||
.is_some()
|
||||
{
|
||||
log::info!(target: "osu", "The osu! updater exited");
|
||||
break;
|
||||
}
|
||||
|
||||
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
if system
|
||||
.processes()
|
||||
.values()
|
||||
.any(crate::utils::is_osu_game_process)
|
||||
{
|
||||
// The updater relaunched osu! to finish the update. Close it so the
|
||||
// launcher can start the game itself with the correct server.
|
||||
log::info!(target: "osu", "The updater relaunched osu!; closing the Wine prefix");
|
||||
runtime.kill_prefix().await;
|
||||
break;
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
log::warn!(target: "osu", "Timed out waiting for the osu! updater; closing the Wine prefix");
|
||||
runtime.kill_prefix().await;
|
||||
break;
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
match updater.wait().await {
|
||||
Ok(status) => log::info!(target: "osu", "osu! updater exited with {status}"),
|
||||
Err(error) => log::warn!(target: "osu", "Could not wait for the osu! updater: {error}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a user-selected path (osu! folder, `.app` bundle, prefix or drive_c)
|
||||
/// to a valid osu! installation folder.
|
||||
pub fn resolve_osu_folder(folder: &Path) -> Option<PathBuf> {
|
||||
if is_valid_osu_folder(folder) {
|
||||
return Some(folder.to_path_buf());
|
||||
}
|
||||
|
||||
let mut prefixes = Vec::new();
|
||||
|
||||
if folder.file_name() == Some(OsStr::new("drive_c")) {
|
||||
if let Some(parent) = canonical_prefix(folder) {
|
||||
prefixes.push(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if folder.join("drive_c").is_dir() {
|
||||
if let Some(parent) = canonical_prefix(&folder.join("drive_c")) {
|
||||
prefixes.push(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if folder.extension() == Some(OsStr::new("app")) || folder.join("Contents").is_dir() {
|
||||
for drive_c in [
|
||||
folder.join("Contents/Resources/drive_c"),
|
||||
folder.join("Contents/SharedSupport/drive_c"),
|
||||
folder.join("Contents/SharedSupport/prefix/drive_c"),
|
||||
] {
|
||||
if drive_c.is_dir() {
|
||||
if let Some(parent) = canonical_prefix(&drive_c) {
|
||||
prefixes.push(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut drive_dirs = Vec::new();
|
||||
find_drive_c_dirs(folder, 5, &mut drive_dirs);
|
||||
prefixes.extend(
|
||||
drive_dirs
|
||||
.iter()
|
||||
.filter_map(|drive_c| drive_c.parent().map(Path::to_path_buf)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(prefix) = prefix_for(folder) {
|
||||
prefixes.push(prefix);
|
||||
}
|
||||
|
||||
prefixes.sort();
|
||||
prefixes.dedup();
|
||||
|
||||
prefixes
|
||||
.into_iter()
|
||||
.find_map(|prefix| find_osu_folder_in_prefix(&prefix))
|
||||
}
|
||||
|
||||
/// Auto-detect an osu! installation in the usual macOS wrapper locations.
|
||||
pub fn find_osu_installation() -> Option<String> {
|
||||
for app in app_bundles() {
|
||||
if let Some(folder) = resolve_osu_folder(&app) {
|
||||
return Some(folder.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
for prefix in bottle_prefixes() {
|
||||
if let Some(folder) = find_osu_folder_in_prefix(&prefix) {
|
||||
return Some(folder.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_osu_folder_in_prefix(prefix: &Path) -> Option<PathBuf> {
|
||||
let drive_c = prefix.join("drive_c");
|
||||
if !drive_c.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(folder) = common_osu_folders(&drive_c)
|
||||
.into_iter()
|
||||
.find(|folder| is_valid_osu_folder(folder))
|
||||
{
|
||||
return Some(folder);
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
collect_osu_folders(&drive_c, 0, 6, &mut candidates);
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|folder| is_valid_osu_folder(folder))
|
||||
}
|
||||
|
||||
fn common_osu_folders(drive_c: &Path) -> Vec<PathBuf> {
|
||||
let mut folders = vec![
|
||||
drive_c.join("osu!"),
|
||||
drive_c.join("Program Files").join("osu!"),
|
||||
drive_c.join("Program Files (x86)").join("osu!"),
|
||||
];
|
||||
|
||||
if let Ok(users) = fs::read_dir(drive_c.join("users")) {
|
||||
for user in users.flatten() {
|
||||
let user_path = user.path();
|
||||
if user_path.is_dir() {
|
||||
folders.push(user_path.join("AppData").join("Local").join("osu!"));
|
||||
folders.push(user_path.join("AppData").join("Roaming").join("osu!"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
folders
|
||||
}
|
||||
|
||||
fn collect_osu_folders(dir: &Path, depth: usize, max_depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth > max_depth {
|
||||
return;
|
||||
}
|
||||
|
||||
if dir.join("osu!.exe").is_file() {
|
||||
out.push(dir.to_path_buf());
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"windows" | "programdata" | "$recycle.bin" | "system volume information"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collect_osu_folders(&path, depth + 1, max_depth, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_drive_c_dirs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if metadata.file_type().is_symlink() {
|
||||
// Wineskin wrappers often add a convenience drive_c symlink at
|
||||
// the app root. Resolve it to the real prefix so WINEPREFIX points
|
||||
// at Contents/Resources instead of the bundle itself.
|
||||
if path.file_name() == Some(OsStr::new("drive_c")) {
|
||||
if let Ok(canonical) = fs::canonicalize(&path) {
|
||||
out.push(canonical);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !metadata.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.file_name() == Some(OsStr::new("drive_c")) {
|
||||
out.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Do not descend into nested apps (for example Wineskin.app).
|
||||
if path.extension() == Some(OsStr::new("app")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
find_drive_c_dirs(&path, depth - 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_for(osu_folder: &Path) -> Option<PathBuf> {
|
||||
let drive_c = osu_folder
|
||||
.ancestors()
|
||||
.find(|ancestor| ancestor.file_name() == Some(OsStr::new("drive_c")))?;
|
||||
canonical_prefix(drive_c)
|
||||
}
|
||||
|
||||
fn canonical_prefix(drive_c: &Path) -> Option<PathBuf> {
|
||||
let canonical = fs::canonicalize(drive_c).ok()?;
|
||||
canonical.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
fn app_bundle_for(prefix: &Path) -> Option<PathBuf> {
|
||||
prefix
|
||||
.ancestors()
|
||||
.find(|ancestor| ancestor.extension() == Some(OsStr::new("app")))
|
||||
.map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn app_bundles() -> Vec<PathBuf> {
|
||||
let mut roots = vec![PathBuf::from("/Applications")];
|
||||
if let Some(home) = home_dir() {
|
||||
roots.push(home.join("Applications"));
|
||||
}
|
||||
|
||||
let mut apps = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension() == Some(OsStr::new("app")) {
|
||||
apps.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps.sort();
|
||||
apps
|
||||
}
|
||||
|
||||
fn bottle_prefixes() -> Vec<PathBuf> {
|
||||
let Some(home) = home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let bottle_roots = [
|
||||
home.join("Library/Containers/com.isaacmarovitz.Whisky/Bottles"),
|
||||
home.join("Library/Application Support/CrossOver/Bottles"),
|
||||
home.join("Library/Application Support/Whisky/Bottles"),
|
||||
];
|
||||
|
||||
let mut prefixes = Vec::new();
|
||||
for root in bottle_roots {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.join("drive_c").is_dir() {
|
||||
prefixes.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prefixes.sort();
|
||||
prefixes
|
||||
}
|
||||
|
||||
fn find_wine_bin(prefix: &Path, app: Option<&Path>) -> Option<(PathBuf, Option<PathBuf>)> {
|
||||
let mut candidates: Vec<(PathBuf, Option<PathBuf>)> = Vec::new();
|
||||
|
||||
if let Some(app) = app {
|
||||
for root in [
|
||||
app.join("Contents/SharedSupport/wine"),
|
||||
app.join("Contents/Resources/WineskinEngine.bundle/Wine"),
|
||||
app.join("Contents/Frameworks/wswine.bundle"),
|
||||
] {
|
||||
push_wine_root(&mut candidates, root);
|
||||
}
|
||||
}
|
||||
|
||||
for root in [
|
||||
prefix.join("WineskinEngine.bundle/Wine"),
|
||||
prefix.join("Wine"),
|
||||
] {
|
||||
push_wine_root(&mut candidates, root);
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
for root in [
|
||||
home.join("Applications/Whisky.app/Contents/Resources/wine"),
|
||||
home.join("Applications/Whisky.app/Contents/Libraries/Wine"),
|
||||
] {
|
||||
push_wine_root(&mut candidates, root);
|
||||
}
|
||||
}
|
||||
|
||||
for root in [
|
||||
PathBuf::from("/Applications/Whisky.app/Contents/Resources/wine"),
|
||||
PathBuf::from("/Applications/Whisky.app/Contents/Libraries/Wine"),
|
||||
PathBuf::from("/Applications/CrossOver.app/Contents/SharedSupport/CrossOver"),
|
||||
] {
|
||||
push_wine_root(&mut candidates, root);
|
||||
}
|
||||
|
||||
for bin in [
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
] {
|
||||
if has_wine_loader(&bin) {
|
||||
candidates.push((bin, None));
|
||||
}
|
||||
}
|
||||
|
||||
for binary in ["wine64", "wine32on64", "wine"] {
|
||||
if let Some(path) = find_in_path(binary) {
|
||||
if let Some(bin) = path.parent() {
|
||||
candidates.push((bin.to_path_buf(), None));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|(bin, _)| has_wine_loader(bin))
|
||||
}
|
||||
|
||||
fn push_wine_root(candidates: &mut Vec<(PathBuf, Option<PathBuf>)>, root: PathBuf) {
|
||||
let bin = root.join("bin");
|
||||
if has_wine_loader(&bin) {
|
||||
candidates.push((bin, Some(root)));
|
||||
}
|
||||
}
|
||||
|
||||
fn has_wine_loader(bin: &Path) -> bool {
|
||||
["wine32on64", "wine64", "wine"]
|
||||
.iter()
|
||||
.any(|loader| bin.join(loader).is_file())
|
||||
}
|
||||
|
||||
fn find_in_path(binary: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(binary))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
fn pe_machine(exe: &Path) -> Option<u16> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = fs::File::open(exe).ok()?;
|
||||
let mut signature = [0u8; 2];
|
||||
file.read_exact(&mut signature).ok()?;
|
||||
if &signature != b"MZ" {
|
||||
return None;
|
||||
}
|
||||
|
||||
file.seek(SeekFrom::Start(0x3c)).ok()?;
|
||||
let mut offset = [0u8; 4];
|
||||
file.read_exact(&mut offset).ok()?;
|
||||
file.seek(SeekFrom::Start(u32::from_le_bytes(offset) as u64))
|
||||
.ok()?;
|
||||
|
||||
let mut header = [0u8; 6];
|
||||
file.read_exact(&mut header).ok()?;
|
||||
if &header[..4] != b"PE\0\0" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(u16::from_le_bytes([header[4], header[5]]))
|
||||
}
|
||||
@@ -104,12 +104,12 @@ impl PresenceActor {
|
||||
self.reconnecting = false;
|
||||
match result {
|
||||
ReconnectResult::Connected(client) => {
|
||||
println!("Presence: reconnect succeeded.");
|
||||
log::info!(target: "launcher", "Presence: reconnect succeeded");
|
||||
self.client = Some(client);
|
||||
self.push_presence().await;
|
||||
}
|
||||
ReconnectResult::Failed => {
|
||||
eprintln!("Presence: all reconnect attempts exhausted.");
|
||||
log::warn!(target: "launcher", "Presence: all reconnect attempts exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,10 @@ impl PresenceActor {
|
||||
let _ = resp.send(true);
|
||||
}
|
||||
None => {
|
||||
eprintln!("Presence: initial connect failed; spawning reconnect task.");
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"Presence: initial connect failed; spawning reconnect task"
|
||||
);
|
||||
let _ = resp.send(false);
|
||||
self.spawn_reconnect_task();
|
||||
}
|
||||
@@ -145,7 +148,7 @@ impl PresenceActor {
|
||||
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
|
||||
if let Some(mut client) = self.client.take() {
|
||||
let _ = client.clear_activity().await;
|
||||
println!("Presence: disconnected.");
|
||||
log::info!(target: "launcher", "Presence: disconnected");
|
||||
}
|
||||
let _ = resp.send(());
|
||||
}
|
||||
@@ -182,9 +185,12 @@ impl PresenceActor {
|
||||
|
||||
let activity = builder.build();
|
||||
|
||||
/* println!("Presence: updating activity: {:?}", activity); */
|
||||
if let Err(e) = client.set_activity(&activity).await {
|
||||
eprintln!("Presence: set_activity failed ({:?}); will reconnect.", e);
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"Presence: set_activity failed ({:?}); will reconnect",
|
||||
e
|
||||
);
|
||||
self.client = None;
|
||||
self.spawn_reconnect_task();
|
||||
}
|
||||
@@ -201,9 +207,12 @@ impl PresenceActor {
|
||||
const MAX_ATTEMPTS: u32 = 5;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let delay = Duration::from_secs(5 * attempt as u64);
|
||||
println!(
|
||||
log::info!(
|
||||
target: "launcher",
|
||||
"Presence: reconnect attempt {}/{} in {:?}…",
|
||||
attempt, MAX_ATTEMPTS, delay
|
||||
attempt,
|
||||
MAX_ATTEMPTS,
|
||||
delay
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
@@ -221,16 +230,16 @@ async fn try_connect_once() -> Option<AsyncDiscordIpcClient> {
|
||||
match AsyncDiscordIpcClient::new("1032772293220384808").await {
|
||||
Ok(mut client) => match client.connect().await {
|
||||
Ok(_) => {
|
||||
println!("Presence: IPC connected.");
|
||||
log::info!(target: "launcher", "Presence: IPC connected");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Presence: IPC connect error: {:?}", e);
|
||||
log::warn!(target: "launcher", "Presence: IPC connect error: {:?}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Presence: failed to create client: {:?}", e);
|
||||
log::warn!(target: "launcher", "Presence: failed to create client: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -256,7 +265,11 @@ pub async fn connect() -> bool {
|
||||
|
||||
pub async fn disconnect() {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await.is_ok() {
|
||||
if PRESENCE_TX
|
||||
.send(PresenceCommand::Disconnect(tx))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
|
||||
}
|
||||
}
|
||||
@@ -343,4 +356,4 @@ pub fn handle_run_event(_app: &AppHandle, event: &RunEvent) {
|
||||
disconnect().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,6 @@ use std::sync::Mutex;
|
||||
pub struct AppState {
|
||||
pub config: Mutex<Config>,
|
||||
pub opened_files: Mutex<Vec<PathBuf>>,
|
||||
/// Theme folder requested by the main window when it opens the editor.
|
||||
pub editor_theme_folder: Mutex<Option<String>>,
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//! Loopback HTTP server for theme assets on Linux.
|
||||
//!
|
||||
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
|
||||
//! protocol, and module scripts from that scheme are unreliable too. A real
|
||||
//! loopback HTTP server gives WebKit normal status codes, range responses and
|
||||
//! MIME types, so v1 and v2 theme assets load the same way they do in a
|
||||
//! browser.
|
||||
|
||||
use http_body_util::Full;
|
||||
use http_range::HttpRange;
|
||||
use hyper::body::{Bytes, Incoming};
|
||||
use hyper::header::{
|
||||
ACCEPT_RANGES, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
|
||||
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ORIGIN, RANGE, VARY,
|
||||
};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
type Body = Full<Bytes>;
|
||||
|
||||
/// Keep individual responses small enough that a media seek cannot allocate
|
||||
/// an unbounded buffer. WebKit asks for the next range as it plays.
|
||||
const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
struct ThemeServer {
|
||||
port: u16,
|
||||
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
}
|
||||
|
||||
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
|
||||
|
||||
/// Bind the server on a random loopback port. The root is the themes folder.
|
||||
pub fn start(root: PathBuf) -> Result<u16, String> {
|
||||
if let Some(server) = SERVER.get() {
|
||||
return Ok(server.port);
|
||||
}
|
||||
|
||||
let root = std::fs::canonicalize(&root)
|
||||
.map_err(|error| format!("Could not resolve the themes folder: {error}"))?;
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("Could not configure the theme asset server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("Could not read the theme asset server address: {error}"))?
|
||||
.port();
|
||||
|
||||
let previews = Arc::new(Mutex::new(HashMap::new()));
|
||||
let server = ThemeServer {
|
||||
port,
|
||||
previews: previews.clone(),
|
||||
};
|
||||
let _ = SERVER.set(server);
|
||||
|
||||
let root = Arc::new(root);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let listener = match TcpListener::from_std(listener) {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => {
|
||||
log::error!(target: "launcher", "Theme asset server failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
target: "launcher",
|
||||
"Theme asset server listening on http://127.0.0.1:{port}/themes"
|
||||
);
|
||||
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
log::debug!(target: "launcher", "Theme asset server accept error: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let root = root.clone();
|
||||
let previews = previews.clone();
|
||||
tokio::spawn(async move {
|
||||
let service =
|
||||
service_fn(move |request| handle(request, root.clone(), previews.clone()));
|
||||
|
||||
if let Err(error) = http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
{
|
||||
log::debug!(target: "launcher", "Theme asset connection ended: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
pub fn base_url() -> Option<String> {
|
||||
SERVER
|
||||
.get()
|
||||
.map(|server| format!("http://127.0.0.1:{}/themes", server.port))
|
||||
}
|
||||
|
||||
/// Register a file picked in the theme editor so its preview can be served
|
||||
/// over HTTP too. The token is only a lookup key; unregistered paths are never
|
||||
/// readable through the server.
|
||||
pub fn preview_url(path: &Path) -> Result<String, String> {
|
||||
let server = SERVER
|
||||
.get()
|
||||
.ok_or_else(|| "The theme asset server is not running".to_string())?;
|
||||
let path = std::fs::canonicalize(path)
|
||||
.map_err(|error| format!("Could not resolve the preview asset: {error}"))?;
|
||||
if !path.is_file() {
|
||||
return Err("The preview asset is not a file".to_string());
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(path.to_string_lossy().as_bytes());
|
||||
hasher.update(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
let token = hex::encode(&hasher.finalize()[..16]);
|
||||
|
||||
server
|
||||
.previews
|
||||
.lock()
|
||||
.map_err(|_| "Could not register the preview asset".to_string())?
|
||||
.insert(token.clone(), path);
|
||||
|
||||
Ok(format!("http://127.0.0.1:{}/preview/{token}", server.port))
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
request: Request<Incoming>,
|
||||
root: Arc<PathBuf>,
|
||||
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
) -> Result<Response<Body>, Infallible> {
|
||||
let origin = request
|
||||
.headers()
|
||||
.get(ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(allowed_origin);
|
||||
|
||||
if request.method() == Method::OPTIONS {
|
||||
return Ok(preflight_response(origin.as_deref()));
|
||||
}
|
||||
|
||||
if request.method() != Method::GET && request.method() != Method::HEAD {
|
||||
return Ok(text_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"Method not allowed",
|
||||
origin.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
let range = request
|
||||
.headers()
|
||||
.get(RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let path = percent_encoding::percent_decode_str(request.uri().path())
|
||||
.decode_utf8_lossy()
|
||||
.to_string();
|
||||
|
||||
let file = if let Some(relative) = path.strip_prefix("/themes/") {
|
||||
safe_theme_path(&root, relative)
|
||||
} else if let Some(token) = path.strip_prefix("/preview/") {
|
||||
previews
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|previews| previews.get(token).cloned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = match file {
|
||||
Some(file) => {
|
||||
serve_file(request.method(), &file, range.as_deref(), origin.as_deref()).await
|
||||
}
|
||||
None => Ok(text_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Not found",
|
||||
origin.as_deref(),
|
||||
)),
|
||||
};
|
||||
|
||||
Ok(response.unwrap_or_else(|message| {
|
||||
log::warn!(target: "launcher", "Theme asset request failed: {message}");
|
||||
text_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&message,
|
||||
origin.as_deref(),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn safe_theme_path(root: &Path, relative: &str) -> Option<PathBuf> {
|
||||
let relative = relative.trim_start_matches('/');
|
||||
if relative.is_empty()
|
||||
|| relative
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidate = root.join(relative);
|
||||
let canonical = std::fs::canonicalize(candidate).ok()?;
|
||||
canonical.starts_with(root).then_some(canonical)
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
method: &Method,
|
||||
path: &Path,
|
||||
range_header: Option<&str>,
|
||||
origin: Option<&str>,
|
||||
) -> Result<Response<Body>, String> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|error| format!("Could not read theme asset: {error}"))?;
|
||||
if !metadata.is_file() {
|
||||
return Ok(text_response(StatusCode::NOT_FOUND, "Not found", origin));
|
||||
}
|
||||
|
||||
let length = metadata.len();
|
||||
let range = match range_header {
|
||||
Some(header) => match parse_range(header, length) {
|
||||
Ok(range) => range,
|
||||
Err(()) => return Ok(range_not_satisfiable(length, origin)),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let (status, start, end) = match range {
|
||||
Some((start, end)) => (StatusCode::PARTIAL_CONTENT, start, end),
|
||||
None => (StatusCode::OK, 0, length.saturating_sub(1)),
|
||||
};
|
||||
|
||||
let body_length = if length == 0 {
|
||||
0
|
||||
} else {
|
||||
end.saturating_sub(start) + 1
|
||||
};
|
||||
|
||||
let body = if method == Method::HEAD || body_length == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.map_err(|error| format!("Could not open theme asset: {error}"))?;
|
||||
if start > 0 {
|
||||
file.seek(SeekFrom::Start(start))
|
||||
.await
|
||||
.map_err(|error| format!("Could not seek theme asset: {error}"))?;
|
||||
}
|
||||
let mut buffer = vec![0_u8; body_length as usize];
|
||||
file.read_exact(&mut buffer)
|
||||
.await
|
||||
.map_err(|error| format!("Could not read theme asset: {error}"))?;
|
||||
buffer
|
||||
};
|
||||
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(CONTENT_TYPE, content_type(path))
|
||||
.header(CONTENT_LENGTH, body_length.to_string())
|
||||
.header(ACCEPT_RANGES, "bytes")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
|
||||
if status == StatusCode::PARTIAL_CONTENT {
|
||||
builder = builder.header(CONTENT_RANGE, format!("bytes {start}-{end}/{length}"));
|
||||
}
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin")
|
||||
.header(
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||
"Content-Range, Content-Length, Accept-Ranges",
|
||||
);
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(body.into()))
|
||||
.map_err(|error| format!("Could not build theme asset response: {error}"))
|
||||
}
|
||||
|
||||
fn parse_range(header: &str, length: u64) -> Result<Option<(u64, u64)>, ()> {
|
||||
if length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let ranges = HttpRange::parse(header, length).map_err(|_| ())?;
|
||||
if ranges.len() != 1 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let range = &ranges[0];
|
||||
let start = range.start;
|
||||
let end = (start + range.length - 1).min(length - 1);
|
||||
if start >= length || end < start {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
// Don't allocate the entire file for a single open-ended range request.
|
||||
Ok(Some((start, end.min(start + MAX_RANGE_BYTES - 1))))
|
||||
}
|
||||
|
||||
fn range_not_satisfiable(length: u64, origin: Option<&str>) -> Response<Body> {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(CONTENT_RANGE, format!("bytes */{length}"))
|
||||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.header(CONTENT_LENGTH, "0");
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin");
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(Bytes::new()))
|
||||
.expect("range response is valid")
|
||||
}
|
||||
|
||||
fn preflight_response(origin: Option<&str>) -> Response<Body> {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
|
||||
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
|
||||
.header(
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||
"Content-Range, Content-Length, Accept-Ranges",
|
||||
)
|
||||
.header(ACCESS_CONTROL_MAX_AGE, "600");
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin");
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(Bytes::new()))
|
||||
.expect("preflight response is valid")
|
||||
}
|
||||
|
||||
fn text_response(status: StatusCode, message: &str, origin: Option<&str>) -> Response<Body> {
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.header(CONTENT_LENGTH, message.len().to_string())
|
||||
.header("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin");
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(Bytes::copy_from_slice(message.as_bytes())))
|
||||
.expect("text response is valid")
|
||||
}
|
||||
|
||||
fn allowed_origin(origin: &str) -> Option<String> {
|
||||
if origin == "tauri://localhost"
|
||||
|| origin == "http://tauri.localhost"
|
||||
|| origin == "https://tauri.localhost"
|
||||
|| origin == "null"
|
||||
|| origin.starts_with("http://localhost:")
|
||||
|| origin.starts_with("http://127.0.0.1:")
|
||||
{
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"html" | "htm" => "text/html; charset=utf-8",
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"avif" => "image/avif",
|
||||
"ico" => "image/x-icon",
|
||||
"webm" => "video/webm",
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"ogv" => "video/ogg",
|
||||
"mp3" => "audio/mpeg",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" => "audio/mp4",
|
||||
"flac" => "audio/flac",
|
||||
"woff2" => "font/woff2",
|
||||
"woff" => "font/woff",
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
"wasm" => "application/wasm",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,112 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
use sysinfo::Pid;
|
||||
|
||||
pub const OSU_FOLDER_FILES: &[&str] = &[
|
||||
"avcodec-51.dll",
|
||||
"avformat-52.dll",
|
||||
"avutil-49.dll",
|
||||
"bass.dll",
|
||||
"bass_fx.dll",
|
||||
"collection.db",
|
||||
"d3dcompiler_47.dll",
|
||||
"libEGL.dll",
|
||||
"libGLESv2.dll",
|
||||
"Microsoft.Ink.dll",
|
||||
"OpenTK.dll",
|
||||
"osu!.cfg",
|
||||
"osu!.db",
|
||||
"osu!.exe",
|
||||
"osu!auth.dll",
|
||||
"osu!gameplay.dll",
|
||||
"osu!seasonal.dll",
|
||||
"osu!ui.dll",
|
||||
"presence.db",
|
||||
"pthreadGC2.dll",
|
||||
"scores.db",
|
||||
];
|
||||
|
||||
pub fn is_valid_osu_folder<P: AsRef<Path>>(folder_path: P) -> bool {
|
||||
let folder_path = folder_path.as_ref();
|
||||
folder_path.join("osu!.exe").is_file()
|
||||
&& check_folder_completeness(folder_path, OSU_FOLDER_FILES) >= 70.0
|
||||
}
|
||||
|
||||
/// True if the process is osu! (either running natively or through Wine/Proton).
|
||||
pub fn is_osu_process(process: &sysinfo::Process) -> bool {
|
||||
let name = process.name().to_string_lossy().to_ascii_lowercase();
|
||||
if name.ends_with("osu!.exe") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(exe) = process.exe() {
|
||||
if exe
|
||||
.file_name()
|
||||
.map(|file_name| file_name.to_string_lossy().to_ascii_lowercase() == "osu!.exe")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
process.cmd().iter().any(|arg| {
|
||||
arg.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("osu!.exe")
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`is_osu_process`], but excludes the osu! updater started with `-repair`.
|
||||
pub fn is_osu_game_process(process: &sysinfo::Process) -> bool {
|
||||
is_osu_process(process)
|
||||
&& !process.cmd().iter().any(|arg| {
|
||||
arg.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("-repair")
|
||||
})
|
||||
}
|
||||
|
||||
fn find_osu_user_config_path<P: AsRef<Path>>(folder_path: P) -> Option<PathBuf> {
|
||||
let folder_path = folder_path.as_ref();
|
||||
if !folder_path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(current_user) = std::env::var("USERNAME").or_else(|_| std::env::var("USER")) {
|
||||
let config_path = folder_path.join(format!("osu!.{}.cfg", current_user));
|
||||
if config_path.is_file() {
|
||||
return Some(config_path);
|
||||
}
|
||||
}
|
||||
|
||||
// The Windows username inside a Wine prefix does not always match the host
|
||||
// username (especially on macOS), so fall back to the most recent osu!.*.cfg.
|
||||
let mut candidates = fs::read_dir(folder_path)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
|
||||
if !file_name.starts_with("osu!.") || !file_name.ends_with(".cfg") {
|
||||
return None;
|
||||
}
|
||||
if file_name == "osu!.cfg" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
Some((modified, path))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
candidates.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
candidates.into_iter().next().map(|(_, path)| path)
|
||||
}
|
||||
|
||||
pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files: &[&str]) -> f32 {
|
||||
let mut found = 0;
|
||||
for file in required_files {
|
||||
@@ -20,20 +125,7 @@ pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files:
|
||||
pub fn get_osu_user_config<P: AsRef<Path>>(
|
||||
osu_folder_path: P,
|
||||
) -> Option<std::collections::HashMap<String, String>> {
|
||||
if !osu_folder_path.as_ref().exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current_user = std::env::var("USERNAME")
|
||||
.or_else(|_| std::env::var("USER"))
|
||||
.unwrap_or_else(|_| "Admin".to_string());
|
||||
let osu_config_path = osu_folder_path
|
||||
.as_ref()
|
||||
.join(format!("osu!.{}.cfg", current_user));
|
||||
println!("Looking for user config at: {:?}", osu_config_path);
|
||||
if !osu_config_path.exists() {
|
||||
return None;
|
||||
}
|
||||
let osu_config_path = find_osu_user_config_path(osu_folder_path)?;
|
||||
|
||||
let mut config_map = std::collections::HashMap::new();
|
||||
if let Ok(contents) = std::fs::read_to_string(osu_config_path) {
|
||||
@@ -44,21 +136,15 @@ pub fn get_osu_user_config<P: AsRef<Path>>(
|
||||
}
|
||||
}
|
||||
|
||||
return Some(config_map);
|
||||
Some(config_map)
|
||||
}
|
||||
|
||||
pub fn set_osu_user_config_vals(
|
||||
osu_folder_path: &str,
|
||||
key_values: &[(&str, Option<&str>)],
|
||||
) -> Result<bool, String> {
|
||||
let current_user = std::env::var("USERNAME")
|
||||
.or_else(|_| std::env::var("USER"))
|
||||
.unwrap_or_else(|_| "Admin".to_string());
|
||||
let osu_config_path = Path::new(osu_folder_path).join(format!("osu!.{}.cfg", current_user));
|
||||
|
||||
if !osu_config_path.exists() {
|
||||
return Err("osu! user config file does not exist".to_string());
|
||||
}
|
||||
let osu_config_path = find_osu_user_config_path(osu_folder_path)
|
||||
.ok_or_else(|| "osu! user config file does not exist".to_string())?;
|
||||
|
||||
let mut lines = fs::read_to_string(&osu_config_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -178,7 +264,7 @@ pub fn get_osu_config<P: AsRef<Path>>(
|
||||
return Some(config_map);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||
let mut search_paths =
|
||||
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect::<Vec<_>>();
|
||||
@@ -203,22 +289,22 @@ pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||
.find(|path| path.is_file())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_osuwinello_available() -> bool {
|
||||
get_osuwinello_path().is_some()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn is_osuwinello_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_wmctrl_available() -> bool {
|
||||
use std::process::Command;
|
||||
Command::new("wmctrl")
|
||||
@@ -229,12 +315,12 @@ pub fn is_wmctrl_available() -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn is_wmctrl_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
||||
use std::process::Command;
|
||||
let find_title = || -> Option<String> {
|
||||
@@ -266,6 +352,11 @@ pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
||||
find_title().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_window_title_by_pid(_pid: Pid) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_window_title_by_pid(pid: Pid) -> String {
|
||||
use std::ffi::OsString;
|
||||
@@ -330,11 +421,16 @@ pub async fn is_net8_installed() -> bool {
|
||||
match output_result {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"Error: `dotnet --list-runtimes` failed with status: {}",
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"`dotnet --list-runtimes` failed with status: {}",
|
||||
output.status
|
||||
);
|
||||
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"dotnet stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,24 +28,27 @@
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' tauri: customprotocol: asset:",
|
||||
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost https://analytics.ez-pp.farm",
|
||||
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost",
|
||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
|
||||
"connect-src": "'self' ipc: http://ipc.localhost https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh",
|
||||
"media-src": "'self' asset: http://asset.localhost https://asset.localhost blob:",
|
||||
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
|
||||
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:*",
|
||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http://127.0.0.1:* https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
|
||||
"connect-src": "'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh https://api.fontsource.org https://cdn.jsdelivr.net",
|
||||
"media-src": "'self' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* blob:",
|
||||
"font-src": [
|
||||
"'self'",
|
||||
"asset:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"http://asset.localhost",
|
||||
"https://asset.localhost"
|
||||
"https://asset.localhost",
|
||||
"http://127.0.0.1:*",
|
||||
"https://cdn.jsdelivr.net"
|
||||
]
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
"$HOME/.ezpplauncher/themes/**"
|
||||
"$HOME/.ezpplauncher/themes/**",
|
||||
"$HOME/.ezpplauncher/font-cache/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"targets": ["app", "dmg"],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||