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
+5574
-865
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] Discord Rich Presence
|
||||||
- [x] Performance Display Overlay (currently in experimental patcher release stream)
|
- [x] Performance Display Overlay (currently in experimental patcher release stream)
|
||||||
- [x] Launcher Theming (API 1.0 bundles and API 2.0 declarative themes)
|
- [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) |
|
| Linux | 🕧 Partially supported (via osu-winello) |
|
||||||
|
|
||||||
> **Windows** remains the fully supported platform. OpenTabletDriver is Windows-only. On macOS,
|
> **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
|
> in-game patching runs the Windows patcher through the same Wine prefix as osu!. The stable
|
||||||
> needs a .NET runtime installed.
|
> patcher stream needs no extra runtime; the experimental stream needs the .NET 8 Desktop Runtime
|
||||||
|
> installed in that prefix.
|
||||||
|
|
||||||
### macOS
|
### 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
|
folder); the launcher resolves the osu! installation inside it, so there is no need to open the
|
||||||
bundle's package contents.
|
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
|
## 🪵 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
|
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
|
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
|
`logs_<date>_<time>.zip`; the five most recent bundles are kept. You can turn file logging off,
|
||||||
or open the folder from **Settings → Logging**.
|
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`.
|
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
|
## 🛠 Build From Source
|
||||||
|
|||||||
+36
-20
@@ -245,15 +245,18 @@ top. Themes can contain up to 100 layers.
|
|||||||
|
|
||||||
Every layer supports:
|
Every layer supports:
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
| ---------- | ------ | ----------------------------------- |
|
| ---------- | ------- | ------------------------------------------------ |
|
||||||
| `id` | string | Unique within the theme |
|
| `id` | string | Unique within the theme |
|
||||||
| `type` | string | `text`, `image`, `video` or `shape` |
|
| `name` | string? | Optional editor label; does not affect rendering |
|
||||||
| `x`, `y` | number | Position in the 1000 × 650 stage |
|
| `type` | string | `text`, `image`, `video` or `shape` |
|
||||||
| `width` | number | 24–1000 |
|
| `x`, `y` | number | Position in the 1000 × 650 stage |
|
||||||
| `height` | number | 24–650 |
|
| `width` | number | 24–1000 |
|
||||||
| `rotation` | number | Degrees, `-360`–`360` |
|
| `height` | number | 24–650 |
|
||||||
| `opacity` | number | `0`–`1` |
|
| `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
|
#### Text
|
||||||
|
|
||||||
@@ -357,27 +360,40 @@ small tweaks, but it is not required. Example:
|
|||||||
full launcher home canvas at 1000 × 650.
|
full launcher home canvas at 1000 × 650.
|
||||||
4. Use the left toolbar to add text, image, video and shape layers. Drag layers on
|
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
|
the canvas, resize with the corner handles, or edit exact values in the right
|
||||||
inspector.
|
inspector. Shift/Cmd-click a layer or drag a marquee over empty canvas to
|
||||||
5. Use the **Theme** tab for metadata, background, effects, typography and custom
|
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
|
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
|
family, or import WOFF2, WOFF, TTF and OTF files. Downloaded fonts are copied
|
||||||
into the theme's `assets/` folder when you save.
|
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
|
`~/.ezpplauncher/themes`. A fresh `assets/preview.png` thumbnail is rendered
|
||||||
from the theme and the launcher applies the saved theme immediately.
|
from the theme and the launcher applies the saved theme immediately. Unsaved
|
||||||
7. Select **Export** to create a shareable `.ezpplauncher-theme` package. Export
|
edits are autosaved as a local draft and offered for recovery after a crash.
|
||||||
saves and refreshes the preview first, so the package always includes the
|
8. Select **Export** to create a shareable `.ezpplauncher-theme` package. A theme
|
||||||
current thumbnail.
|
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:
|
Keyboard shortcuts:
|
||||||
|
|
||||||
- `Cmd/Ctrl+Z` undoes the last change.
|
- `Cmd/Ctrl+Z` undoes the last change.
|
||||||
- `Cmd/Ctrl+Shift+Z` or `Ctrl+Y` redoes it.
|
- `Cmd/Ctrl+Shift+Z` or `Ctrl+Y` redoes it.
|
||||||
- `Cmd/Ctrl+S` saves.
|
- `Cmd/Ctrl+S` saves.
|
||||||
- `Cmd/Ctrl+D` duplicates the selected layer.
|
- `Cmd/Ctrl+D` duplicates the selected layer or multi-selection.
|
||||||
- Arrow keys nudge the selected layer by 1 px; hold Shift for 10 px.
|
- `Cmd/Ctrl+A` selects every visible layer.
|
||||||
- `Delete` removes the selected layer.
|
- Shift/Cmd-click toggles a layer in the current selection.
|
||||||
- `Esc` deselects the layer.
|
- 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"
|
log = "0.4"
|
||||||
time = { version = "0.3", features = ["local-offset"] }
|
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-body-util = "0.1.3"
|
||||||
http-range = "0.1.5"
|
http-range = "0.1.5"
|
||||||
hyper = { version = "1.8.1", features = ["server", "http1"] }
|
hyper = { version = "1.8.1", features = ["server", "http1"] }
|
||||||
|
|||||||
+859
-10
@@ -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}"))
|
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]
|
#[tauri::command]
|
||||||
pub fn get_hwid() -> String {
|
pub fn get_hwid() -> String {
|
||||||
get_id()
|
get_id()
|
||||||
@@ -135,6 +143,135 @@ fn normalized_osu_folder(folder: &str) -> Option<PathBuf> {
|
|||||||
is_valid_osu_folder(&path).then_some(path)
|
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]
|
#[tauri::command]
|
||||||
pub fn find_osu_installation() -> Option<String> {
|
pub fn find_osu_installation() -> Option<String> {
|
||||||
find_osu_installation_impl()
|
find_osu_installation_impl()
|
||||||
@@ -1415,12 +1552,19 @@ pub(crate) fn theme_root() -> Result<PathBuf, String> {
|
|||||||
std::fs::canonicalize(&root).map_err(|e| format!("Failed to resolve the themes directory: {e}"))
|
std::fs::canonicalize(&root).map_err(|e| format!("Failed to resolve the themes directory: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
fn linux_theme_asset_base() -> String {
|
fn loopback_theme_asset_base() -> String {
|
||||||
if crate::theme_server::base_url().is_none() {
|
if crate::theme_server::base_url().is_none() {
|
||||||
if let Ok(root) = theme_root() {
|
if let Ok(root) = theme_root() {
|
||||||
if let Err(error) = crate::theme_server::start(root) {
|
match crate::theme_server::media_root() {
|
||||||
log::warn!(target: "launcher", "Could not start the theme asset server: {error}");
|
Ok(media) => {
|
||||||
|
if let Err(error) = crate::theme_server::start(root, media) {
|
||||||
|
log::warn!(target: "launcher", "Could not start the theme asset server: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!(target: "launcher", "Could not prepare the media cache: {error}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1429,12 +1573,12 @@ fn linux_theme_asset_base() -> String {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn theme_asset_base() -> String {
|
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()
|
String::new()
|
||||||
}
|
}
|
||||||
@@ -1442,19 +1586,545 @@ pub fn theme_asset_base() -> String {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn theme_preview_url(path: String) -> Result<String, String> {
|
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));
|
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;
|
let _ = path;
|
||||||
Ok(String::new())
|
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)]
|
#[derive(Serialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThemeEditorContext {
|
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`.
|
/// Only allow writing inside `~/.ezpplauncher/themes`.
|
||||||
fn checked_theme_folder(theme_folder: &str) -> Result<PathBuf, String> {
|
fn checked_theme_folder(theme_folder: &str) -> Result<PathBuf, String> {
|
||||||
let root = theme_root()?;
|
let root = theme_root()?;
|
||||||
|
|||||||
+39
-19
@@ -13,25 +13,27 @@ pub mod macos;
|
|||||||
pub mod osudb;
|
pub mod osudb;
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
pub mod theme_server;
|
pub mod theme_server;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{
|
commands::{
|
||||||
check_for_corruption, config_all, config_clear, config_delete, config_exists, config_get,
|
backup_theme_folder, check_for_corruption, config_all, config_clear, config_delete,
|
||||||
config_set, download_and_extract_theme, download_ezpp_launcher_update,
|
config_exists, config_get, config_set, download_and_extract_theme,
|
||||||
download_ezpp_launcher_update_files, encrypt_string, exit, export_theme, extract_theme,
|
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
||||||
find_osu_installation, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid,
|
export_theme, extract_theme, file_size, find_osu_installation, get_beatmapsets_count,
|
||||||
get_launcher_version, get_osu_release_stream, get_osu_skin, get_osu_version, get_platform,
|
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
||||||
get_skins_count, has_net8, has_osuwinello, has_wmctrl, install_ezpp_launcher_update,
|
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,
|
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,
|
list_theme_backups, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder,
|
||||||
opened_urls, pick_osu_installation, presence_connect, presence_disconnect,
|
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||||
presence_is_connected, presence_update_button, presence_update_status,
|
presence_connect, presence_disconnect, presence_is_connected, presence_update_button,
|
||||||
presence_update_user, read_theme_info, replace_ui_files, resolve_osu_folder,
|
presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
|
||||||
run_open_tablet_driver, run_osu, run_osu_updater, save_theme_project, set_logging_enabled,
|
resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater,
|
||||||
set_osu_config_values, set_osu_user_config_values, stop_open_tablet_driver,
|
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
||||||
theme_asset_base, theme_editor_context, theme_preview_url, valid_osu_folder,
|
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
||||||
|
valid_osu_folder,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -111,10 +113,14 @@ pub fn run() {
|
|||||||
editor_theme_folder: Mutex::new(None),
|
editor_theme_folder: Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebKitGTK cannot play theme media through the custom asset
|
// WebKit's custom-protocol media pipeline is unreliable (and
|
||||||
// protocol, so Linux gets a real loopback HTTP server instead.
|
// loads large WebMs into memory), so Linux and macOS use a real
|
||||||
#[cfg(target_os = "linux")]
|
// loopback HTTP server with byte-range support instead.
|
||||||
match commands::theme_root().and_then(theme_server::start) {
|
#[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(_) => {}
|
Ok(_) => {}
|
||||||
Err(error) => log::warn!(
|
Err(error) => log::warn!(
|
||||||
target: "launcher",
|
target: "launcher",
|
||||||
@@ -127,6 +133,9 @@ pub fn run() {
|
|||||||
if let Err(error) = macos::round_window(&window, 12.0) {
|
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||||
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
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(())
|
Ok(())
|
||||||
@@ -142,6 +151,7 @@ pub fn run() {
|
|||||||
get_skins_count,
|
get_skins_count,
|
||||||
get_osu_version,
|
get_osu_version,
|
||||||
get_osu_release_stream,
|
get_osu_release_stream,
|
||||||
|
installation_diagnostics,
|
||||||
set_osu_config_values,
|
set_osu_config_values,
|
||||||
set_osu_user_config_values,
|
set_osu_user_config_values,
|
||||||
run_osu_updater,
|
run_osu_updater,
|
||||||
@@ -155,6 +165,7 @@ pub fn run() {
|
|||||||
get_launcher_version,
|
get_launcher_version,
|
||||||
exit,
|
exit,
|
||||||
get_platform,
|
get_platform,
|
||||||
|
file_size,
|
||||||
check_for_corruption,
|
check_for_corruption,
|
||||||
presence_connect,
|
presence_connect,
|
||||||
presence_disconnect,
|
presence_disconnect,
|
||||||
@@ -169,6 +180,9 @@ pub fn run() {
|
|||||||
download_ezpp_launcher_update,
|
download_ezpp_launcher_update,
|
||||||
install_ezpp_launcher_update,
|
install_ezpp_launcher_update,
|
||||||
read_theme_info,
|
read_theme_info,
|
||||||
|
backup_theme_folder,
|
||||||
|
list_theme_backups,
|
||||||
|
restore_theme_backup,
|
||||||
extract_theme,
|
extract_theme,
|
||||||
download_and_extract_theme,
|
download_and_extract_theme,
|
||||||
save_theme_project,
|
save_theme_project,
|
||||||
@@ -178,6 +192,7 @@ pub fn run() {
|
|||||||
theme_editor_context,
|
theme_editor_context,
|
||||||
theme_asset_base,
|
theme_asset_base,
|
||||||
theme_preview_url,
|
theme_preview_url,
|
||||||
|
optimize_theme_media,
|
||||||
config_get,
|
config_get,
|
||||||
config_set,
|
config_set,
|
||||||
config_exists,
|
config_exists,
|
||||||
@@ -190,7 +205,9 @@ pub fn run() {
|
|||||||
is_open_tablet_driver_path_valid,
|
is_open_tablet_driver_path_valid,
|
||||||
log_frontend,
|
log_frontend,
|
||||||
set_logging_enabled,
|
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_fs::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -208,6 +225,9 @@ pub fn run() {
|
|||||||
if let Err(error) = macos::round_window(&window, 12.0) {
|
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||||
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
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.
|
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
|
||||||
|
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use log::Level;
|
use log::Level;
|
||||||
|
use serde::Serialize;
|
||||||
use tauri::Runtime;
|
use tauri::Runtime;
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::plugin::TauriPlugin;
|
||||||
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
|
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
|
||||||
@@ -376,6 +377,105 @@ pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.build()
|
.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`.
|
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||||
pub fn capture_osu_output(child: &mut Child) {
|
pub fn capture_osu_output(child: &mut Child) {
|
||||||
capture_output(child, "osu");
|
capture_output(child, "osu");
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), St
|
|||||||
Ok(())
|
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.
|
/// Show a native open panel that accepts `.app` bundles as well as folders.
|
||||||
///
|
///
|
||||||
/// AppKit treats application bundles as packages (directories) by default, so a
|
/// AppKit treats application bundles as packages (directories) by default, so a
|
||||||
@@ -133,6 +162,55 @@ pub struct WineRuntime {
|
|||||||
wine_root: Option<PathBuf>,
|
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 {
|
impl WineRuntime {
|
||||||
/// Build a runtime description for an osu! folder inside a Wine prefix.
|
/// Build a runtime description for an osu! folder inside a Wine prefix.
|
||||||
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
|
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())
|
.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> {
|
fn find_in_path(binary: &str) -> Option<PathBuf> {
|
||||||
let path = std::env::var_os("PATH")?;
|
let path = std::env::var_os("PATH")?;
|
||||||
std::env::split_paths(&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:`
|
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
|
||||||
//! protocol, and module scripts from that scheme are unreliable too. A real
|
//! protocol, and WKWebView's range handling makes large media stall there.
|
||||||
//! loopback HTTP server gives WebKit normal status codes, range responses and
|
//! A real loopback HTTP server gives WebKit normal status codes, range
|
||||||
//! MIME types, so v1 and v2 theme assets load the same way they do in a
|
//! responses and MIME types, so v1 and v2 theme assets load the same way they
|
||||||
//! browser.
|
//! do in a browser without loading a whole video into a blob first.
|
||||||
|
|
||||||
use http_body_util::Full;
|
use http_body_util::Full;
|
||||||
use http_range::HttpRange;
|
use http_range::HttpRange;
|
||||||
@@ -34,19 +34,22 @@ const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
|
|||||||
|
|
||||||
struct ThemeServer {
|
struct ThemeServer {
|
||||||
port: u16,
|
port: u16,
|
||||||
|
root: PathBuf,
|
||||||
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
|
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
|
||||||
|
|
||||||
/// Bind the server on a random loopback port. The root is the themes folder.
|
/// 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() {
|
if let Some(server) = SERVER.get() {
|
||||||
return Ok(server.port);
|
return Ok(server.port);
|
||||||
}
|
}
|
||||||
|
|
||||||
let root = std::fs::canonicalize(&root)
|
let root = std::fs::canonicalize(&root)
|
||||||
.map_err(|error| format!("Could not resolve the themes folder: {error}"))?;
|
.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))
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
|
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
|
||||||
listener
|
listener
|
||||||
@@ -60,11 +63,13 @@ pub fn start(root: PathBuf) -> Result<u16, String> {
|
|||||||
let previews = Arc::new(Mutex::new(HashMap::new()));
|
let previews = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let server = ThemeServer {
|
let server = ThemeServer {
|
||||||
port,
|
port,
|
||||||
|
root: root.clone(),
|
||||||
previews: previews.clone(),
|
previews: previews.clone(),
|
||||||
};
|
};
|
||||||
let _ = SERVER.set(server);
|
let _ = SERVER.set(server);
|
||||||
|
|
||||||
let root = Arc::new(root);
|
let root = Arc::new(root);
|
||||||
|
let media_root = Arc::new(media_root);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let listener = match TcpListener::from_std(listener) {
|
let listener = match TcpListener::from_std(listener) {
|
||||||
Ok(listener) => listener,
|
Ok(listener) => listener,
|
||||||
@@ -89,10 +94,12 @@ pub fn start(root: PathBuf) -> Result<u16, String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
let media_root = media_root.clone();
|
||||||
let previews = previews.clone();
|
let previews = previews.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let service =
|
let service = service_fn(move |request| {
|
||||||
service_fn(move |request| handle(request, root.clone(), previews.clone()));
|
handle(request, root.clone(), media_root.clone(), previews.clone())
|
||||||
|
});
|
||||||
|
|
||||||
if let Err(error) = http1::Builder::new()
|
if let Err(error) = http1::Builder::new()
|
||||||
.serve_connection(TokioIo::new(stream), service)
|
.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))
|
.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
|
/// 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
|
/// over HTTP too. The token is only a lookup key; unregistered paths are never
|
||||||
/// readable through the server.
|
/// readable through the server.
|
||||||
@@ -149,6 +198,7 @@ pub fn preview_url(path: &Path) -> Result<String, String> {
|
|||||||
async fn handle(
|
async fn handle(
|
||||||
request: Request<Incoming>,
|
request: Request<Incoming>,
|
||||||
root: Arc<PathBuf>,
|
root: Arc<PathBuf>,
|
||||||
|
media_root: Arc<PathBuf>,
|
||||||
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||||
) -> Result<Response<Body>, Infallible> {
|
) -> Result<Response<Body>, Infallible> {
|
||||||
let origin = request
|
let origin = request
|
||||||
@@ -179,8 +229,15 @@ async fn handle(
|
|||||||
.decode_utf8_lossy()
|
.decode_utf8_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
target: "launcher",
|
||||||
|
"Theme asset request: {path} range={range:?}"
|
||||||
|
);
|
||||||
|
|
||||||
let file = if let Some(relative) = path.strip_prefix("/themes/") {
|
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/") {
|
} else if let Some(token) = path.strip_prefix("/preview/") {
|
||||||
previews
|
previews
|
||||||
.lock()
|
.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('/');
|
let relative = relative.trim_start_matches('/');
|
||||||
if relative.is_empty()
|
if relative.is_empty()
|
||||||
|| relative
|
|| 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>
|
</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="grid grid-cols-2 gap-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-x" class={label}>X</Label>
|
<Label for="element-x" class={label}>X</Label>
|
||||||
@@ -98,6 +109,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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'}
|
{#if element.type === 'text'}
|
||||||
<div class="space-y-3 border-t border-theme-800 pt-3">
|
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|||||||
@@ -10,20 +10,26 @@
|
|||||||
definition,
|
definition,
|
||||||
assets = '',
|
assets = '',
|
||||||
assetOverrides = {},
|
assetOverrides = {},
|
||||||
selectedId = undefined,
|
selectedIds = [],
|
||||||
interactive = false,
|
interactive = false,
|
||||||
scale = 1,
|
scale = 1,
|
||||||
onselect,
|
onselect,
|
||||||
|
onselectmany,
|
||||||
|
onmove,
|
||||||
onpatch,
|
onpatch,
|
||||||
|
oncontext,
|
||||||
}: {
|
}: {
|
||||||
definition: ThemeV2Definition;
|
definition: ThemeV2Definition;
|
||||||
assets?: string;
|
assets?: string;
|
||||||
assetOverrides?: Record<string, string>;
|
assetOverrides?: Record<string, string>;
|
||||||
selectedId?: string;
|
selectedIds?: string[];
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
scale?: number;
|
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;
|
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const bodyFont = $derived(definition.typography?.body);
|
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"
|
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||||
style={stageStyle}
|
style={stageStyle}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onpointerdown={() => {
|
|
||||||
if (interactive) onselect?.(undefined);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
|
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
|
||||||
|
|
||||||
@@ -185,9 +188,12 @@
|
|||||||
{assets}
|
{assets}
|
||||||
{assetOverrides}
|
{assetOverrides}
|
||||||
{interactive}
|
{interactive}
|
||||||
{selectedId}
|
{selectedIds}
|
||||||
{scale}
|
{scale}
|
||||||
{onselect}
|
{onselect}
|
||||||
|
{onselectmany}
|
||||||
|
{onmove}
|
||||||
{onpatch}
|
{onpatch}
|
||||||
|
{oncontext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { custom_theme_volume } from '$lib/global';
|
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 type { ThemeV2Definition } from '$lib/themeV2';
|
||||||
import { themeAssetUrl } from '$lib/themeV2';
|
import { themeAssetUrl } from '$lib/themeV2';
|
||||||
import { reduceAnimations } from '$lib/userSettings';
|
import { reduceAnimations } from '$lib/userSettings';
|
||||||
@@ -116,6 +116,7 @@
|
|||||||
<video
|
<video
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
use:themeMediaSource={assetUrl(background.src)}
|
use:themeMediaSource={assetUrl(background.src)}
|
||||||
|
use:themeVideoLifecycle
|
||||||
autoplay
|
autoplay
|
||||||
loop
|
loop
|
||||||
muted={background.muted !== false}
|
muted={background.muted !== false}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fontStack } from '$lib/themeFonts';
|
import { fontStack } from '$lib/themeFonts';
|
||||||
import { themeMediaSource } from '$lib/themeMedia';
|
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||||
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
|
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -8,22 +8,29 @@
|
|||||||
assets = '',
|
assets = '',
|
||||||
assetOverrides = {},
|
assetOverrides = {},
|
||||||
interactive = false,
|
interactive = false,
|
||||||
selectedId = undefined,
|
selectedIds = [],
|
||||||
scale = 1,
|
scale = 1,
|
||||||
onselect,
|
onselect,
|
||||||
|
onselectmany,
|
||||||
|
onmove,
|
||||||
onpatch,
|
onpatch,
|
||||||
|
oncontext,
|
||||||
}: {
|
}: {
|
||||||
elements?: ThemeV2Element[];
|
elements?: ThemeV2Element[];
|
||||||
assets?: string;
|
assets?: string;
|
||||||
assetOverrides?: Record<string, string>;
|
assetOverrides?: Record<string, string>;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
selectedId?: string;
|
selectedIds?: string[];
|
||||||
scale?: number;
|
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;
|
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
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.
|
// Keep the live muted property in sync, including live editor toggles.
|
||||||
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||||
@@ -35,68 +42,209 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DragState = {
|
type ElementOrigin = {
|
||||||
id: string;
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
ids: string[];
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
originX: number;
|
origins: ElementOrigin[];
|
||||||
originY: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
|
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
|
||||||
|
|
||||||
type ResizeState = DragState & {
|
type ResizeState = {
|
||||||
|
id: string;
|
||||||
direction: ResizeDirection;
|
direction: ResizeDirection;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
originWidth: number;
|
originWidth: number;
|
||||||
originHeight: 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 drag = $state<DragState | null>(null);
|
||||||
let resize = $state<ResizeState | 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 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) {
|
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.preventDefault();
|
||||||
event.stopPropagation();
|
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 = {
|
drag = {
|
||||||
id: element.id,
|
ids: origins.map((origin) => origin.id),
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
originX: element.x,
|
origins,
|
||||||
originY: element.y,
|
|
||||||
};
|
};
|
||||||
|
snapGuides = {};
|
||||||
window.addEventListener('pointermove', moveDrag);
|
window.addEventListener('pointermove', moveDrag);
|
||||||
window.addEventListener('pointerup', endDrag, { once: true });
|
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) {
|
function moveDrag(event: PointerEvent) {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const element = elements.find((candidate) => candidate.id === drag!.id);
|
|
||||||
if (!element) return;
|
const bounds = groupBounds(drag.origins);
|
||||||
const dx = (event.clientX - drag.startX) / scale;
|
const rawDx = (event.clientX - drag.startX) / scale;
|
||||||
const dy = (event.clientY - drag.startY) / scale;
|
const rawDy = (event.clientY - drag.startY) / scale;
|
||||||
onpatch?.(drag.id, {
|
let dx = clamp(rawDx, -bounds.left, 1000 - bounds.right);
|
||||||
x: Math.round(clamp(drag.originX + dx, 0, 1000 - element.width)),
|
let dy = clamp(rawDy, -bounds.top, 650 - bounds.bottom);
|
||||||
y: Math.round(clamp(drag.originY + dy, 0, 650 - element.height)),
|
|
||||||
});
|
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() {
|
function endDrag() {
|
||||||
drag = null;
|
drag = null;
|
||||||
|
snapGuides = {};
|
||||||
window.removeEventListener('pointermove', moveDrag);
|
window.removeEventListener('pointermove', moveDrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginResize(
|
function beginResize(event: PointerEvent, element: ThemeV2Element, direction: ResizeDirection) {
|
||||||
event: PointerEvent,
|
if (!interactive || element.locked) return;
|
||||||
element: ThemeV2Element,
|
|
||||||
direction: ResizeState['direction']
|
|
||||||
) {
|
|
||||||
if (!interactive) return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
resize = {
|
resize = {
|
||||||
@@ -137,30 +285,100 @@
|
|||||||
resize = null;
|
resize = null;
|
||||||
window.removeEventListener('pointermove', moveResize);
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={overlay}
|
||||||
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
|
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
onpointerdown={beginMarquee}
|
||||||
|
oncontextmenu={(event) => {
|
||||||
|
if (!interactive || event.target !== event.currentTarget) return;
|
||||||
|
event.preventDefault();
|
||||||
|
oncontext?.(undefined, event);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each elements as element (element.id)}
|
{#each visibleElements as element (element.id)}
|
||||||
{@const isSelected = interactive && element.id === selectedId}
|
{@const selected = isSelected(element.id)}
|
||||||
|
{@const singleSelected = selected && selectedIds.length === 1}
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
<div
|
<div
|
||||||
class="absolute select-none"
|
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 ??
|
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
|
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{selected
|
||||||
? '2px solid #ff006e'
|
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
|
||||||
: 'none'};outline-offset:2px;"
|
: 'none'};outline-offset:2px;"
|
||||||
role={interactive ? 'button' : undefined}
|
role={interactive ? 'button' : undefined}
|
||||||
tabindex={interactive ? 0 : -1}
|
tabindex={interactive ? 0 : -1}
|
||||||
aria-label={interactive ? `Select ${element.type} layer` : undefined}
|
aria-label={interactive ? `Select ${element.type} layer` : undefined}
|
||||||
onpointerdown={(event) => beginDrag(event, element)}
|
onpointerdown={(event) => beginDrag(event, element)}
|
||||||
|
oncontextmenu={(event) => {
|
||||||
|
if (!interactive) return;
|
||||||
|
event.preventDefault();
|
||||||
|
oncontext?.(element.id, event);
|
||||||
|
}}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
|
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onselect?.(element.id);
|
selectElement(element.id, modifierPressed(event));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -199,6 +417,7 @@
|
|||||||
<video
|
<video
|
||||||
class="pointer-events-none h-full w-full"
|
class="pointer-events-none h-full w-full"
|
||||||
use:themeMediaSource={assetUrl(element.src)}
|
use:themeMediaSource={assetUrl(element.src)}
|
||||||
|
use:themeVideoLifecycle
|
||||||
autoplay
|
autoplay
|
||||||
loop
|
loop
|
||||||
muted={element.muted !== false}
|
muted={element.muted !== false}
|
||||||
@@ -219,7 +438,7 @@
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isSelected}
|
{#if singleSelected && !element.locked}
|
||||||
{#each resizeDirections as direction (direction)}
|
{#each resizeDirections as direction (direction)}
|
||||||
<span
|
<span
|
||||||
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
|
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
|
||||||
@@ -239,6 +458,34 @@
|
|||||||
{element.type}
|
{element.type}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/each}
|
{/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>
|
</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.
|
* Theme media playback helper.
|
||||||
*
|
*
|
||||||
* On macOS, WKWebView's media pipeline asks Tauri's custom `asset:` protocol
|
* Linux and macOS mainly use the loopback theme server, which supports byte
|
||||||
* for thousands of one-byte ranges when it plays a WebM file. That request
|
* ranges and streams large videos without loading them into memory. This file
|
||||||
* storm starves the audio decoder, which makes the audio stutter or drop out.
|
* still provides a blob fallback for macOS when an asset URL must be used
|
||||||
* Fetching the file once and handing the media element a `blob:` URL avoids
|
* directly. WKWebView asks Tauri's custom `asset:` protocol for thousands of
|
||||||
* the custom protocol entirely and lets WebKit decode from a local buffer.
|
* 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;
|
type MediaCleanup = () => void;
|
||||||
|
|
||||||
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
|
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
|
// The request-storm problem is specific to WKWebView. Keep the native
|
||||||
// streaming path everywhere else, where the asset protocol behaves correctly
|
// streaming path everywhere else, where the asset protocol behaves correctly
|
||||||
// (and where blob-backed media can fail to decode in WebKitGTK).
|
// (and where blob-backed media can fail to decode in WebKitGTK).
|
||||||
const useBlobMedia =
|
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
|
||||||
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 blobUrlPromises = new Map<string, Promise<string>>();
|
||||||
|
const optimizedUrlPromises = new Map<string, Promise<string>>();
|
||||||
const objectUrls = new Set<string>();
|
const objectUrls = new Set<string>();
|
||||||
const mediaTokens = new WeakMap<HTMLMediaElement, number>();
|
const mediaTokens = new WeakMap<HTMLMediaElement, number>();
|
||||||
const mediaCleanups = new WeakMap<HTMLMediaElement, MediaCleanup>();
|
const mediaCleanups = new WeakMap<HTMLMediaElement, MediaCleanup>();
|
||||||
|
const autoplayArmed = new WeakSet<HTMLMediaElement>();
|
||||||
let tokenCounter = 0;
|
let tokenCounter = 0;
|
||||||
let cacheGeneration = 0;
|
let cacheGeneration = 0;
|
||||||
|
|
||||||
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
|
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
|
||||||
!!url && assetPattern.test(url);
|
!!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. */
|
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
|
||||||
export const themeMediaUrl = async (source: string): Promise<string> => {
|
export const themeMediaUrl = async (source: string): Promise<string> => {
|
||||||
|
const optimized = await optimizedThemeMediaUrl(source);
|
||||||
|
if (optimized !== source) return optimized;
|
||||||
|
|
||||||
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
|
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
|
||||||
|
|
||||||
const cached = blobUrlPromises.get(source);
|
const cached = blobUrlPromises.get(source);
|
||||||
@@ -41,6 +137,12 @@ export const themeMediaUrl = async (source: string): Promise<string> => {
|
|||||||
const response = await fetch(source);
|
const response = await fetch(source);
|
||||||
if (!response.ok) throw new Error(`Theme media request failed (${response.status})`);
|
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 blob = await response.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
@@ -67,10 +169,44 @@ export const themeMediaUrl = async (source: string): Promise<string> => {
|
|||||||
export const clearThemeMediaCache = () => {
|
export const clearThemeMediaCache = () => {
|
||||||
cacheGeneration += 1;
|
cacheGeneration += 1;
|
||||||
blobUrlPromises.clear();
|
blobUrlPromises.clear();
|
||||||
|
optimizedUrlPromises.clear();
|
||||||
for (const url of objectUrls) URL.revokeObjectURL(url);
|
for (const url of objectUrls) URL.revokeObjectURL(url);
|
||||||
objectUrls.clear();
|
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
|
* 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.
|
* 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.get(node)?.();
|
||||||
mediaCleanups.delete(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) => {
|
void themeMediaUrl(source).then((url) => {
|
||||||
if (mediaTokens.get(node) !== token || !node.isConnected) return;
|
if (mediaTokens.get(node) !== token || !node.isConnected) return;
|
||||||
if (node.src === url) return;
|
|
||||||
|
|
||||||
const wasPlaying = !node.paused;
|
if (node.src === url) {
|
||||||
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
|
if (wasPlaying || node.autoplay) playThemeMedia(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
node.removeEventListener('loadedmetadata', onLoaded);
|
node.removeEventListener('loadedmetadata', onLoaded);
|
||||||
@@ -105,22 +250,19 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasPlaying || node.autoplay) {
|
if (wasPlaying || node.autoplay) playThemeMedia(node);
|
||||||
void node.play().catch(() => {
|
|
||||||
// Autoplay can be rejected until the user interacts with the window.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
mediaCleanups.delete(node);
|
mediaCleanups.delete(node);
|
||||||
|
|
||||||
// Keep the theme usable if this WebView cannot decode the blob.
|
// Keep the theme usable if this WebView cannot decode the blob or the
|
||||||
if (isThemeAssetUrl(source) && node.src !== source) {
|
// optimized file.
|
||||||
|
if (node.src !== source) {
|
||||||
node.src = source;
|
node.src = source;
|
||||||
node.load();
|
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=...`.
|
* 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.getAttribute('src') ??
|
||||||
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
|
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
|
||||||
undefined;
|
undefined;
|
||||||
if (!isThemeAssetUrl(source)) return;
|
if (!source) return;
|
||||||
|
if (!isThemeAssetUrl(source) && !loopbackThemePattern.test(source)) return;
|
||||||
if (lastSource.get(media) === source) return;
|
if (lastSource.get(media) === source) return;
|
||||||
|
|
||||||
lastSource.set(media, source);
|
lastSource.set(media, source);
|
||||||
|
|||||||
@@ -1124,6 +1124,7 @@ export const renderThemePreview = async ({
|
|||||||
drawStatsCard(context);
|
drawStatsCard(context);
|
||||||
drawFooter(context);
|
drawFooter(context);
|
||||||
for (const element of definition.elements ?? []) {
|
for (const element of definition.elements ?? []) {
|
||||||
|
if (element.hidden) continue;
|
||||||
await drawElement(context, element);
|
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 = {
|
export type ThemeV2ElementBase = {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Optional editor-facing name; does not affect rendering. */
|
||||||
|
name?: string;
|
||||||
/** Stage coordinates: the launcher canvas is 1000 x 650. */
|
/** Stage coordinates: the launcher canvas is 1000 x 650. */
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -88,6 +90,10 @@ export type ThemeV2ElementBase = {
|
|||||||
height: number;
|
height: number;
|
||||||
rotation?: number;
|
rotation?: number;
|
||||||
opacity?: 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 & {
|
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 type = oneOf(source.type, ['text', 'image', 'video', 'shape'] as const, 'text');
|
||||||
const base = {
|
const base = {
|
||||||
id: text(source.id, `element_${index + 1}`).slice(0, 64),
|
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),
|
x: clamp(source.x, 0, 0, 1000),
|
||||||
y: clamp(source.y, 0, 0, 650),
|
y: clamp(source.y, 0, 0, 650),
|
||||||
width: clamp(source.width, 320, 24, 1000),
|
width: clamp(source.width, 320, 24, 1000),
|
||||||
height: clamp(source.height, 120, 24, 650),
|
height: clamp(source.height, 120, 24, 650),
|
||||||
rotation: clamp(source.rotation, 0, -360, 360),
|
rotation: clamp(source.rotation, 0, -360, 360),
|
||||||
opacity: clamp(source.opacity, 1, 0, 1),
|
opacity: clamp(source.opacity, 1, 0, 1),
|
||||||
|
hidden: source.hidden === true,
|
||||||
|
locked: source.locked === true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
|
|||||||
+79
-9
@@ -24,7 +24,17 @@ export type Theme = {
|
|||||||
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
||||||
progress: number;
|
progress: number;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
|
/** The version available in the repository when an update is pending. */
|
||||||
|
availableVersion?: string;
|
||||||
definition?: ThemeV2Definition;
|
definition?: ThemeV2Definition;
|
||||||
|
backups?: ThemeBackup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeBackup = {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
createdAt: number;
|
||||||
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeInfo = {
|
export type ThemeInfo = {
|
||||||
@@ -239,11 +249,26 @@ export const getThemes = async (): Promise<Theme[]> => {
|
|||||||
if (await fs.exists(folderPath)) {
|
if (await fs.exists(folderPath)) {
|
||||||
const themeFolders = await fs.readDir(folderPath);
|
const themeFolders = await fs.readDir(folderPath);
|
||||||
for (const themeFolder of themeFolders) {
|
for (const themeFolder of themeFolders) {
|
||||||
const theme = await themeFromFolder(
|
// Finder drops .DS_Store into this folder; never let a stray file or an
|
||||||
themeFolder.name,
|
// unreadable package hide every other installed theme.
|
||||||
await path.join(folderPath, themeFolder.name)
|
if (!themeFolder.isDirectory || themeFolder.name.startsWith('.')) continue;
|
||||||
);
|
|
||||||
if (theme) themes.push(theme);
|
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;
|
return themes;
|
||||||
@@ -254,6 +279,7 @@ export const downloadTheme = async (
|
|||||||
force = false
|
force = false
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
if (theme.status !== 'not-installed' && !force) return { success: false };
|
if (theme.status !== 'not-installed' && !force) return { success: false };
|
||||||
|
const wasInstalled = theme.status === 'installed';
|
||||||
|
|
||||||
patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
|
patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
|
||||||
|
|
||||||
@@ -265,7 +291,10 @@ export const downloadTheme = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (themeContents.error) {
|
if (themeContents.error) {
|
||||||
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
|
patchTheme(theme.name, (current) => ({
|
||||||
|
...current,
|
||||||
|
status: wasInstalled ? 'installed' : 'not-installed',
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: themeContents.error.message ?? 'An unknown error occurred',
|
error: themeContents.error.message ?? 'An unknown error occurred',
|
||||||
@@ -286,6 +315,19 @@ export const downloadTheme = async (
|
|||||||
progress: 0,
|
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;
|
let extractionStartedAt = 0;
|
||||||
const beginExtraction = (progress: number) => {
|
const beginExtraction = (progress: number) => {
|
||||||
if (extractionStartedAt === 0) extractionStartedAt = Date.now();
|
if (extractionStartedAt === 0) extractionStartedAt = Date.now();
|
||||||
@@ -318,6 +360,16 @@ export const downloadTheme = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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', {
|
await invoke('download_and_extract_theme', {
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
expectedSha: themeContents.data.file_contents.sha,
|
expectedSha: themeContents.data.file_contents.sha,
|
||||||
@@ -327,7 +379,11 @@ export const downloadTheme = async (
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
const erro = err as Error;
|
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.' };
|
return { success: false, error: erro.message || 'Failed to download or extract theme.' };
|
||||||
} finally {
|
} finally {
|
||||||
unlistenDownload();
|
unlistenDownload();
|
||||||
@@ -349,7 +405,11 @@ export const downloadTheme = async (
|
|||||||
|
|
||||||
const installedTheme = await themeFromFolder(folderName, themeFolder);
|
const installedTheme = await themeFromFolder(folderName, themeFolder);
|
||||||
if (!installedTheme) {
|
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.' };
|
return { success: false, error: 'The downloaded theme package is invalid.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +538,9 @@ export const reloadThemes = async () => {
|
|||||||
if (installed) {
|
if (installed) {
|
||||||
try {
|
try {
|
||||||
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
|
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 {
|
} catch {
|
||||||
// Ignore malformed versions instead of breaking the whole theme list.
|
// Ignore malformed versions instead of breaking the whole theme list.
|
||||||
@@ -513,7 +575,9 @@ export const reloadThemes = async () => {
|
|||||||
nsfw: theme.nsfw,
|
nsfw: theme.nsfw,
|
||||||
definition: theme.definition,
|
definition: theme.definition,
|
||||||
updateAvailable: theme.updateAvailable,
|
updateAvailable: theme.updateAvailable,
|
||||||
|
availableVersion: theme.availableVersion,
|
||||||
cssStyle: theme.cssStyle,
|
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 });
|
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) =>
|
export const exportTheme = async (theme: Theme, destinationPath: string) =>
|
||||||
exportThemeFolder(theme.folder_name, destinationPath);
|
exportThemeFolder(theme.folder_name, destinationPath);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
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) => {
|
export const numberHumanReadable = (number: number) => {
|
||||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
import { initLogging } from '$lib/logging';
|
import { initLogging } from '$lib/logging';
|
||||||
import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil';
|
import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil';
|
||||||
import * as presence from '$lib/presence';
|
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 { themeV2Css } from '$lib/themeV2';
|
||||||
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
|
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
|
||||||
import {
|
import {
|
||||||
@@ -165,10 +166,20 @@
|
|||||||
|
|
||||||
const localThemes = await getThemes();
|
const localThemes = await getThemes();
|
||||||
const lastThemeName = await config_theme.get('Default');
|
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);
|
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');
|
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 {
|
} else {
|
||||||
loadTheme(last_theme, $custom_theme_container!, await config_theme_volume.get(0.15));
|
loadTheme(last_theme, $custom_theme_container!, await config_theme_volume.get(0.15));
|
||||||
}
|
}
|
||||||
@@ -342,4 +353,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</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}
|
{/if}
|
||||||
+1359
-625
File diff suppressed because it is too large.
Load diff
@@ -566,8 +566,9 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
|
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
|
||||||
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||||
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Requires
|
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Stable
|
||||||
.NET in your Wine prefix.{/if}
|
patching needs no extra runtime; the experimental stream needs .NET 8 in
|
||||||
|
your Wine prefix.{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
Reference in new issue
Block a user