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:
1 parent
87202de656
commit
c02b26e8ae
41 files changed
+5129
-420
No files matched your search
@@ -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
|
||||
|
||||
+28
-12
@@ -246,14 +246,17 @@ top. Themes can contain up to 100 layers.
|
||||
Every layer supports:
|
||||
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+858
-9
@@ -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,26 +1552,33 @@ 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) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::theme_server::base_url().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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, '.');
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
+957
-223
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
Reference in new issue
Block a user