chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard

This commit is contained in:
HorizonCode committed 2026-10-29 10:53:12 +01:00
1 parent 417f54a94a
commit 87202de656
97 files changed
+10968 -1422

No files matched your search

+84
View File
@@ -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",
+13
View File
@@ -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"
+3 -2
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+761 -276
View File
File diff suppressed because it is too large. Load diff
+65 -12
View File
@@ -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);
});
}
+537
View File
@@ -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(&current, 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();
}
}
+801
View File
@@ -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]]))
}
+26 -13
View File
@@ -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;
});
}
}
}
+2
View File
@@ -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>>,
}
+436
View File
@@ -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",
}
}
+129 -33
View File
@@ -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;
}
+10 -7
View File
@@ -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/**"
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}