feat: polish theme tooling, diagnostics and launcher workflows

Expand the theme editor with multi-select, snapping, context menus, draft
recovery and export validation; add theme backups and trust prompts. Add
in-app diagnostics and rotating log bundle viewing, hardware-accelerated
theme video playback on macOS, and refresh the settings, setup wizard and
theme gallery layouts.
This commit is contained in:
HorizonCode committed 2026-10-29 15:41:33 +01:00
1 parent 87202de656
commit c02b26e8ae
41 files changed
+5574 -865

No files matched your search

+1 -1
View File
@@ -60,7 +60,7 @@ tauri-plugin-log = "2"
log = "0.4"
time = { version = "0.3", features = ["local-offset"] }
[target.'cfg(target_os = "linux")'.dependencies]
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
http-body-util = "0.1.3"
http-range = "0.1.5"
hyper = { version = "1.8.1", features = ["server", "http1"] }
+859 -10
View File
@@ -77,6 +77,14 @@ pub fn open_logs_folder() -> Result<(), String> {
open::that(&dir).map_err(|error| format!("Failed to open the logs folder: {error}"))
}
/// Return the byte size of a regular file. Used by the theme editor to
/// validate and size picked assets without granting the webview broad fs reads.
#[tauri::command]
pub fn file_size(path: String) -> Option<u64> {
let metadata = std::fs::metadata(path).ok()?;
metadata.is_file().then_some(metadata.len())
}
#[tauri::command]
pub fn get_hwid() -> String {
get_id()
@@ -135,6 +143,135 @@ fn normalized_osu_folder(folder: &str) -> Option<PathBuf> {
is_valid_osu_folder(&path).then_some(path)
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InstallationDiagnostics {
pub platform: String,
pub os_version: Option<String>,
pub os_arch: String,
pub launcher_version: String,
pub osu_folder: String,
pub resolved_folder: Option<String>,
pub osu_writable: Option<bool>,
pub patcher_path: Option<String>,
pub patcher_installed: bool,
pub patcher_file_count: usize,
pub patcher_total_size: u64,
pub host_net8: bool,
pub osuwinello: bool,
pub wmctrl: bool,
pub wine_prefix: Option<String>,
pub wine_binary: Option<String>,
pub wine_app_bundle: Option<String>,
pub wine_version: Option<String>,
pub wine_prefix_arch: Option<String>,
pub wine_net8: Option<bool>,
pub wine_error: Option<String>,
pub ffmpeg_status: Option<String>,
pub logs_directory: String,
}
fn directory_is_writable(path: &Path) -> bool {
let probe = path.join(format!(".ezpplauncher-write-test-{}", std::process::id()));
let _ = std::fs::remove_file(&probe);
let writable = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
.is_ok();
let _ = std::fs::remove_file(&probe);
writable
}
fn osu_install_writable(osu_folder: &Path) -> bool {
if !directory_is_writable(osu_folder) {
return false;
}
let support_folder = osu_folder.join("EZPPLauncher");
!support_folder.is_dir() || directory_is_writable(&support_folder)
}
fn patcher_file_stats(osu_folder: &Path) -> (usize, u64) {
let Ok(entries) = std::fs::read_dir(osu_folder.join("EZPPLauncher/patcher")) else {
return (0, 0);
};
entries
.flatten()
.filter_map(|entry| {
let metadata = entry.metadata().ok()?;
metadata.is_file().then_some(metadata.len())
})
.fold((0, 0), |(count, total), size| (count + 1, total + size))
}
/// Collect the installation details shown in Settings → Diagnostics.
#[tauri::command]
pub async fn installation_diagnostics(app: AppHandle, folder: String) -> InstallationDiagnostics {
let resolved = normalized_osu_folder(&folder);
let resolved_folder = resolved
.as_ref()
.map(|path| path.to_string_lossy().into_owned());
let patcher_path = resolved
.as_ref()
.map(|path| path.join("EZPPLauncher/patcher/osu!.patcher.exe"));
let (patcher_file_count, patcher_total_size) = resolved
.as_deref()
.map(patcher_file_stats)
.unwrap_or_default();
let mut diagnostics = InstallationDiagnostics {
platform: std::env::consts::OS.to_string(),
os_version: System::long_os_version(),
os_arch: std::env::consts::ARCH.to_string(),
launcher_version: app.package_info().version.to_string(),
osu_folder: folder,
resolved_folder,
osu_writable: resolved.as_deref().map(osu_install_writable),
patcher_installed: patcher_path
.as_ref()
.map(|path| path.is_file())
.unwrap_or(false),
patcher_file_count,
patcher_total_size,
patcher_path: patcher_path.map(|path| path.to_string_lossy().into_owned()),
host_net8: is_net8_installed().await,
osuwinello: is_osuwinello_available(),
wmctrl: is_wmctrl_available(),
wine_prefix: None,
wine_binary: None,
wine_app_bundle: None,
wine_version: None,
wine_prefix_arch: None,
wine_net8: None,
wine_error: None,
ffmpeg_status: None,
logs_directory: logging::logs_directory().to_string_lossy().into_owned(),
};
#[cfg(target_os = "macos")]
if let Some(path) = resolved {
match crate::macos::WineDiagnostics::for_osu_folder(&path) {
Ok(wine) => {
diagnostics.wine_prefix = Some(wine.prefix.to_string_lossy().into_owned());
diagnostics.wine_binary = Some(wine.wine_bin.to_string_lossy().into_owned());
diagnostics.wine_app_bundle = wine
.app_bundle
.map(|bundle| bundle.to_string_lossy().into_owned());
diagnostics.wine_version = wine.wine_version;
diagnostics.wine_prefix_arch = wine.prefix_arch;
diagnostics.wine_net8 = Some(wine.net8_installed);
}
Err(error) => diagnostics.wine_error = Some(error),
}
diagnostics.ffmpeg_status = theme_ffmpeg_status();
}
diagnostics
}
#[tauri::command]
pub fn find_osu_installation() -> Option<String> {
find_osu_installation_impl()
@@ -1415,12 +1552,19 @@ pub(crate) fn theme_root() -> Result<PathBuf, String> {
std::fs::canonicalize(&root).map_err(|e| format!("Failed to resolve the themes directory: {e}"))
}
#[cfg(target_os = "linux")]
fn linux_theme_asset_base() -> String {
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn loopback_theme_asset_base() -> String {
if crate::theme_server::base_url().is_none() {
if let Ok(root) = theme_root() {
if let Err(error) = crate::theme_server::start(root) {
log::warn!(target: "launcher", "Could not start the theme asset server: {error}");
match crate::theme_server::media_root() {
Ok(media) => {
if let Err(error) = crate::theme_server::start(root, media) {
log::warn!(target: "launcher", "Could not start the theme asset server: {error}");
}
}
Err(error) => {
log::warn!(target: "launcher", "Could not prepare the media cache: {error}");
}
}
}
}
@@ -1429,12 +1573,12 @@ fn linux_theme_asset_base() -> String {
#[tauri::command]
pub fn theme_asset_base() -> String {
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
return linux_theme_asset_base();
return loopback_theme_asset_base();
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
String::new()
}
@@ -1442,19 +1586,545 @@ pub fn theme_asset_base() -> String {
#[tauri::command]
pub fn theme_preview_url(path: String) -> Result<String, String> {
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let _ = linux_theme_asset_base();
let _ = loopback_theme_asset_base();
return crate::theme_server::preview_url(Path::new(&path));
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = path;
Ok(String::new())
}
}
/// Return a hardware-decodable copy of a large VP9/AV1 theme video.
///
/// WebKit on macOS decodes VP9 in software. A 1080p 10 Mbps WebM can use
/// enough CPU to starve the audio decoder, while H.264 is decoded by the
/// media engine. The first playback of a large theme video is transcoded to a
/// cached 720p H.264/AAC file; later loads return the cached URL directly.
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThemeMediaProgress {
pub stage: String,
pub progress: f64,
}
#[cfg(target_os = "macos")]
const FFMPEG_VERSION: &str = "9.0.2";
#[cfg(target_os = "macos")]
struct FfmpegBuild {
url: &'static str,
sha256: &'static str,
}
#[cfg(target_os = "macos")]
fn ffmpeg_build() -> Result<FfmpegBuild, String> {
match std::env::consts::ARCH {
"aarch64" => Ok(FfmpegBuild {
url: "https://ffmpeg.martin-riedl.de/download/macos/arm64/1789931890_9.0.2/ffmpeg.zip",
sha256: "c8ed4c4e6978a03c485edbfe4e0a5dc2380f8a30bba5150531b31b094492d924",
}),
"x86_64" => Ok(FfmpegBuild {
url: "https://ffmpeg.martin-riedl.de/download/macos/amd64/1789931006_9.0.2/ffmpeg.zip",
sha256: "7c6b4125b191cbf773832dc51f424cf2b6bb7da43007d1e066f95909e47cacd4",
}),
arch => Err(format!(
"No FFmpeg build is available for the macOS architecture {arch}"
)),
}
}
#[tauri::command]
pub async fn optimize_theme_media(app: AppHandle, source_url: String) -> Result<String, String> {
#[cfg(not(target_os = "macos"))]
{
let _ = app;
return Ok(source_url);
}
#[cfg(target_os = "macos")]
{
let Some(source) = crate::theme_server::resolve_theme_url(&source_url) else {
return Ok(source_url);
};
let metadata = match tokio::fs::metadata(&source).await {
Ok(metadata) => metadata,
Err(error) => {
log::warn!(target: "launcher", "Could not inspect theme media: {error}");
return Ok(source_url);
}
};
if metadata.len() < 20 * 1024 * 1024 {
return Ok(source_url);
}
let (cache_name, destination) = match theme_media_cache_path(&source, &metadata) {
Ok(cache) => cache,
Err(error) => {
log::warn!(target: "launcher", "Could not prepare the media cache: {error}");
return Ok(source_url);
}
};
if destination
.metadata()
.map(|meta| meta.is_file() && meta.len() > 1024)
.unwrap_or(false)
{
return Ok(crate::theme_server::media_url(&cache_name)
.unwrap_or_else(|| source_url.to_string()));
}
let ffmpeg = match ensure_theme_ffmpeg(&app).await {
Ok(path) => path,
Err(error) => {
log::warn!(target: "launcher", "Theme video optimization is unavailable: {error}");
return Ok(source_url);
}
};
let _ = app.emit(
"theme-media-progress",
ThemeMediaProgress {
stage: "optimize".to_string(),
progress: 0.0,
},
);
let original = source_url.clone();
tokio::task::spawn_blocking(move || {
optimize_theme_media_blocking(&ffmpeg, &source, &original, &cache_name, &destination)
})
.await
.map_err(|error| format!("Theme media optimizer thread failed: {error}"))?
}
}
#[cfg(target_os = "macos")]
fn theme_media_cache_path(
source: &Path,
metadata: &std::fs::Metadata,
) -> Result<(String, PathBuf), String> {
use sha2::{Digest, Sha256};
let cache_root = crate::theme_server::media_root()?;
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let mut hasher = Sha256::new();
hasher.update(source.to_string_lossy().as_bytes());
hasher.update(metadata.len().to_le_bytes());
hasher.update(modified.to_le_bytes());
hasher.update(b"h264-720-v1");
let cache_name = format!("{}.mp4", hex::encode(&hasher.finalize()[..16]));
let destination = cache_root.join(&cache_name);
Ok((cache_name, destination))
}
#[cfg(target_os = "macos")]
fn theme_tools_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Could not locate the home directory".to_string())?;
let directory = PathBuf::from(home).join(".ezpplauncher").join("bin");
std::fs::create_dir_all(&directory)
.map_err(|error| format!("Failed to create the theme media tools folder: {error}"))?;
std::fs::canonicalize(&directory)
.map_err(|error| format!("Failed to resolve the theme media tools folder: {error}"))
}
#[cfg(target_os = "macos")]
fn verify_ffmpeg(path: &Path) -> bool {
std::process::Command::new(path)
.arg("-version")
.output()
.map(|output| {
output.status.success()
&& String::from_utf8_lossy(&output.stdout).starts_with("ffmpeg version")
})
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn theme_ffmpeg_status() -> Option<String> {
let home = std::env::var_os("HOME")?;
let managed = PathBuf::from(home).join(".ezpplauncher/bin/ffmpeg");
if managed.is_file() && verify_ffmpeg(&managed) {
return Some(format!(
"Managed FFmpeg {FFMPEG_VERSION} at {}",
managed.display()
));
}
find_media_binary("ffmpeg")
.filter(|path| verify_ffmpeg(path))
.map(|path| format!("System FFmpeg at {}", path.display()))
}
#[cfg(target_os = "macos")]
static FFMPEG_DOWNLOAD_LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> =
std::sync::OnceLock::new();
#[cfg(target_os = "macos")]
async fn ensure_theme_ffmpeg(app: &AppHandle) -> Result<PathBuf, String> {
let managed = theme_tools_dir()?.join("ffmpeg");
if managed.is_file() && verify_ffmpeg(&managed) {
return Ok(managed);
}
if managed.exists() {
let _ = std::fs::remove_file(&managed);
}
match download_ffmpeg(app).await {
Ok(path) => Ok(path),
Err(download_error) => {
log::warn!(target: "launcher", "Could not download ffmpeg: {download_error}");
find_media_binary("ffmpeg")
.filter(|path| verify_ffmpeg(path))
.ok_or_else(|| {
format!("ffmpeg is not installed and the download failed: {download_error}")
})
}
}
}
#[cfg(target_os = "macos")]
async fn download_ffmpeg(app: &AppHandle) -> Result<PathBuf, String> {
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
let build = ffmpeg_build()?;
let directory = theme_tools_dir()?;
let target = directory.join("ffmpeg");
let lock = FFMPEG_DOWNLOAD_LOCK.get_or_init(|| tokio::sync::Mutex::new(()));
let _guard = lock.lock().await;
if target.is_file() && verify_ffmpeg(&target) {
return Ok(target);
}
let zip_path = directory.join("ffmpeg.zip.part");
let binary_path = directory.join("ffmpeg.part");
let _ = std::fs::remove_file(&zip_path);
let _ = std::fs::remove_file(&binary_path);
log::info!(
target: "launcher",
"Downloading FFmpeg {FFMPEG_VERSION} for theme video optimization"
);
let _ = app.emit(
"theme-media-progress",
ThemeMediaProgress {
stage: "download".to_string(),
progress: 0.0,
},
);
let client = Client::builder()
.user_agent(concat!("EZPPLauncher/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| format!("Could not create the FFmpeg download client: {error}"))?;
let response = client
.get(build.url)
.send()
.await
.map_err(|error| format!("Could not download FFmpeg: {error}"))?
.error_for_status()
.map_err(|error| format!("FFmpeg download failed: {error}"))?;
let total = response.content_length().unwrap_or(0);
let mut file = tokio::fs::File::create(&zip_path)
.await
.map_err(|error| format!("Could not create the FFmpeg archive: {error}"))?;
let mut hasher = Sha256::new();
let mut received = 0_u64;
let mut last_percent = u8::MAX;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|error| format!("Could not read the FFmpeg download: {error}"))?;
received += chunk.len() as u64;
hasher.update(&chunk);
file.write_all(&chunk)
.await
.map_err(|error| format!("Could not write the FFmpeg archive: {error}"))?;
if total > 0 {
let percent = ((received.min(total) * 100) / total) as u8;
if percent != last_percent {
last_percent = percent;
let _ = app.emit(
"theme-media-progress",
ThemeMediaProgress {
stage: "download".to_string(),
progress: percent as f64 / 100.0,
},
);
}
}
}
file.flush()
.await
.map_err(|error| format!("Could not finish the FFmpeg archive: {error}"))?;
drop(file);
let digest = hex::encode(hasher.finalize());
if digest != build.sha256 {
let _ = std::fs::remove_file(&zip_path);
return Err("The downloaded FFmpeg archive failed its SHA-256 check".to_string());
}
let _ = app.emit(
"theme-media-progress",
ThemeMediaProgress {
stage: "extract".to_string(),
progress: 0.0,
},
);
let zip_for_task = zip_path.clone();
let binary_for_task = binary_path.clone();
let target_for_task = target.clone();
tokio::task::spawn_blocking(move || -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let file = std::fs::File::open(&zip_for_task)
.map_err(|error| format!("Could not open the FFmpeg archive: {error}"))?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|error| format!("Could not read the FFmpeg archive: {error}"))?;
let mut entry = archive
.by_name("ffmpeg")
.map_err(|error| format!("The FFmpeg archive does not contain the binary: {error}"))?;
let mut output = std::fs::File::create(&binary_for_task)
.map_err(|error| format!("Could not create the FFmpeg binary: {error}"))?;
std::io::copy(&mut entry, &mut output)
.map_err(|error| format!("Could not extract the FFmpeg binary: {error}"))?;
drop(output);
std::fs::set_permissions(&binary_for_task, std::fs::Permissions::from_mode(0o755))
.map_err(|error| format!("Could not make FFmpeg executable: {error}"))?;
std::fs::rename(&binary_for_task, &target_for_task)
.map_err(|error| format!("Could not install FFmpeg: {error}"))?;
let _ = std::fs::remove_file(&zip_for_task);
Ok(())
})
.await
.map_err(|error| format!("FFmpeg extraction thread failed: {error}"))??;
if !verify_ffmpeg(&target) {
let _ = std::fs::remove_file(&target);
return Err("The downloaded FFmpeg binary could not be executed".to_string());
}
log::info!(
target: "launcher",
"FFmpeg {FFMPEG_VERSION} installed at {}",
target.display()
);
Ok(target)
}
#[cfg(target_os = "macos")]
fn find_media_binary(name: &str) -> Option<PathBuf> {
let variable = format!("EZPP_{}", name.to_ascii_uppercase());
if let Some(value) = std::env::var_os(variable) {
let path = PathBuf::from(value);
if path.is_file() {
return Some(path);
}
}
let mut candidates = Vec::new();
if let Some(path) = std::env::var_os("PATH") {
candidates.extend(std::env::split_paths(&path).map(|directory| directory.join(name)));
}
for directory in [
"/opt/homebrew/bin",
"/usr/local/bin",
"/opt/local/bin",
"/usr/bin",
] {
candidates.push(PathBuf::from(directory).join(name));
}
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(target_os = "macos")]
fn optimize_theme_media_blocking(
ffmpeg: &Path,
source: &Path,
source_url: &str,
cache_name: &str,
destination: &Path,
) -> Result<String, String> {
use std::process::Command;
let probe = Command::new(ffmpeg)
.args(["-hide_banner", "-i"])
.arg(source)
.output()
.map_err(|error| format!("Could not probe theme media: {error}"))?;
let probe_text = String::from_utf8_lossy(&probe.stderr);
let codec = probe_text.lines().find_map(|line| {
let index = line.find("Video: ")?;
line[index + 7..]
.split(|character: char| character == ',' || character == ' ')
.next()
.map(str::to_ascii_lowercase)
});
let Some(codec) = codec else {
return Ok(source_url.to_string());
};
if codec == "h264" || codec == "hevc" {
return Ok(source_url.to_string());
}
let temporary = destination.with_extension("mp4.tmp");
let _ = std::fs::remove_file(&temporary);
log::info!(
target: "launcher",
"Optimizing theme video for hardware playback ({} -> 720p H.264)",
source.display()
);
let started = std::time::Instant::now();
let mut command = Command::new(ffmpeg);
command.args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"]);
command.arg(source);
command.args([
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-vf",
"scale=-2:720",
"-pix_fmt",
"yuv420p",
"-c:v",
"h264_videotoolbox",
"-b:v",
"3500k",
"-maxrate",
"4500k",
"-bufsize",
"7000k",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
"-map_metadata",
"-1",
"-f",
"mp4",
]);
command.arg(&temporary);
let first = command.output();
let success = match first {
Ok(output) if output.status.success() => true,
Ok(output) => {
log::warn!(
target: "launcher",
"Hardware theme video encode failed: {}",
String::from_utf8_lossy(&output.stderr)
.chars()
.take(500)
.collect::<String>()
);
false
}
Err(error) => {
log::warn!(target: "launcher", "Could not run ffmpeg: {error}");
false
}
};
let success = if success {
true
} else {
let _ = std::fs::remove_file(&temporary);
let mut fallback = Command::new(ffmpeg);
fallback.args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"]);
fallback.arg(source);
fallback.args([
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-vf",
"scale=-2:720",
"-pix_fmt",
"yuv420p",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"22",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
"-map_metadata",
"-1",
"-f",
"mp4",
]);
fallback.arg(&temporary);
match fallback.output() {
Ok(output) if output.status.success() => true,
Ok(output) => {
log::warn!(
target: "launcher",
"Software theme video encode failed: {}",
String::from_utf8_lossy(&output.stderr)
.chars()
.take(500)
.collect::<String>()
);
false
}
Err(error) => {
log::warn!(target: "launcher", "Could not run the fallback ffmpeg encode: {error}");
false
}
}
};
if !success {
let _ = std::fs::remove_file(&temporary);
return Ok(source_url.to_string());
}
std::fs::rename(&temporary, destination)
.map_err(|error| format!("Could not store the optimized theme video: {error}"))?;
log::info!(
target: "launcher",
"Optimized theme video in {:.1}s: {}",
started.elapsed().as_secs_f64(),
destination.display()
);
Ok(crate::theme_server::media_url(cache_name).unwrap_or_else(|| source_url.to_string()))
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThemeEditorContext {
@@ -1531,6 +2201,185 @@ pub fn theme_editor_context(app: AppHandle) -> Result<ThemeEditorContext, String
})
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThemeBackupInfo {
pub path: String,
pub label: String,
pub created_at: u64,
pub size: u64,
}
fn theme_backups_root() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Could not locate the home directory".to_string())?;
let root = PathBuf::from(home)
.join(".ezpplauncher")
.join("theme-backups");
std::fs::create_dir_all(&root)
.map_err(|error| format!("Failed to create the theme backup directory: {error}"))?;
std::fs::canonicalize(&root)
.map_err(|error| format!("Failed to resolve the theme backup directory: {error}"))
}
fn directory_size(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.filter_map(|entry| entry.ok())
.map(|entry| {
let path = entry.path();
match entry.file_type() {
Ok(kind) if kind.is_dir() => directory_size(&path),
Ok(kind) if kind.is_file() => entry.metadata().map(|meta| meta.len()).unwrap_or(0),
_ => 0,
}
})
.sum()
}
fn copy_theme_tree(source: &Path, destination: &Path) -> Result<(), String> {
std::fs::create_dir_all(destination)
.map_err(|error| format!("Failed to create {}: {error}", destination.display()))?;
for entry in std::fs::read_dir(source)
.map_err(|error| format!("Failed to read {}: {error}", source.display()))?
{
let entry = entry.map_err(|error| format!("Failed to read theme entry: {error}"))?;
let kind = entry
.file_type()
.map_err(|error| format!("Failed to inspect theme entry: {error}"))?;
if kind.is_symlink() {
continue;
}
let target = destination.join(entry.file_name());
if kind.is_dir() {
copy_theme_tree(&entry.path(), &target)?;
} else if kind.is_file() {
std::fs::copy(entry.path(), &target)
.map_err(|error| format!("Failed to copy theme file: {error}"))?;
}
}
Ok(())
}
fn theme_backup_dir(theme_folder: &Path) -> Result<PathBuf, String> {
let name = theme_folder
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "The theme folder has no name".to_string())?;
Ok(theme_backups_root()?.join(name))
}
fn prune_theme_backups(directory: &Path) -> Result<(), String> {
let mut entries = std::fs::read_dir(directory)
.map_err(|error| format!("Failed to read theme backups: {error}"))?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
while entries.len() > 5 {
let oldest = entries.remove(0);
let _ = std::fs::remove_dir_all(oldest.path());
}
Ok(())
}
/// Copy a theme aside before overwriting it during an update.
#[tauri::command]
pub async fn backup_theme_folder(theme_folder: String) -> Result<String, String> {
let folder = checked_theme_folder(&theme_folder)?;
if !folder.is_dir() {
return Err("The theme folder does not exist".to_string());
}
tokio::task::spawn_blocking(move || {
let backup_root = theme_backup_dir(&folder)?;
std::fs::create_dir_all(&backup_root)
.map_err(|error| format!("Failed to create the theme backup directory: {error}"))?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let destination = backup_root.join(format!("backup_{stamp}"));
copy_theme_tree(&folder, &destination)?;
prune_theme_backups(&backup_root)?;
Ok(destination.to_string_lossy().into_owned())
})
.await
.map_err(|error| format!("Thread error: {error}"))?
}
#[tauri::command]
pub async fn list_theme_backups(theme_folder: String) -> Result<Vec<ThemeBackupInfo>, String> {
let folder = checked_theme_folder(&theme_folder)?;
tokio::task::spawn_blocking(move || {
let backup_root = theme_backup_dir(&folder)?;
if !backup_root.is_dir() {
return Ok(Vec::new());
}
let mut backups = std::fs::read_dir(&backup_root)
.map_err(|error| format!("Failed to read theme backups: {error}"))?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.filter_map(|entry| {
let path = entry.path();
let created_at = entry
.metadata()
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
Some(ThemeBackupInfo {
label: entry.file_name().to_string_lossy().into_owned(),
path: path.to_string_lossy().into_owned(),
created_at,
size: directory_size(&path),
})
})
.collect::<Vec<_>>();
backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(backups)
})
.await
.map_err(|error| format!("Thread error: {error}"))?
}
#[tauri::command]
pub async fn restore_theme_backup(
theme_folder: String,
backup_folder: String,
) -> Result<(), String> {
let folder = checked_theme_folder(&theme_folder)?;
tokio::task::spawn_blocking(move || {
let allowed_root = theme_backup_dir(&folder)?;
let backup = std::fs::canonicalize(&backup_folder)
.map_err(|error| format!("Failed to resolve the theme backup: {error}"))?;
if !backup.starts_with(&allowed_root) || !backup.is_dir() {
return Err("The backup is outside this theme's backup folder".to_string());
}
if folder.exists() {
std::fs::remove_dir_all(&folder)
.map_err(|error| format!("Failed to replace the theme folder: {error}"))?;
}
copy_theme_tree(&backup, &folder)
})
.await
.map_err(|error| format!("Thread error: {error}"))?
}
/// Only allow writing inside `~/.ezpplauncher/themes`.
fn checked_theme_folder(theme_folder: &str) -> Result<PathBuf, String> {
let root = theme_root()?;
+39 -19
View File
@@ -13,25 +13,27 @@ pub mod macos;
pub mod osudb;
pub mod presence;
pub mod state;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
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, 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,
backup_theme_folder, 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,
export_theme, extract_theme, file_size, 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, installation_diagnostics,
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
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, 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,
list_theme_backups, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder,
open_url_in_browser, opened_urls, optimize_theme_media, 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,
resolve_osu_folder, restore_theme_backup, 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,
};
@@ -111,10 +113,14 @@ pub fn run() {
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) {
// WebKit's custom-protocol media pipeline is unreliable (and
// loads large WebMs into memory), so Linux and macOS use a real
// loopback HTTP server with byte-range support instead.
#[cfg(any(target_os = "linux", target_os = "macos"))]
match commands::theme_root().and_then(|root| {
let media = theme_server::media_root()?;
theme_server::start(root, media)
}) {
Ok(_) => {}
Err(error) => log::warn!(
target: "launcher",
@@ -127,6 +133,9 @@ pub fn run() {
if let Err(error) = macos::round_window(&window, 12.0) {
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
}
if let Err(error) = macos::allow_media_autoplay(&window) {
log::warn!(target: "launcher", "{error}");
}
}
Ok(())
@@ -142,6 +151,7 @@ pub fn run() {
get_skins_count,
get_osu_version,
get_osu_release_stream,
installation_diagnostics,
set_osu_config_values,
set_osu_user_config_values,
run_osu_updater,
@@ -155,6 +165,7 @@ pub fn run() {
get_launcher_version,
exit,
get_platform,
file_size,
check_for_corruption,
presence_connect,
presence_disconnect,
@@ -169,6 +180,9 @@ pub fn run() {
download_ezpp_launcher_update,
install_ezpp_launcher_update,
read_theme_info,
backup_theme_folder,
list_theme_backups,
restore_theme_backup,
extract_theme,
download_and_extract_theme,
save_theme_project,
@@ -178,6 +192,7 @@ pub fn run() {
theme_editor_context,
theme_asset_base,
theme_preview_url,
optimize_theme_media,
config_get,
config_set,
config_exists,
@@ -190,7 +205,9 @@ pub fn run() {
is_open_tablet_driver_path_valid,
log_frontend,
set_logging_enabled,
open_logs_folder
open_logs_folder,
logging::list_log_bundles,
logging::read_log_bundle
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
@@ -208,6 +225,9 @@ pub fn run() {
if let Err(error) = macos::round_window(&window, 12.0) {
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
}
if let Err(error) = macos::allow_media_autoplay(&window) {
log::warn!(target: "launcher", "{error}");
}
}
}
+101 -1
View File
@@ -15,12 +15,13 @@
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use log::Level;
use serde::Serialize;
use tauri::Runtime;
use tauri::plugin::TauriPlugin;
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
@@ -376,6 +377,105 @@ pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
.build()
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogBundleInfo {
pub path: String,
pub name: String,
pub size: u64,
pub modified: u64,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogBundleEntry {
pub name: String,
pub content: String,
}
/// List the rotating ZIP bundles, newest first, for the in-app log viewer.
#[tauri::command]
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
let dir = logs_directory();
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut bundles = fs::read_dir(&dir)
.map_err(|error| format!("Could not read the logs folder: {error}"))?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?.to_string();
if !name.starts_with("logs_") || !name.ends_with(".zip") {
return None;
}
let metadata = entry.metadata().ok()?;
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
Some(LogBundleInfo {
path: path.to_string_lossy().into_owned(),
name,
size: metadata.len(),
modified,
})
})
.collect::<Vec<_>>();
bundles.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(bundles)
}
/// Read the launcher/osu/patcher logs out of one bundle.
#[tauri::command]
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
let dir = logs_directory();
let requested = fs::canonicalize(&path)
.map_err(|error| format!("Could not open the log bundle: {error}"))?;
let root = fs::canonicalize(&dir)
.map_err(|error| format!("Could not resolve the logs folder: {error}"))?;
if !requested.starts_with(&root) {
return Err("The log bundle is outside the logs folder".to_string());
}
let file = File::open(&requested)
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|error| format!("Invalid log bundle: {error}"))?;
let mut entries = Vec::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
if !entry.name().ends_with(".log") {
continue;
}
let mut bytes = Vec::new();
entry
.by_ref()
.take(2 * 1024 * 1024)
.read_to_end(&mut bytes)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
let mut content = String::from_utf8_lossy(&bytes).into_owned();
if entry.size() > bytes.len() as u64 {
content.push_str("\n… log truncated in the viewer …\n");
}
entries.push(LogBundleEntry {
name: entry.name().to_string(),
content,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
/// 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");
+106
View File
@@ -54,6 +54,35 @@ pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), St
Ok(())
}
/// Allow theme media to start without a click. WKWebView otherwise blocks
/// autoplay for anything with an audio track, which makes a theme's background
/// video sit frozen until the user interacts with the launcher.
pub fn allow_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> {
use objc2::msg_send;
use objc2::runtime::AnyObject;
window
.with_webview(|webview| unsafe {
let webview = webview.inner() as *mut AnyObject;
if webview.is_null() {
return;
}
let configuration: *mut AnyObject = msg_send![webview, configuration];
if configuration.is_null() {
return;
} // WKAudiovisualMediaTypesNone. This is a public WKWebView setting.
let _: () =
msg_send![configuration, setMediaTypesRequiringUserActionForPlayback: 0usize];
let current: usize = msg_send![configuration, mediaTypesRequiringUserActionForPlayback];
log::debug!(
target: "launcher",
"Theme media autoplay policy: {current}"
);
})
.map_err(|error| format!("Could not enable theme media autoplay: {error}"))
}
/// Show a native open panel that accepts `.app` bundles as well as folders.
///
/// AppKit treats application bundles as packages (directories) by default, so a
@@ -133,6 +162,55 @@ pub struct WineRuntime {
wine_root: Option<PathBuf>,
}
#[derive(Clone)]
pub struct WineDiagnostics {
pub prefix: PathBuf,
pub wine_bin: PathBuf,
pub app_bundle: Option<PathBuf>,
pub net8_installed: bool,
pub wine_version: Option<String>,
pub prefix_arch: Option<String>,
}
impl WineDiagnostics {
/// Inspect the wrapper without starting it, for the launcher's diagnostics panel.
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
let runtime = WineRuntime::for_osu_folder(osu_folder)?;
let net8_installed = [
runtime
.prefix
.join("drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"),
runtime
.prefix
.join("drive_c/Program Files (x86)/dotnet/shared/Microsoft.WindowsDesktop.App"),
]
.iter()
.any(|path| {
fs::read_dir(path)
.ok()
.map(|entries| {
entries.filter_map(|entry| entry.ok()).any(|entry| {
entry
.file_name()
.to_str()
.map(|name| name.starts_with("8."))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
Ok(Self {
prefix: runtime.prefix.clone(),
wine_bin: runtime.wine_bin.clone(),
app_bundle: app_bundle_for(&runtime.prefix),
net8_installed,
wine_version: wine_version_for(&runtime.wine_bin),
prefix_arch: prefix_architecture(&runtime.prefix),
})
}
}
impl WineRuntime {
/// Build a runtime description for an osu! folder inside a Wine prefix.
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
@@ -768,6 +846,34 @@ fn has_wine_loader(bin: &Path) -> bool {
.any(|loader| bin.join(loader).is_file())
}
fn wine_version_for(bin: &Path) -> Option<String> {
let loader = ["wine64", "wine", "wine32on64"]
.iter()
.map(|name| bin.join(name))
.find(|path| path.is_file())?;
let output = std::process::Command::new(loader)
.arg("--version")
.env("WINEDEBUG", "-all")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!version.is_empty()).then_some(version)
}
fn prefix_architecture(prefix: &Path) -> Option<String> {
if prefix.join("drive_c/windows/syswow64").is_dir() {
Some("win64".to_string())
} else if prefix.join("drive_c/windows/system32").is_dir() {
Some("win32".to_string())
} else {
None
}
}
fn find_in_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
+67 -10
View File
@@ -1,10 +1,10 @@
//! Loopback HTTP server for theme assets on Linux.
//! Loopback HTTP server for theme assets on Linux and macOS.
//!
//! 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.
//! protocol, and WKWebView's range handling makes large media stall there.
//! 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 without loading a whole video into a blob first.
use http_body_util::Full;
use http_range::HttpRange;
@@ -34,19 +34,22 @@ const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
struct ThemeServer {
port: u16,
root: PathBuf,
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> {
pub fn start(root: PathBuf, media_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 media_root = std::fs::canonicalize(&media_root)
.map_err(|error| format!("Could not resolve the theme media cache: {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
@@ -60,11 +63,13 @@ pub fn start(root: PathBuf) -> Result<u16, String> {
let previews = Arc::new(Mutex::new(HashMap::new()));
let server = ThemeServer {
port,
root: root.clone(),
previews: previews.clone(),
};
let _ = SERVER.set(server);
let root = Arc::new(root);
let media_root = Arc::new(media_root);
tauri::async_runtime::spawn(async move {
let listener = match TcpListener::from_std(listener) {
Ok(listener) => listener,
@@ -89,10 +94,12 @@ pub fn start(root: PathBuf) -> Result<u16, String> {
};
let root = root.clone();
let media_root = media_root.clone();
let previews = previews.clone();
tokio::spawn(async move {
let service =
service_fn(move |request| handle(request, root.clone(), previews.clone()));
let service = service_fn(move |request| {
handle(request, root.clone(), media_root.clone(), previews.clone())
});
if let Err(error) = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
@@ -113,6 +120,48 @@ pub fn base_url() -> Option<String> {
.map(|server| format!("http://127.0.0.1:{}/themes", server.port))
}
/// The cache folder for optimized theme media, next to the themes folder.
pub fn media_root() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Could not locate the home directory".to_string())?;
let root = PathBuf::from(home)
.join(".ezpplauncher")
.join("media-cache");
std::fs::create_dir_all(&root)
.map_err(|error| format!("Failed to create the theme media cache: {error}"))?;
std::fs::canonicalize(&root)
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
}
/// Map a loopback theme URL back to the file it serves. Only paths inside the
/// configured themes folder are accepted.
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
let server = SERVER.get()?;
let url = url::Url::parse(value).ok()?;
if url.scheme() != "http"
|| url.host_str() != Some("127.0.0.1")
|| url.port() != Some(server.port)
{
return None;
}
let path = percent_encoding::percent_decode_str(url.path())
.decode_utf8_lossy()
.to_string();
let relative = path.strip_prefix("/themes/")?;
safe_path(&server.root, relative)
}
/// Public URL for a file in the optimized media cache.
pub fn media_url(file_name: &str) -> Option<String> {
let server = SERVER.get()?;
Some(format!(
"http://127.0.0.1:{}/media/{}",
server.port, file_name
))
}
/// 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.
@@ -149,6 +198,7 @@ pub fn preview_url(path: &Path) -> Result<String, String> {
async fn handle(
request: Request<Incoming>,
root: Arc<PathBuf>,
media_root: Arc<PathBuf>,
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
) -> Result<Response<Body>, Infallible> {
let origin = request
@@ -179,8 +229,15 @@ async fn handle(
.decode_utf8_lossy()
.to_string();
log::debug!(
target: "launcher",
"Theme asset request: {path} range={range:?}"
);
let file = if let Some(relative) = path.strip_prefix("/themes/") {
safe_theme_path(&root, relative)
safe_path(&root, relative)
} else if let Some(relative) = path.strip_prefix("/media/") {
safe_path(&media_root, relative)
} else if let Some(token) = path.strip_prefix("/preview/") {
previews
.lock()
@@ -211,7 +268,7 @@ async fn handle(
}))
}
fn safe_theme_path(root: &Path, relative: &str) -> Option<PathBuf> {
fn safe_path(root: &Path, relative: &str) -> Option<PathBuf> {
let relative = relative.trim_start_matches('/');
if relative.is_empty()
|| relative