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

+21 -5
View File
@@ -24,7 +24,9 @@ It enhances the osu! experience with quality-of-life features and integration sp
- [x] Discord Rich Presence
- [x] Performance Display Overlay (currently in experimental patcher release stream)
- [x] Launcher Theming (API 1.0 bundles and API 2.0 declarative themes)
- [x] Advanced theme editor in its own window with freeform text/image/video/shape layers, fonts, live launcher preview and export
- [x] Advanced theme editor in its own window with freeform text/image/video/shape layers, multi-select and snapping, layer locks, draft recovery, validation and export
- [x] Theme update backups with one-click rollback, plus a trust prompt before running executable API 1.0 themes
- [x] In-app diagnostics for osu!, Wine, the patcher, and the experimental stream's .NET 8 runtime, plus a log-bundle viewer
---
@@ -37,8 +39,9 @@ It enhances the osu! experience with quality-of-life features and integration sp
| Linux | 🕧 Partially supported (via osu-winello) |
> **Windows** remains the fully supported platform. OpenTabletDriver is Windows-only. On macOS,
> in-game patching runs the Windows patcher through the same Wine prefix as osu!, so that prefix
> needs a .NET runtime installed.
> in-game patching runs the Windows patcher through the same Wine prefix as osu!. The stable
> patcher stream needs no extra runtime; the experimental stream needs the .NET 8 Desktop Runtime
> installed in that prefix.
### macOS
@@ -51,6 +54,14 @@ If detection fails, choose **Browse** and pick the `osu!.app` wrapper itself (or
folder); the launcher resolves the osu! installation inside it, so there is no need to open the
bundle's package contents.
Large VP8/VP9/AV1 theme videos are transcoded once to a cached 720p H.264/AAC file so WebKit
can use the Mac's hardware decoder. The first large video triggers a one-time (~63 MB) download
of a signed static FFmpeg 9.0.2 build from `ffmpeg.martin-riedl.de` into `~/.ezpplauncher/bin`;
the launcher verifies its pinned SHA-256 and makes it executable. The managed build requires
macOS 12 or newer. If the download fails, a system FFmpeg (Homebrew or MacPorts) is used; without
either, themes still load but large WebM videos remain on WebKit's software decoder and may
stutter. Theme authors can also ship an H.264/MP4 version alongside the WebM.
---
## 🪵 Logs
@@ -65,10 +76,15 @@ Launcher, osu! and patcher output is written to `.ezpplauncher/logs` in your hom
A file is only created when that stream actually writes a line. On each new session, or when
any file reaches 5 MiB, all three active files are bundled into a single
`logs_<date>_<time>.zip`; the five most recent bundles are kept. You can turn file logging off
or open the folder from **Settings → Logging**.
`logs_<date>_<time>.zip`; the five most recent bundles are kept. You can turn file logging off,
open the folder, or browse and filter a bundle in the built-in viewer from **Settings → Logging**.
On Windows the folder is `%USERPROFILE%\.ezpplauncher\logs`.
**Settings → Diagnostics** checks the selected osu! install and write access, the macOS Wine
prefix/runtime/version and (for the experimental patcher stream) its .NET 8 runtime, the FFmpeg
theme-video transcoder, osu-winello/wmctrl on Linux, and whether the patcher is present. It can
copy a plain-text support report or reopen the setup wizard for a guided repair.
---
## 🛠 Build From Source
+36 -20
View File
@@ -245,15 +245,18 @@ top. Themes can contain up to 100 layers.
Every layer supports:
| Field | Type | Notes |
| ---------- | ------ | ----------------------------------- |
| `id` | string | Unique within the theme |
| `type` | string | `text`, `image`, `video` or `shape` |
| `x`, `y` | number | Position in the 1000 × 650 stage |
| `width` | number | 24–1000 |
| `height` | number | 24–650 |
| `rotation` | number | Degrees, `-360`–`360` |
| `opacity` | number | `0`–`1` |
| Field | Type | Notes |
| ---------- | ------- | ------------------------------------------------ |
| `id` | string | Unique within the theme |
| `name` | string? | Optional editor label; does not affect rendering |
| `type` | string | `text`, `image`, `video` or `shape` |
| `x`, `y` | number | Position in the 1000 × 650 stage |
| `width` | number | 24–1000 |
| `height` | number | 24–650 |
| `rotation` | number | Degrees, `-360`–`360` |
| `opacity` | number | `0`–`1` |
| `hidden` | boolean | Keep the layer in the file but do not render it |
| `locked` | boolean | Prevent moving/resizing the layer in the editor |
#### Text
@@ -357,27 +360,40 @@ small tweaks, but it is not required. Example:
full launcher home canvas at 1000 × 650.
4. Use the left toolbar to add text, image, video and shape layers. Drag layers on
the canvas, resize with the corner handles, or edit exact values in the right
inspector.
5. Use the **Theme** tab for metadata, background, effects, typography and custom
inspector. Shift/Cmd-click a layer or drag a marquee over empty canvas to
select several layers. Multi-selections can be dragged together and aligned
or distributed from the right panel. Layers snap to canvas edges, the canvas
center, and other layers while dragging; hold Alt to disable snapping.
Right-click a layer for quick duplicate, ordering, hide/lock and delete
actions, or right-click empty canvas to add a new layer.
5. The layer list supports double-click rename, hide/show, lock/unlock and
drag-to-reorder. Hidden layers remain in the package, while locked layers
cannot be moved or resized on the canvas.
6. Use the **Theme** tab for metadata, background, effects, typography and custom
CSS. Open the **Fonts** tab to search and download from every Fontsource
family, or import WOFF2, WOFF, TTF and OTF files. Downloaded fonts are copied
into the theme's `assets/` folder when you save.
6. Select **Save theme** to write `theme.json` and copy new assets into
7. Select **Save theme** to write `theme.json` and copy new assets into
`~/.ezpplauncher/themes`. A fresh `assets/preview.png` thumbnail is rendered
from the theme and the launcher applies the saved theme immediately.
7. Select **Export** to create a shareable `.ezpplauncher-theme` package. Export
saves and refreshes the preview first, so the package always includes the
current thumbnail.
from the theme and the launcher applies the saved theme immediately. Unsaved
edits are autosaved as a local draft and offered for recovery after a crash.
8. Select **Export** to create a shareable `.ezpplauncher-theme` package. A theme
check runs first and reports missing assets, oversized media, missing fonts
and unused imports. Export saves and refreshes the preview, then reports the
final package size.
Keyboard shortcuts:
- `Cmd/Ctrl+Z` undoes the last change.
- `Cmd/Ctrl+Shift+Z` or `Ctrl+Y` redoes it.
- `Cmd/Ctrl+S` saves.
- `Cmd/Ctrl+D` duplicates the selected layer.
- Arrow keys nudge the selected layer by 1 px; hold Shift for 10 px.
- `Delete` removes the selected layer.
- `Esc` deselects the layer.
- `Cmd/Ctrl+D` duplicates the selected layer or multi-selection.
- `Cmd/Ctrl+A` selects every visible layer.
- Shift/Cmd-click toggles a layer in the current selection.
- Arrow keys nudge the selected layer(s) by 1 px; hold Shift for 10 px.
- `Delete` removes the selected layer(s).
- Hold Alt while dragging to skip snapping.
- `Esc` clears the selection or closes an open editor dialog.
---
+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
@@ -0,0 +1,416 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { launcherStream } from '$lib/global';
import { getReleaseStream, getVersion, isOsuCorrupted } from '$lib/osuUtil';
import { osuInstallationPath, patch } from '$lib/userSettings';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
CheckCircle2,
ClipboardCopy,
Info,
LoaderCircle,
RefreshCw,
Wrench,
XCircle,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
type InstallationDiagnostics = {
platform: string;
osVersion?: string;
osArch: string;
launcherVersion: string;
osuFolder: string;
resolvedFolder?: string;
osuWritable?: boolean;
patcherPath?: string;
patcherInstalled: boolean;
patcherFileCount: number;
patcherTotalSize: number;
hostNet8: boolean;
osuwinello: boolean;
wmctrl: boolean;
winePrefix?: string;
wineBinary?: string;
wineAppBundle?: string;
wineVersion?: string;
winePrefixArch?: string;
wineNet8?: boolean;
wineError?: string;
ffmpegStatus?: string;
logsDirectory: string;
};
type Check = {
label: string;
detail: string;
status: 'ok' | 'warning' | 'error' | 'info';
};
let { onRepair }: { onRepair?: () => void } = $props();
let checking = $state(true);
let checks = $state<Check[]>([]);
let lastChecked = $state<Date | undefined>(undefined);
let platformName = $state('');
let launcherVersionName = $state('');
let osVersion = $state('');
let osArch = $state('');
let logsDirectory = $state('');
const icons = {
ok: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
info: Info,
};
const platformLabel = (platform: string) => {
switch (platform) {
case 'macos':
return 'macOS';
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
default:
return platform || 'unknown';
}
};
async function runChecks() {
checking = true;
checks = [];
try {
const folder = $osuInstallationPath;
const diagnostics = await invoke<InstallationDiagnostics>('installation_diagnostics', {
folder,
});
platformName = diagnostics.platform;
launcherVersionName = diagnostics.launcherVersion;
osVersion = diagnostics.osVersion ?? '';
osArch = diagnostics.osArch;
logsDirectory = diagnostics.logsDirectory;
const resolved = diagnostics.resolvedFolder;
// Only the experimental patcher build targets .NET 8.
const needsNet8 = $patch && $launcherStream === 'experimental';
const result: Check[] = [];
result.push({
label: 'Launcher',
detail: `${diagnostics.launcherVersion || 'unknown version'} · ${
diagnostics.osVersion || platformLabel(diagnostics.platform)
}${diagnostics.osArch ? ` (${diagnostics.osArch})` : ''}`,
status: 'ok',
});
if (!folder.trim()) {
result.push({
label: 'osu! installation',
detail: 'No installation path is configured. Run the setup wizard to pick osu!.',
status: 'error',
});
} else if (!resolved) {
result.push({
label: 'osu! installation',
detail: `The configured path could not be resolved: ${folder}`,
status: 'error',
});
} else {
result.push({
label: 'osu! installation',
detail: resolved,
status: 'ok',
});
try {
const [version, stream] = await Promise.all([
getVersion(resolved),
getReleaseStream(resolved),
]);
result.push({
label: 'osu! version',
detail: `${stream ?? 'unknown stream'} · ${version || 'version unavailable'}`,
status: version ? 'ok' : 'warning',
});
} catch (error) {
result.push({
label: 'osu! version',
detail: error instanceof Error ? error.message : String(error),
status: 'warning',
});
}
try {
const corrupted = await isOsuCorrupted(resolved);
result.push({
label: 'Game files',
detail: corrupted
? 'osu!.exe or a required file looks corrupted. Re-run the updater or setup.'
: 'The osu! executable passed the integrity check.',
status: corrupted ? 'error' : 'ok',
});
} catch (error) {
result.push({
label: 'Game files',
detail: error instanceof Error ? error.message : String(error),
status: 'warning',
});
}
}
if (typeof diagnostics.osuWritable === 'boolean') {
result.push({
label: 'osu! folder access',
detail: diagnostics.osuWritable
? 'The launcher can write to this folder, so updates and patcher downloads can be installed.'
: 'The launcher cannot write to this folder. Updates and patcher downloads will fail; check folder permissions.',
status: diagnostics.osuWritable ? 'ok' : 'error',
});
}
const selectedStream = $launcherStream;
const effectiveStream = diagnostics.platform === 'windows' ? selectedStream : 'stable';
result.push({
label: 'Patcher stream',
detail:
diagnostics.platform === 'windows'
? `${selectedStream} selected`
: `${effectiveStream} (selected: ${selectedStream}; macOS/Linux always use stable)`,
status: 'ok',
});
result.push({
label: 'Patcher',
detail: diagnostics.patcherInstalled
? `${diagnostics.patcherFileCount} file${
diagnostics.patcherFileCount === 1 ? '' : 's'
}, ${formatBytes(diagnostics.patcherTotalSize)} · ${diagnostics.patcherPath}`
: 'Patcher files are not installed yet. They will be downloaded automatically the next time you launch osu!.',
status: diagnostics.patcherInstalled ? 'ok' : $patch ? 'warning' : 'info',
});
result.push({
label: 'Logs',
detail: diagnostics.logsDirectory || 'The logs folder is unavailable.',
status: diagnostics.logsDirectory ? 'ok' : 'warning',
});
if (diagnostics.platform === 'macos') {
if (diagnostics.wineError) {
result.push({
label: 'Wine wrapper',
detail: diagnostics.wineError,
status: 'error',
});
} else {
result.push({
label: 'Wine prefix',
detail: diagnostics.winePrefix || 'Wine prefix not detected.',
status: diagnostics.winePrefix ? 'ok' : 'error',
});
result.push({
label: 'Wine runtime',
detail: diagnostics.wineBinary || 'No Wine loader found inside the wrapper.',
status: diagnostics.wineBinary ? 'ok' : 'error',
});
result.push({
label: 'Wine version',
detail: diagnostics.wineVersion || 'Could not determine the Wine version.',
status: diagnostics.wineVersion ? 'ok' : 'warning',
});
if (diagnostics.winePrefixArch) {
result.push({
label: 'Wine prefix architecture',
detail:
diagnostics.winePrefixArch === 'win64'
? '64-bit (win64)'
: diagnostics.winePrefixArch === 'win32'
? '32-bit (win32)'
: diagnostics.winePrefixArch,
status: 'ok',
});
}
if (diagnostics.wineAppBundle) {
result.push({
label: 'App wrapper',
detail: diagnostics.wineAppBundle,
status: 'ok',
});
}
result.push({
label: '.NET 8 in prefix (experimental)',
detail:
diagnostics.wineNet8 === true
? 'The Microsoft.WindowsDesktop.App 8 runtime was found.'
: 'The Windows .NET 8 Desktop Runtime was not found in this prefix. Only the experimental patcher stream needs it.',
status: diagnostics.wineNet8 === true ? 'ok' : needsNet8 ? 'error' : 'warning',
});
result.push({
label: 'FFmpeg (theme video)',
detail: diagnostics.ffmpegStatus
? diagnostics.ffmpegStatus
: 'FFmpeg was not found. Large theme videos will stay on WebKit’s software decoder and may stutter.',
status: diagnostics.ffmpegStatus ? 'ok' : 'warning',
});
}
} else if (diagnostics.platform === 'linux') {
result.push({
label: 'osu-winello',
detail: diagnostics.osuwinello
? 'osu-winello was found.'
: 'osu-winello was not found in PATH. Launching osu! may fail.',
status: diagnostics.osuwinello ? 'ok' : 'error',
});
result.push({
label: 'wmctrl',
detail: diagnostics.wmctrl
? 'wmctrl is available for window detection.'
: 'wmctrl is missing. The patcher may not detect the osu! window.',
status: diagnostics.wmctrl ? 'ok' : $patch ? 'warning' : 'info',
});
} else {
result.push({
label: '.NET 8 Desktop Runtime (experimental)',
detail: diagnostics.hostNet8
? 'Microsoft.WindowsDesktop.App 8 was found.'
: 'The .NET 8 Desktop Runtime was not found. Only the experimental patcher stream needs it.',
status: diagnostics.hostNet8 ? 'ok' : needsNet8 ? 'error' : 'warning',
});
}
checks = result;
lastChecked = new Date();
} catch (error) {
checks = [
{
label: 'Diagnostics',
detail: error instanceof Error ? error.message : String(error),
status: 'error',
},
];
} finally {
checking = false;
}
}
async function copyReport() {
const body = checks
.map((check) => `[${check.status.toUpperCase()}] ${check.label}: ${check.detail}`)
.join('\n');
const report = [
'EZPPLauncher diagnostics',
`Generated: ${new Date().toISOString()}`,
`Launcher: ${launcherVersionName || 'unknown'}`,
`Platform: ${osVersion || platformLabel(platformName)}${osArch ? ` (${osArch})` : ''}`,
`Logs: ${logsDirectory || 'unknown'}`,
'',
body,
].join('\n');
try {
await navigator.clipboard.writeText(report);
sileo.success({
title: 'Diagnostics copied',
description: 'Paste it into your support report.',
});
} catch {
sileo.error({ title: 'Could not copy diagnostics' });
}
}
onMount(runChecks);
</script>
<div class="divide-y divide-theme-800/50 border-t border-theme-800/50">
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
<div class="min-w-0">
<p class="text-sm font-medium">System checks</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Inspect the selected osu! install, folder permissions, Wine wrapper, patcher, FFmpeg and
runtime dependencies.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-1.5">
{#if lastChecked}
<span class="mr-1 text-[10px] text-muted-foreground">
{lastChecked.toLocaleTimeString()}
</span>
{/if}
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={copyReport}
>
<ClipboardCopy class="size-4" />
Copy report
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={runChecks}
disabled={checking}
>
<RefreshCw class={checking ? 'size-4 animate-spin' : 'size-4'} />
Refresh
</Button>
{#if onRepair}
<Button variant="secondary" size="sm" onclick={onRepair}>
<Wrench class="size-4" />
Repair setup
</Button>
{/if}
</div>
</div>
<div class="space-y-2 py-3">
{#if checking}
<div class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin" />
Checking your installation…
</div>
{:else}
{#each checks as check (check.label)}
{@const Icon = icons[check.status]}
<div
class="flex items-start gap-2.5 rounded-lg border border-theme-800/70 bg-theme-950/60 px-3 py-2"
>
<Icon
class="mt-0.5 size-4 shrink-0 {check.status === 'ok'
? 'text-emerald-400'
: check.status === 'warning'
? 'text-amber-300'
: check.status === 'error'
? 'text-destructive'
: 'text-muted-foreground'}"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-xs font-medium text-theme-100">{check.label}</p>
<Badge
variant={check.status === 'error'
? 'destructive'
: check.status === 'warning'
? 'secondary'
: 'outline'}
class="px-1.5 py-0 text-[9px] capitalize"
>
{check.status}
</Badge>
</div>
<p class="mt-0.5 break-words text-[10px] leading-relaxed text-muted-foreground">
{check.detail}
</p>
</div>
</div>
{/each}
{/if}
</div>
</div>
+337
View File
@@ -0,0 +1,337 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import { openLogsFolder } from '$lib/osuUtil';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
Copy,
FileText,
FolderOpen,
LoaderCircle,
RefreshCw,
Search,
Terminal,
X,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
type LogBundleInfo = {
path: string;
name: string;
size: number;
modified: number;
};
type LogBundleEntry = {
name: string;
content: string;
};
let { onclose }: { onclose: () => void } = $props();
let bundles = $state<LogBundleInfo[]>([]);
let selectedPath = $state<string | undefined>(undefined);
let entries = $state<LogBundleEntry[]>([]);
let selectedLog = $state('launcher.log');
let query = $state('');
let errorsOnly = $state(false);
let loadingBundles = $state(true);
let loadingEntries = $state(false);
let errorMessage = $state('');
const selectedBundle = $derived(bundles.find((bundle) => bundle.path === selectedPath));
const activeEntry = $derived(entries.find((entry) => entry.name === selectedLog));
const allLines = $derived(activeEntry?.content.split(/\r?\n/) ?? []);
const filteredLines = $derived.by(() => {
const text = query.trim().toLowerCase();
return allLines.filter((line) => {
if (errorsOnly && !/(error|warn|panic|fatal|failed)/i.test(line)) return false;
return !text || line.toLowerCase().includes(text);
});
});
const visibleLines = $derived(filteredLines.slice(0, 5000));
async function loadBundles() {
loadingBundles = true;
errorMessage = '';
try {
bundles = await invoke<LogBundleInfo[]>('list_log_bundles');
if (bundles.length === 0) {
selectedPath = undefined;
entries = [];
return;
}
const stillThere = bundles.some((bundle) => bundle.path === selectedPath);
const next = stillThere ? selectedPath : bundles[0].path;
selectedPath = next;
await loadBundle(next!);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
loadingBundles = false;
}
}
async function loadBundle(path: string) {
selectedPath = path;
loadingEntries = true;
errorMessage = '';
try {
entries = await invoke<LogBundleEntry[]>('read_log_bundle', { path });
if (!entries.some((entry) => entry.name === selectedLog)) {
selectedLog = entries[0]?.name ?? 'launcher.log';
}
} catch (error) {
entries = [];
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
loadingEntries = false;
}
}
async function refresh() {
await loadBundles();
}
async function copyVisible() {
const text = filteredLines.join('\n');
try {
await navigator.clipboard.writeText(text);
sileo.success({
title: 'Log lines copied',
description: `${filteredLines.length} lines from ${selectedLog}.`,
});
} catch {
sileo.error({ title: 'Could not copy the log' });
}
}
async function openFolder() {
try {
await openLogsFolder();
} catch (error) {
sileo.error({
title: 'Could not open the logs folder',
description: error instanceof Error ? error.message : String(error),
});
}
}
function lineClass(line: string) {
if (/(error|panic|fatal|failed)/i.test(line)) return 'text-red-300';
if (/(warn|deprecated|missing)/i.test(line)) return 'text-amber-200';
if (/(info|started|loaded|saved)/i.test(line)) return 'text-theme-200';
return 'text-theme-300';
}
onMount(loadBundles);
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') onclose();
}}
/>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-5 backdrop-blur-sm"
role="presentation"
>
<div
class="flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="log-viewer-title"
>
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-theme-800 p-3">
<div class="flex min-w-0 items-center gap-2">
<div class="flex size-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Terminal class="size-4" />
</div>
<div class="min-w-0">
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2>
<p class="truncate text-[10px] text-muted-foreground">
Launcher, osu! and patcher output, grouped by session
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={openFolder}
>
<FolderOpen class="size-3.5" />
Folder
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={refresh}
disabled={loadingBundles}
>
<RefreshCw class={loadingBundles ? 'size-3.5 animate-spin' : 'size-3.5'} />
Refresh
</Button>
<Button variant="ghost" size="icon" class="size-8" title="Close logs" onclick={onclose}>
<X class="size-4" />
</Button>
</div>
</header>
<div class="grid min-h-0 flex-1 grid-cols-[230px_minmax(0,1fr)]">
<aside class="flex min-h-0 flex-col border-r border-theme-800 bg-black/20">
<div
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
>
<span>Bundles</span>
<span>{bundles.length}</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
{#if loadingBundles}
<div class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin" />
Loading…
</div>
{:else if bundles.length === 0}
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5
MiB.
</div>
{:else}
{#each bundles as bundle (bundle.path)}
<button
type="button"
class="mb-1 w-full rounded-md border px-2 py-2 text-left transition {bundle.path ===
selectedPath
? 'border-primary/40 bg-primary/10'
: 'border-transparent hover:border-theme-800 hover:bg-theme-900/60'}"
onclick={() => loadBundle(bundle.path)}
>
<span class="flex items-center gap-1.5">
<FileText class="size-3.5 shrink-0 text-primary" />
<span class="truncate text-[11px] font-medium text-theme-100">
{bundle.name}
</span>
</span>
<span class="mt-1 block pl-5 text-[9px] text-muted-foreground">
{new Date(bundle.modified).toLocaleString()} · {formatBytes(bundle.size)}
</span>
</button>
{/each}
{/if}
</div>
</aside>
<main class="flex min-h-0 min-w-0 flex-col">
{#if errorMessage}
<div
class="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
>
<AlertTriangle class="size-4" />
{errorMessage}
</div>
{/if}
<div class="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-theme-800 p-2">
{#if entries.length > 0}
{#each entries as entry (entry.name)}
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] {selectedLog === entry.name
? 'bg-primary/15 text-primary'
: 'text-muted-foreground'}"
onclick={() => (selectedLog = entry.name)}
>
{entry.name}
</Button>
{/each}
{/if}
<div class="relative ml-auto w-52">
<Search
class="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-7 border-theme-800 bg-theme-950 pl-7 text-[10px]"
placeholder="Filter lines…"
bind:value={query}
/>
</div>
<div
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1"
>
<Checkbox id="log-errors-only" bind:checked={errorsOnly} />
<label for="log-errors-only" class="text-[10px] text-muted-foreground">
Errors only
</label>
</div>
<Button
variant="outline"
size="sm"
class="h-7 border-theme-800 bg-theme-950 text-[10px]"
onclick={copyVisible}
disabled={filteredLines.length === 0}
>
<Copy class="size-3" />
Copy
</Button>
</div>
<div class="min-h-0 flex-1 overflow-auto bg-black/25">
{#if loadingEntries}
<div
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
>
<LoaderCircle class="size-4 animate-spin" />
Reading bundle…
</div>
{:else if !selectedBundle}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
>
<Terminal class="size-8 opacity-40" />
<p class="text-xs">Choose a log bundle to inspect it.</p>
</div>
{:else if filteredLines.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
>
<Search class="size-6 opacity-40" />
<p class="text-xs">No lines match this filter.</p>
</div>
{:else}
<div class="p-3 font-mono text-[10px] leading-relaxed">
{#each visibleLines as line, index (index)}
<div class="whitespace-pre-wrap break-words {lineClass(line)}">{line || ' '}</div>
{/each}
{#if filteredLines.length > visibleLines.length}
<p class="mt-2 text-amber-200">
Showing the first {visibleLines.length} matching lines. Narrow the filter to see more.
</p>
{/if}
</div>
{/if}
</div>
<footer
class="flex shrink-0 items-center justify-between border-t border-theme-800 px-3 py-1.5 text-[9px] text-muted-foreground"
>
<span>{filteredLines.length} matching lines</span>
<span>
{selectedBundle
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
: 'No bundle selected'}
</span>
</footer>
</main>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
@@ -39,6 +39,17 @@
</div>
</div>
<div class="space-y-1">
<Label for="element-name" class={label}>Layer name</Label>
<Input
id="element-name"
class={field}
value={element.name ?? ''}
placeholder="Optional label, for example Logo"
oninput={(event) => (element.name = (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-x" class={label}>X</Label>
@@ -98,6 +109,29 @@
</div>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-theme-800 pt-3">
<div class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 px-2 py-1.5">
<Checkbox
id="element-hidden"
checked={element.hidden === true}
onCheckedChange={(checked) => (element.hidden = checked === true)}
/>
<Label for="element-hidden" class="text-[10px] font-normal text-muted-foreground">
Hidden
</Label>
</div>
<div class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 px-2 py-1.5">
<Checkbox
id="element-locked"
checked={element.locked === true}
onCheckedChange={(checked) => (element.locked = checked === true)}
/>
<Label for="element-locked" class="text-[10px] font-normal text-muted-foreground">
Locked
</Label>
</div>
</div>
{#if element.type === 'text'}
<div class="space-y-3 border-t border-theme-800 pt-3">
<div class="space-y-1">
+13 -7
View File
@@ -10,20 +10,26 @@
definition,
assets = '',
assetOverrides = {},
selectedId = undefined,
selectedIds = [],
interactive = false,
scale = 1,
onselect,
onselectmany,
onmove,
onpatch,
oncontext,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
selectedId?: string;
selectedIds?: string[];
interactive?: boolean;
scale?: number;
onselect?: (id: string | undefined) => void;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
oncontext?: (id: string | undefined, event: MouseEvent) => void;
} = $props();
const bodyFont = $derived(definition.typography?.body);
@@ -53,9 +59,6 @@
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
style={stageStyle}
role="presentation"
onpointerdown={() => {
if (interactive) onselect?.(undefined);
}}
>
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
@@ -185,9 +188,12 @@
{assets}
{assetOverrides}
{interactive}
{selectedId}
{selectedIds}
{scale}
{onselect}
{onselectmany}
{onmove}
{onpatch}
{oncontext}
/>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { custom_theme_volume } from '$lib/global';
import { themeMediaSource } from '$lib/themeMedia';
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
import type { ThemeV2Definition } from '$lib/themeV2';
import { themeAssetUrl } from '$lib/themeV2';
import { reduceAnimations } from '$lib/userSettings';
@@ -116,6 +116,7 @@
<video
class="absolute inset-0 h-full w-full object-cover"
use:themeMediaSource={assetUrl(background.src)}
use:themeVideoLifecycle
autoplay
loop
muted={background.muted !== false}
+282 -35
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { fontStack } from '$lib/themeFonts';
import { themeMediaSource } from '$lib/themeMedia';
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
let {
@@ -8,22 +8,29 @@
assets = '',
assetOverrides = {},
interactive = false,
selectedId = undefined,
selectedIds = [],
scale = 1,
onselect,
onselectmany,
onmove,
onpatch,
oncontext,
}: {
elements?: ThemeV2Element[];
assets?: string;
assetOverrides?: Record<string, string>;
interactive?: boolean;
selectedId?: string;
selectedIds?: string[];
scale?: number;
onselect?: (id: string | undefined) => void;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
oncontext?: (id: string | undefined, event: MouseEvent) => void;
} = $props();
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
const visibleElements = $derived(elements.filter((element) => !element.hidden));
// Keep the live muted property in sync, including live editor toggles.
function applyMuted(node: HTMLVideoElement, muted: boolean) {
@@ -35,68 +42,209 @@
};
}
type DragState = {
type ElementOrigin = {
id: string;
x: number;
y: number;
width: number;
height: number;
};
type DragState = {
ids: string[];
startX: number;
startY: number;
originX: number;
originY: number;
origins: ElementOrigin[];
};
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
type ResizeState = DragState & {
type ResizeState = {
id: string;
direction: ResizeDirection;
startX: number;
startY: number;
originX: number;
originY: number;
originWidth: number;
originHeight: number;
};
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
type MarqueeState = {
startX: number;
startY: number;
x: number;
y: number;
width: number;
height: number;
additive: boolean;
};
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
const SNAP_DISTANCE = 6;
let overlay = $state<HTMLDivElement | undefined>(undefined);
let drag = $state<DragState | null>(null);
let resize = $state<ResizeState | null>(null);
let marquee = $state<MarqueeState | null>(null);
let snapGuides = $state<{ x?: number; y?: number }>({});
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const selectedSet = $derived(new Set(selectedIds));
const isSelected = (id: string) => interactive && selectedSet.has(id);
const modifierPressed = (event: PointerEvent | KeyboardEvent) =>
event.shiftKey || event.metaKey || event.ctrlKey;
function selectElement(id: string | undefined, additive = false) {
onselect?.(id, additive);
}
function beginDrag(event: PointerEvent, element: ThemeV2Element) {
if (!interactive || (event.target as HTMLElement).dataset.resize) return;
if (!interactive || event.button !== 0) return;
if ((event.target as HTMLElement).dataset.resize) return;
event.preventDefault();
event.stopPropagation();
onselect?.(element.id);
const additive = modifierPressed(event);
if (additive) {
selectElement(element.id, true);
return;
}
if (element.locked) {
selectElement(element.id, false);
return;
}
const keepGroup = selectedSet.has(element.id) && selectedIds.length > 1;
if (!keepGroup) selectElement(element.id, false);
const candidateIds = keepGroup ? [...selectedIds] : [element.id];
const origins = candidateIds
.map((id) => elements.find((candidate) => candidate.id === id))
.filter(
(candidate): candidate is ThemeV2Element => candidate !== undefined && !candidate.locked
)
.map((candidate) => ({
id: candidate.id,
x: candidate.x,
y: candidate.y,
width: candidate.width,
height: candidate.height,
}));
if (origins.length === 0) return;
drag = {
id: element.id,
ids: origins.map((origin) => origin.id),
startX: event.clientX,
startY: event.clientY,
originX: element.x,
originY: element.y,
origins,
};
snapGuides = {};
window.addEventListener('pointermove', moveDrag);
window.addEventListener('pointerup', endDrag, { once: true });
}
function groupBounds(origins: ElementOrigin[]) {
const left = Math.min(...origins.map((origin) => origin.x));
const top = Math.min(...origins.map((origin) => origin.y));
const right = Math.max(...origins.map((origin) => origin.x + origin.width));
const bottom = Math.max(...origins.map((origin) => origin.y + origin.height));
return { left, top, right, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
}
function closestSnap(
lines: number[],
candidates: number[]
): { delta: number; guide: number } | undefined {
let best: { delta: number; guide: number } | undefined;
for (const line of lines) {
for (const candidate of candidates) {
const delta = candidate - line;
if (Math.abs(delta) > SNAP_DISTANCE) continue;
if (!best || Math.abs(delta) < Math.abs(best.delta)) {
best = { delta, guide: candidate };
}
}
}
return best;
}
function moveDrag(event: PointerEvent) {
if (!drag) return;
const element = elements.find((candidate) => candidate.id === drag!.id);
if (!element) return;
const dx = (event.clientX - drag.startX) / scale;
const dy = (event.clientY - drag.startY) / scale;
onpatch?.(drag.id, {
x: Math.round(clamp(drag.originX + dx, 0, 1000 - element.width)),
y: Math.round(clamp(drag.originY + dy, 0, 650 - element.height)),
});
const bounds = groupBounds(drag.origins);
const rawDx = (event.clientX - drag.startX) / scale;
const rawDy = (event.clientY - drag.startY) / scale;
let dx = clamp(rawDx, -bounds.left, 1000 - bounds.right);
let dy = clamp(rawDy, -bounds.top, 650 - bounds.bottom);
if (!event.altKey) {
const others = visibleElements.filter((element) => !drag!.ids.includes(element.id));
const xCandidates = [
0,
500,
1000,
...others.flatMap((element) => [
element.x,
element.x + element.width / 2,
element.x + element.width,
]),
];
const yCandidates = [
0,
325,
650,
...others.flatMap((element) => [
element.y,
element.y + element.height / 2,
element.y + element.height,
]),
];
const xSnap = closestSnap(
[bounds.left + dx, bounds.centerX + dx, bounds.right + dx],
xCandidates
);
const ySnap = closestSnap(
[bounds.top + dy, bounds.centerY + dy, bounds.bottom + dy],
yCandidates
);
if (xSnap) dx += xSnap.delta;
if (ySnap) dy += ySnap.delta;
snapGuides = { x: xSnap?.guide, y: ySnap?.guide };
} else {
snapGuides = {};
}
dx = clamp(dx, -bounds.left, 1000 - bounds.right);
dy = clamp(dy, -bounds.top, 650 - bounds.bottom);
onmove?.(
drag.origins.map((origin) => ({
id: origin.id,
x: Math.round(clamp(origin.x + dx, 0, 1000 - origin.width)),
y: Math.round(clamp(origin.y + dy, 0, 650 - origin.height)),
}))
);
}
function endDrag() {
drag = null;
snapGuides = {};
window.removeEventListener('pointermove', moveDrag);
}
function beginResize(
event: PointerEvent,
element: ThemeV2Element,
direction: ResizeState['direction']
) {
if (!interactive) return;
function beginResize(event: PointerEvent, element: ThemeV2Element, direction: ResizeDirection) {
if (!interactive || element.locked) return;
event.preventDefault();
event.stopPropagation();
resize = {
@@ -137,30 +285,100 @@
resize = null;
window.removeEventListener('pointermove', moveResize);
}
function beginMarquee(event: PointerEvent) {
if (!interactive || event.button !== 0 || event.target !== event.currentTarget || !overlay) {
return;
}
const rect = overlay.getBoundingClientRect();
const startX = clamp((event.clientX - rect.left) / scale, 0, 1000);
const startY = clamp((event.clientY - rect.top) / scale, 0, 650);
const additive = modifierPressed(event);
if (!additive) onselectmany?.([], false);
marquee = { startX, startY, x: startX, y: startY, width: 0, height: 0, additive };
window.addEventListener('pointermove', moveMarquee);
window.addEventListener('pointerup', endMarquee, { once: true });
}
function moveMarquee(event: PointerEvent) {
if (!marquee || !overlay) return;
const rect = overlay.getBoundingClientRect();
const currentX = clamp((event.clientX - rect.left) / scale, 0, 1000);
const currentY = clamp((event.clientY - rect.top) / scale, 0, 650);
marquee = {
...marquee,
x: Math.min(marquee.startX, currentX),
y: Math.min(marquee.startY, currentY),
width: Math.abs(currentX - marquee.startX),
height: Math.abs(currentY - marquee.startY),
};
}
function endMarquee() {
if (!marquee) return;
const selection = marquee;
marquee = null;
window.removeEventListener('pointermove', moveMarquee);
if (selection.width < 3 && selection.height < 3) {
return;
}
const hits = visibleElements
.filter(
(element) =>
!element.locked &&
element.x < selection.x + selection.width &&
element.x + element.width > selection.x &&
element.y < selection.y + selection.height &&
element.y + element.height > selection.y
)
.map((element) => element.id);
onselectmany?.(hits, selection.additive);
}
</script>
<div
bind:this={overlay}
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
role="presentation"
onpointerdown={beginMarquee}
oncontextmenu={(event) => {
if (!interactive || event.target !== event.currentTarget) return;
event.preventDefault();
oncontext?.(undefined, event);
}}
>
{#each elements as element (element.id)}
{@const isSelected = interactive && element.id === selectedId}
{#each visibleElements as element (element.id)}
{@const selected = isSelected(element.id)}
{@const singleSelected = selected && selectedIds.length === 1}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="absolute select-none"
class:cursor-move={interactive}
class:cursor-move={interactive && !element.locked}
class:cursor-default={interactive && element.locked}
style="left:{element.x}px;top:{element.y}px;width:{element.width}px;height:{element.height}px;transform:rotate({element.rotation ??
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{isSelected
? '2px solid #ff006e'
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{selected
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
: 'none'};outline-offset:2px;"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : -1}
aria-label={interactive ? `Select ${element.type} layer` : undefined}
onpointerdown={(event) => beginDrag(event, element)}
oncontextmenu={(event) => {
if (!interactive) return;
event.preventDefault();
oncontext?.(element.id, event);
}}
onkeydown={(event) => {
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onselect?.(element.id);
selectElement(element.id, modifierPressed(event));
}
}}
>
@@ -199,6 +417,7 @@
<video
class="pointer-events-none h-full w-full"
use:themeMediaSource={assetUrl(element.src)}
use:themeVideoLifecycle
autoplay
loop
muted={element.muted !== false}
@@ -219,7 +438,7 @@
></div>
{/if}
{#if isSelected}
{#if singleSelected && !element.locked}
{#each resizeDirections as direction (direction)}
<span
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
@@ -239,6 +458,34 @@
{element.type}
</span>
{/if}
{#if selected && selectedIds.length > 1}
<span
class="absolute -top-5 left-0 rounded bg-primary/90 px-1 py-0.5 text-[8px] font-semibold text-primary-foreground"
>
selected
</span>
{/if}
</div>
{/each}
{#if snapGuides.x !== undefined}
<div
class="pointer-events-none absolute inset-y-0 z-30 w-px bg-primary/80"
style="left:{snapGuides.x}px"
></div>
{/if}
{#if snapGuides.y !== undefined}
<div
class="pointer-events-none absolute inset-x-0 z-30 h-px bg-primary/80"
style="top:{snapGuides.y}px"
></div>
{/if}
{#if marquee}
<div
class="pointer-events-none absolute z-30 border border-primary bg-primary/10"
style="left:{marquee.x}px;top:{marquee.y}px;width:{marquee.width}px;height:{marquee.height}px;"
></div>
{/if}
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
inset,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
inset?: boolean;
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
{closeOnSelect}
bind:checked
bind:indeterminate
data-slot="context-menu-checkbox-item"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>
@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithoutChildrenOrChild } from '$lib/utils.js';
import ContextMenuPortal from './context-menu-portal.svelte';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
portalProps,
class: className,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
} = $props();
</script>
<ContextMenuPortal {...portalProps}>
<ContextMenuPrimitive.Content
bind:ref
data-slot="context-menu-content"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--bits-context-menu-content-available-height) origin-(--bits-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
className
)}
{...restProps}
/>
</ContextMenuPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
data-slot="context-menu-group-heading"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
</script>
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<ContextMenuPrimitive.Item
bind:ref
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="context-menu-label"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
</script>
<ContextMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(''),
...restProps
}: ContextMenuPrimitive.RadioGroupProps = $props();
</script>
<ContextMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="context-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,37 @@
<script lang="ts">
import { cn, type WithoutChild } from '$lib/utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
{closeOnSelect}
data-slot="context-menu-radio-item"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
data-slot="context-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="context-menu-shortcut"
class={cn(
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className
)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ContextMenuPortal from './context-menu-portal.svelte';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
align = 'start',
alignOffset = -3,
portalProps,
...restProps
}: ContextMenuPrimitive.SubContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
} = $props();
</script>
<ContextMenuPortal {...portalProps}>
<ContextMenuPrimitive.SubContent
bind:ref
data-slot="context-menu-sub-content"
{align}
{alignOffset}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 z-50 origin-(--bits-context-menu-content-transform-origin) overflow-hidden',
className
)}
{...restProps}
/>
</ContextMenuPortal>
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithoutChild } from '$lib/utils.js';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
data-slot="context-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</ContextMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props();
</script>
<ContextMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.TriggerProps = $props();
</script>
<ContextMenuPrimitive.Trigger
bind:ref
data-slot="context-menu-trigger"
class={cn('select-none', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props();
</script>
<ContextMenuPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,52 @@
import CheckboxItem from './context-menu-checkbox-item.svelte';
import Content from './context-menu-content.svelte';
import GroupHeading from './context-menu-group-heading.svelte';
import Group from './context-menu-group.svelte';
import Item from './context-menu-item.svelte';
import Label from './context-menu-label.svelte';
import Portal from './context-menu-portal.svelte';
import RadioGroup from './context-menu-radio-group.svelte';
import RadioItem from './context-menu-radio-item.svelte';
import Separator from './context-menu-separator.svelte';
import Shortcut from './context-menu-shortcut.svelte';
import SubContent from './context-menu-sub-content.svelte';
import SubTrigger from './context-menu-sub-trigger.svelte';
import Sub from './context-menu-sub.svelte';
import Trigger from './context-menu-trigger.svelte';
import Root from './context-menu.svelte';
export {
Root,
Sub,
Portal,
Item,
GroupHeading,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Portal as ContextMenuPortal,
Item as ContextMenuItem,
GroupHeading as ContextMenuGroupHeading,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem,
Label as ContextMenuLabel,
};
+202 -19
View File
@@ -1,35 +1,131 @@
/**
* Theme media playback helper.
*
* On macOS, WKWebView's media pipeline asks Tauri's custom `asset:` protocol
* for thousands of one-byte ranges when it plays a WebM file. That request
* storm starves the audio decoder, which makes the audio stutter or drop out.
* Fetching the file once and handing the media element a `blob:` URL avoids
* the custom protocol entirely and lets WebKit decode from a local buffer.
* Linux and macOS mainly use the loopback theme server, which supports byte
* ranges and streams large videos without loading them into memory. This file
* still provides a blob fallback for macOS when an asset URL must be used
* directly. WKWebView asks Tauri's custom `asset:` protocol for thousands of
* one-byte ranges, which can starve the audio decoder. A blob avoids that, but
* WebKit's blob-backed media path starts stuttering past roughly 30 MB, so
* large files are left on the direct URL instead.
*/
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { writable } from 'svelte/store';
type MediaCleanup = () => void;
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
const loopbackThemePattern = /^http:\/\/127\.0\.0\.1:\d+\/themes\//i;
const videoFilePattern = /\.(?:webm|mp4|m4v|mov|ogv|mkv)(?:[?#]|$)/i;
const MAX_BLOB_BYTES = 24 * 1024 * 1024;
const OPTIMIZE_AFTER_BYTES = 20 * 1024 * 1024;
// The request-storm problem is specific to WKWebView. Keep the native
// streaming path everywhere else, where the asset protocol behaves correctly
// (and where blob-backed media can fail to decode in WebKitGTK).
const useBlobMedia =
typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
const useBlobMedia = isMac;
/** True while a large theme video is being prepared for hardware playback. */
export const themeMediaPreparing = writable(false);
/** Human-readable detail for the preparation overlay. */
export const themeMediaStatus = writable('');
type ThemeMediaProgress = {
stage: 'download' | 'extract' | 'optimize';
progress: number;
};
let preparingCount = 0;
let statusListenerStarted = false;
const setPreparing = (delta: number) => {
preparingCount = Math.max(0, preparingCount + delta);
themeMediaPreparing.set(preparingCount > 0);
if (preparingCount === 0) themeMediaStatus.set('');
};
const ensureStatusListener = async () => {
if (statusListenerStarted || typeof window === 'undefined') return;
statusListenerStarted = true;
try {
await listen<ThemeMediaProgress>('theme-media-progress', (event) => {
const { stage, progress } = event.payload;
if (stage === 'download') {
themeMediaStatus.set(`Downloading video engine… ${Math.round(progress * 100)}%`);
} else if (stage === 'extract') {
themeMediaStatus.set('Installing video engine…');
} else if (stage === 'optimize') {
themeMediaStatus.set('Converting theme video for hardware playback…');
}
});
} catch (error) {
console.warn('Could not listen for theme video preparation progress', error);
}
};
const blobUrlPromises = new Map<string, Promise<string>>();
const optimizedUrlPromises = new Map<string, Promise<string>>();
const objectUrls = new Set<string>();
const mediaTokens = new WeakMap<HTMLMediaElement, number>();
const mediaCleanups = new WeakMap<HTMLMediaElement, MediaCleanup>();
const autoplayArmed = new WeakSet<HTMLMediaElement>();
let tokenCounter = 0;
let cacheGeneration = 0;
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
!!url && assetPattern.test(url);
const isLoopbackThemeVideo = (source: string) =>
isMac && loopbackThemePattern.test(source) && videoFilePattern.test(source);
/**
* WebKit's VP9 decoder is software-only on macOS, and a large 1080p theme
* video can starve the audio decoder. Ask the launcher for a cached 720p
* H.264/AAC version, which the macOS media engine decodes in hardware.
*/
const optimizedThemeMediaUrl = async (source: string): Promise<string> => {
if (!isLoopbackThemeVideo(source)) return source;
const cached = optimizedUrlPromises.get(source);
if (cached) return cached;
const promise = (async () => {
try {
const head = await fetch(source, { method: 'HEAD', cache: 'no-store' });
const declaredLength = Number(head.headers.get('content-length'));
if (!head.ok || !Number.isFinite(declaredLength) || declaredLength < OPTIMIZE_AFTER_BYTES) {
return source;
}
setPreparing(1);
themeMediaStatus.set('Preparing theme video for smooth playback…');
await ensureStatusListener();
try {
const optimized = await invoke<string>('optimize_theme_media', {
sourceUrl: source,
});
return optimized || source;
} finally {
setPreparing(-1);
}
} catch (error) {
console.warn('Could not optimize theme video for hardware playback', error);
return source;
}
})();
optimizedUrlPromises.set(source, promise);
return promise;
};
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
export const themeMediaUrl = async (source: string): Promise<string> => {
const optimized = await optimizedThemeMediaUrl(source);
if (optimized !== source) return optimized;
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
const cached = blobUrlPromises.get(source);
@@ -41,6 +137,12 @@ export const themeMediaUrl = async (source: string): Promise<string> => {
const response = await fetch(source);
if (!response.ok) throw new Error(`Theme media request failed (${response.status})`);
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > MAX_BLOB_BYTES) {
void response.body?.cancel();
return source;
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
@@ -67,10 +169,44 @@ export const themeMediaUrl = async (source: string): Promise<string> => {
export const clearThemeMediaCache = () => {
cacheGeneration += 1;
blobUrlPromises.clear();
optimizedUrlPromises.clear();
for (const url of objectUrls) URL.revokeObjectURL(url);
objectUrls.clear();
};
/** Retry playback on the first click or key press after an autoplay rejection. */
const armAutoplayRetry = (node: HTMLMediaElement) => {
if (autoplayArmed.has(node)) return;
autoplayArmed.add(node);
const retry = () => {
document.removeEventListener('pointerdown', retry, true);
document.removeEventListener('keydown', retry, true);
autoplayArmed.delete(node);
if (node.isConnected) void node.play().catch(() => {});
};
document.addEventListener('pointerdown', retry, true);
document.addEventListener('keydown', retry, true);
};
const playThemeMedia = (node: HTMLMediaElement) => {
void node
.play()
.then(() => {
console.debug('Theme media playing', {
element: node.tagName,
muted: node.muted,
volume: node.volume,
source: node.currentSrc,
});
})
.catch((error) => {
console.warn('Theme media autoplay was blocked', error);
armAutoplayRetry(node);
});
};
/**
* Swap a media element over to a blob URL while keeping its playback position
* and state. `source` must be the original asset URL, not the blob URL.
@@ -81,12 +217,21 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
mediaCleanups.get(node)?.();
mediaCleanups.delete(node);
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
const wasPlaying = !node.paused;
const needsPreparation = node instanceof HTMLVideoElement && isLoopbackThemeVideo(source);
// Keep the last frame visible while the first H.264 encode runs instead of
// letting WebKit struggle through the software VP9 track.
if (needsPreparation) node.pause();
void themeMediaUrl(source).then((url) => {
if (mediaTokens.get(node) !== token || !node.isConnected) return;
if (node.src === url) return;
const wasPlaying = !node.paused;
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
if (node.src === url) {
if (wasPlaying || node.autoplay) playThemeMedia(node);
return;
}
const cleanup = () => {
node.removeEventListener('loadedmetadata', onLoaded);
@@ -105,22 +250,19 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
}
}
if (wasPlaying || node.autoplay) {
void node.play().catch(() => {
// Autoplay can be rejected until the user interacts with the window.
});
}
if (wasPlaying || node.autoplay) playThemeMedia(node);
};
const onError = () => {
cleanup();
mediaCleanups.delete(node);
// Keep the theme usable if this WebView cannot decode the blob.
if (isThemeAssetUrl(source) && node.src !== source) {
// Keep the theme usable if this WebView cannot decode the blob or the
// optimized file.
if (node.src !== source) {
node.src = source;
node.load();
if (wasPlaying || node.autoplay) void node.play().catch(() => {});
if (wasPlaying || node.autoplay) playThemeMedia(node);
}
};
@@ -133,6 +275,46 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
});
};
/**
* Pause background videos while the launcher is hidden or unfocused, then
* resume only the videos this helper paused itself. This keeps muted theme
* videos from decoding in the background and prevents surprise audio when the
* window comes back.
*/
export function themeVideoLifecycle(node: HTMLVideoElement) {
let pausedByLifecycle = false;
const pause = () => {
if (node.paused) return;
pausedByLifecycle = true;
node.pause();
};
const resume = () => {
if (!pausedByLifecycle) return;
pausedByLifecycle = false;
playThemeMedia(node);
};
const sync = () => {
if (document.visibilityState === 'visible' && document.hasFocus()) resume();
else pause();
};
document.addEventListener('visibilitychange', sync);
window.addEventListener('focus', sync);
window.addEventListener('blur', sync);
sync();
return {
destroy() {
document.removeEventListener('visibilitychange', sync);
window.removeEventListener('focus', sync);
window.removeEventListener('blur', sync);
},
};
}
/**
* Svelte action for v2 theme videos/audio. Use this instead of `src=...`.
*/
@@ -166,7 +348,8 @@ export function smoothThemeMedia(node: HTMLElement) {
media.getAttribute('src') ??
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
undefined;
if (!isThemeAssetUrl(source)) return;
if (!source) return;
if (!isThemeAssetUrl(source) && !loopbackThemePattern.test(source)) return;
if (lastSource.get(media) === source) return;
lastSource.set(media, source);
+1
View File
@@ -1124,6 +1124,7 @@ export const renderThemePreview = async ({
drawStatsCard(context);
drawFooter(context);
for (const element of definition.elements ?? []) {
if (element.hidden) continue;
await drawElement(context, element);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Theme } from '$lib/themes';
/**
* API 1.0 themes are executable bundles: loading one imports its entry script
* into the launcher window. API 2.0 themes are declarative data and do not
* need a trust decision.
*/
const TRUSTED_THEMES_KEY = 'ezpplauncher.trusted-v1-themes';
export const themeRunsCode = (theme: Pick<Theme, 'apiVersion' | 'scriptUrl'>) =>
theme.apiVersion.split('.')[0] === '1' && theme.scriptUrl.length > 0;
export const themeTrustKey = (theme: Pick<Theme, 'folder_name' | 'version'>) =>
`${theme.folder_name}@${theme.version}`;
function readTrustedThemes(): string[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(TRUSTED_THEMES_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === 'string') : [];
} catch {
return [];
}
}
export const isThemeTrusted = (theme: Theme) =>
!themeRunsCode(theme) || readTrustedThemes().includes(themeTrustKey(theme));
export const trustTheme = (theme: Theme) => {
if (!themeRunsCode(theme) || typeof window === 'undefined') return;
const trusted = readTrustedThemes();
const key = themeTrustKey(theme);
if (!trusted.includes(key)) {
localStorage.setItem(TRUSTED_THEMES_KEY, JSON.stringify([...trusted, key]));
}
};
+9
View File
@@ -81,6 +81,8 @@ export type ThemeV2ElementVerticalAlign = 'top' | 'center' | 'bottom';
export type ThemeV2ElementBase = {
id: string;
/** Optional editor-facing name; does not affect rendering. */
name?: string;
/** Stage coordinates: the launcher canvas is 1000 x 650. */
x: number;
y: number;
@@ -88,6 +90,10 @@ export type ThemeV2ElementBase = {
height: number;
rotation?: number;
opacity?: number;
/** Hidden layers stay in the package but are not rendered. */
hidden?: boolean;
/** Locked layers can be selected in the layer list but not dragged on canvas. */
locked?: boolean;
};
export type ThemeV2TextElement = ThemeV2ElementBase & {
@@ -222,12 +228,15 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
const type = oneOf(source.type, ['text', 'image', 'video', 'shape'] as const, 'text');
const base = {
id: text(source.id, `element_${index + 1}`).slice(0, 64),
name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined,
x: clamp(source.x, 0, 0, 1000),
y: clamp(source.y, 0, 0, 650),
width: clamp(source.width, 320, 24, 1000),
height: clamp(source.height, 120, 24, 650),
rotation: clamp(source.rotation, 0, -360, 360),
opacity: clamp(source.opacity, 1, 0, 1),
hidden: source.hidden === true,
locked: source.locked === true,
};
if (type === 'text') {
+79 -9
View File
@@ -24,7 +24,17 @@ export type Theme = {
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
progress: number;
updateAvailable: boolean;
/** The version available in the repository when an update is pending. */
availableVersion?: string;
definition?: ThemeV2Definition;
backups?: ThemeBackup[];
};
export type ThemeBackup = {
path: string;
label: string;
createdAt: number;
size: number;
};
export type ThemeInfo = {
@@ -239,11 +249,26 @@ export const getThemes = async (): Promise<Theme[]> => {
if (await fs.exists(folderPath)) {
const themeFolders = await fs.readDir(folderPath);
for (const themeFolder of themeFolders) {
const theme = await themeFromFolder(
themeFolder.name,
await path.join(folderPath, themeFolder.name)
);
if (theme) themes.push(theme);
// Finder drops .DS_Store into this folder; never let a stray file or an
// unreadable package hide every other installed theme.
if (!themeFolder.isDirectory || themeFolder.name.startsWith('.')) continue;
const themeFolderPath = await path.join(folderPath, themeFolder.name);
try {
const theme = await themeFromFolder(themeFolder.name, themeFolderPath);
if (theme) {
try {
theme.backups = await invoke<ThemeBackup[]>('list_theme_backups', {
themeFolder: themeFolderPath,
});
} catch {
theme.backups = [];
}
themes.push(theme);
}
} catch (error) {
console.error(`Failed to load theme ${themeFolder.name}`, error);
}
}
}
return themes;
@@ -254,6 +279,7 @@ export const downloadTheme = async (
force = false
): Promise<{ success: boolean; error?: string }> => {
if (theme.status !== 'not-installed' && !force) return { success: false };
const wasInstalled = theme.status === 'installed';
patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
@@ -265,7 +291,10 @@ export const downloadTheme = async (
);
if (themeContents.error) {
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
patchTheme(theme.name, (current) => ({
...current,
status: wasInstalled ? 'installed' : 'not-installed',
}));
return {
success: false,
error: themeContents.error.message ?? 'An unknown error occurred',
@@ -286,6 +315,19 @@ export const downloadTheme = async (
progress: 0,
}));
let updateBackup: string | undefined;
const restoreUpdateBackup = async () => {
if (!updateBackup) return;
try {
await invoke('restore_theme_backup', {
themeFolder,
backupFolder: updateBackup,
});
} catch (error) {
console.error('Could not restore the theme backup after a failed update', error);
}
};
let extractionStartedAt = 0;
const beginExtraction = (progress: number) => {
if (extractionStartedAt === 0) extractionStartedAt = Date.now();
@@ -318,6 +360,16 @@ export const downloadTheme = async (
});
try {
// Keep the currently installed files before an update overwrites them.
// The backup also covers a failed/partial download so the user can roll back.
if (theme.status === 'installed') {
try {
updateBackup = await invoke<string>('backup_theme_folder', { themeFolder });
} catch (error) {
console.warn('Could not back up the theme before updating it', error);
}
}
await invoke('download_and_extract_theme', {
downloadUrl,
expectedSha: themeContents.data.file_contents.sha,
@@ -327,7 +379,11 @@ export const downloadTheme = async (
} catch (err) {
console.log(err);
const erro = err as Error;
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
await restoreUpdateBackup();
patchTheme(theme.name, (current) => ({
...current,
status: wasInstalled ? 'installed' : 'not-installed',
}));
return { success: false, error: erro.message || 'Failed to download or extract theme.' };
} finally {
unlistenDownload();
@@ -349,7 +405,11 @@ export const downloadTheme = async (
const installedTheme = await themeFromFolder(folderName, themeFolder);
if (!installedTheme) {
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
await restoreUpdateBackup();
patchTheme(theme.name, (current) => ({
...current,
status: wasInstalled ? 'installed' : 'not-installed',
}));
return { success: false, error: 'The downloaded theme package is invalid.' };
}
@@ -478,7 +538,9 @@ export const reloadThemes = async () => {
if (installed) {
try {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
const installedTheme = combinedMap.get(downloadable.name)!;
installedTheme.updateAvailable = true;
installedTheme.availableVersion = downloadable.version;
}
} catch {
// Ignore malformed versions instead of breaking the whole theme list.
@@ -513,7 +575,9 @@ export const reloadThemes = async () => {
nsfw: theme.nsfw,
definition: theme.definition,
updateAvailable: theme.updateAvailable,
availableVersion: theme.availableVersion,
cssStyle: theme.cssStyle,
backups: theme.backups ?? storeTheme.backups,
};
});
@@ -555,6 +619,12 @@ export const exportThemeFolder = async (folderName: string, destinationPath: str
await invoke('export_theme', { themeFolder, destinationPath });
};
export const listThemeBackups = async (themeFolder: string) =>
await invoke<ThemeBackup[]>('list_theme_backups', { themeFolder });
export const restoreThemeBackup = async (themeFolder: string, backupFolder: string) =>
await invoke('restore_theme_backup', { themeFolder, backupFolder });
export const exportTheme = async (theme: Theme, destinationPath: string) =>
exportThemeFolder(theme.folder_name, destinationPath);
+10
View File
@@ -8,6 +8,16 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
export type WithoutChild<T> = T extends { child?: unknown } ? Omit<T, 'child'> : T;
export type WithoutChildren<T> = T extends { children?: unknown } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export const numberHumanReadable = (number: number) => {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
};
+22 -3
View File
@@ -30,7 +30,8 @@
import { initLogging } from '$lib/logging';
import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil';
import * as presence from '$lib/presence';
import { smoothThemeMedia } from '$lib/themeMedia';
import { smoothThemeMedia, themeMediaPreparing, themeMediaStatus } from '$lib/themeMedia';
import { isThemeTrusted } from '$lib/themeTrust';
import { themeV2Css } from '$lib/themeV2';
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
import {
@@ -165,10 +166,20 @@
const localThemes = await getThemes();
const lastThemeName = await config_theme.get('Default');
const defaultTheme = localThemes.find((t) => t.name === 'Default') ?? localThemes[0];
const last_theme = localThemes.find((t) => t.name === lastThemeName);
if (!last_theme) {
const canLoadLastTheme = Boolean(last_theme && isThemeTrusted(last_theme));
if (!last_theme || !canLoadLastTheme) {
await config.value<string>('theme').set('Default');
loadTheme(localThemes[0], $custom_theme_container!, await config_theme_volume.get(0.15));
loadTheme(defaultTheme, $custom_theme_container!, await config_theme_volume.get(0.15));
if (last_theme && !canLoadLastTheme) {
sileo.error({
title: 'Theme needs approval',
description:
'The last theme runs executable code and has not been trusted on this device. The default theme was loaded instead.',
});
}
} else {
loadTheme(last_theme, $custom_theme_container!, await config_theme_volume.get(0.15));
}
@@ -342,4 +353,12 @@
{/if}
{@render children()}
</main>
{#if $themeMediaPreparing}
<div
class="pointer-events-none fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-full border border-white/10 bg-black/75 px-4 py-2 text-xs font-medium text-white/80 shadow-xl backdrop-blur animate-pulse"
>
{$themeMediaStatus || 'Preparing theme video for smooth playback…'}
</div>
{/if}
{/if}
+1359 -625
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -566,8 +566,9 @@
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Requires
.NET in your Wine prefix.{/if}
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Stable
patching needs no extra runtime; the experimental stream needs .NET 8 in
your Wine prefix.{/if}
</p>
</div>
<Checkbox