Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8071085e94 | ||
|
|
fbfa3aa54c | ||
|
|
c02b26e8ae | ||
|
|
87202de656 |
No files matched your search
@@ -14,28 +14,76 @@ It enhances the osu! experience with quality-of-life features and integration sp
|
|||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
- [x] Automatically updates osu! before launching
|
- [x] Automatically updates osu! before launching
|
||||||
- [x] Automatically start/stop the OpenTabletDriver Daemon
|
- [x] Automatically start/stop the OpenTabletDriver Daemon (Windows)
|
||||||
- [x] Automatic ingame login
|
- [x] Automatic ingame login
|
||||||
- [x] Patches the osu! client to show misses in Relax and Autopilot
|
- [x] Patches the osu! client to show misses in Relax and Autopilot (Windows, Linux and macOS via Wine)
|
||||||
- [x] Displays your EZPPFarm stats in the launcher
|
- [x] Displays your EZPPFarm stats in the launcher
|
||||||
- [x] Shows the number of imported beatmapsets
|
- [x] Shows the number of imported beatmapsets
|
||||||
- [x] Shows the number of imported skins
|
- [x] Shows the number of imported skins
|
||||||
- [x] Displays ping to the EZPPFarm server
|
- [x] Displays ping to the EZPPFarm server
|
||||||
- [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
|
- [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, 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💻 Supported Platforms
|
## 💻 Supported Platforms
|
||||||
|
|
||||||
| Platform | Status |
|
| Platform | Status |
|
||||||
| -------- | ---------------------------------------- |
|
| -------- | ------------------------------------------------------ |
|
||||||
| Windows | ✅ Supported |
|
| Windows | ✅ Supported |
|
||||||
| macOS | ❌ Not supported |
|
| macOS | 🕧 Partially supported (via Wineskin/Whisky/CrossOver) |
|
||||||
| Linux | 🕧 Partially supported (via osu-winello) |
|
| Linux | 🕧 Partially supported (via osu-winello) |
|
||||||
|
|
||||||
> Currently, only **Windows** is fully supported. Support for other platforms may be considered in the future.
|
> **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!. The stable
|
||||||
|
> patcher stream needs no extra runtime; the experimental stream needs the .NET 8 Desktop Runtime
|
||||||
|
> installed in that prefix.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
osu! stable is a Windows build, so on macOS it runs inside a Wine wrapper. EZPPLauncher
|
||||||
|
supports Wineskin wrappers (the usual `osu!.app` wine skin), Whisky bottles and CrossOver
|
||||||
|
bottles.
|
||||||
|
|
||||||
|
The setup wizard automatically looks for Wineskins in `/Applications` and `~/Applications`.
|
||||||
|
If detection fails, choose **Browse** and pick the `osu!.app` wrapper itself (or a Wine prefix
|
||||||
|
folder); the launcher resolves the osu! installation inside it, so there is no need to open the
|
||||||
|
bundle's package contents.
|
||||||
|
|
||||||
|
Large VP8/VP9/AV1 theme videos are transcoded once to a cached 720p H.264/AAC file so WebKit
|
||||||
|
can use the Mac's hardware decoder. The first large video triggers a one-time (~63 MB) download
|
||||||
|
of a signed static FFmpeg 9.0.2 build from `ffmpeg.martin-riedl.de` into `~/.ezpplauncher/bin`;
|
||||||
|
the launcher verifies its pinned SHA-256 and makes it executable. The managed build requires
|
||||||
|
macOS 12 or newer. If the download fails, a system FFmpeg (Homebrew or MacPorts) is used; without
|
||||||
|
either, themes still load but large WebM videos remain on WebKit's software decoder and may
|
||||||
|
stutter. Theme authors can also ship an H.264/MP4 version alongside the WebM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🪵 Logs
|
||||||
|
|
||||||
|
Launcher, osu! and patcher output is written to `.ezpplauncher/logs` in your home folder:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
| -------------- | -------------------------------------------------------------- |
|
||||||
|
| `launcher.log` | Launcher lifecycle, webview console output and uncaught errors |
|
||||||
|
| `osu.log` | osu! and osu! updater output, including stdout/stderr |
|
||||||
|
| `patcher.log` | Patcher output, including stdout/stderr |
|
||||||
|
|
||||||
|
A file is only created when that stream actually writes a line. On each new session, or when
|
||||||
|
any file reaches 5 MiB, all three active files are bundled into a single
|
||||||
|
`logs_<date>_<time>.zip`; the five most recent bundles are kept. You can turn file logging off,
|
||||||
|
open the folder, or browse and filter a bundle in the built-in viewer from **Settings → Logging**.
|
||||||
|
On Windows the folder is `%USERPROFILE%\.ezpplauncher\logs`.
|
||||||
|
|
||||||
|
**Settings → Diagnostics** checks the selected osu! install and write access, the macOS Wine
|
||||||
|
prefix/runtime/version and (for the experimental patcher stream) its .NET 8 runtime, the FFmpeg
|
||||||
|
theme-video transcoder, osu-winello/wmctrl on Linux, and whether the patcher is present. It can
|
||||||
|
copy a plain-text support report or reopen the setup wizard for a guided repair.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,6 +95,7 @@ It enhances the osu! experience with quality-of-life features and integration sp
|
|||||||
- **Cargo** (comes with rustup)
|
- **Cargo** (comes with rustup)
|
||||||
- **[CMake](https://cmake.org/download/)**
|
- **[CMake](https://cmake.org/download/)**
|
||||||
- **[Bun](https://bun.sh/)** (JavaScript runtime)
|
- **[Bun](https://bun.sh/)** (JavaScript runtime)
|
||||||
|
- On macOS: **Xcode Command Line Tools** (`xcode-select --install`)
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
|
|
||||||
@@ -61,7 +110,12 @@ bun run build
|
|||||||
After building, the launcher executable will be located at:
|
After building, the launcher executable will be located at:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
# Windows
|
||||||
src-tauri/target/release/ezpplauncher.exe
|
src-tauri/target/release/ezpplauncher.exe
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
src-tauri/target/release/bundle/macos/EZPPLauncher.app
|
||||||
|
src-tauri/target/release/bundle/dmg/EZPPLauncher_*.dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -69,10 +123,12 @@ src-tauri/target/release/ezpplauncher.exe
|
|||||||
## 📁 Project Structure (Simplified)
|
## 📁 Project Structure (Simplified)
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src/ # Frontend (Svelte)
|
├── src/ # Frontend (Svelte)
|
||||||
├── src-tauri/ # Backend (Rust)
|
├── src-tauri/ # Backend (Rust)
|
||||||
│ ├── tauri.conf.json # Tauri configuration
|
│ ├── src/macos.rs # Wine/Wineskin support for macOS
|
||||||
├── package.json # Bun project config
|
│ ├── tauri.conf.json # Tauri configuration
|
||||||
|
│ └── tauri.macos.conf.json # macOS bundle targets (app + dmg)
|
||||||
|
├── package.json # Bun project config
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,4 +136,15 @@ src-tauri/target/release/ezpplauncher.exe
|
|||||||
|
|
||||||
## 🎨 Launcher Themes
|
## 🎨 Launcher Themes
|
||||||
|
|
||||||
By default, the launcher includes several downloadable themes. If you want to create your own custom theme, please use the [create-ezpplauncher-theme](https://git.ez-pp.farm/EZPPFarm/create-ezpplauncher-theme) repository for a template and instructions.
|
By default, the launcher includes several downloadable themes. Themes can be created
|
||||||
|
in two ways:
|
||||||
|
|
||||||
|
- Use the built-in **advanced editor** on the Themes screen. It opens in its own
|
||||||
|
resizable window with a full launcher home canvas, freeform text/image/video/shape
|
||||||
|
layers, WebM support, `@fontsource` fonts and custom font imports. It creates API 2.0
|
||||||
|
themes and can export them as shareable `.ezpplauncher-theme` files.
|
||||||
|
- Use the [create-ezpplauncher-theme](https://git.ez-pp.farm/EZPPFarm/create-ezpplauncher-theme)
|
||||||
|
CLI. It supports both API 1.0 Svelte themes and API 2.0 declarative themes.
|
||||||
|
|
||||||
|
See [THEME_API_V2.md](THEME_API_V2.md) for the full API 2.0 schema, limits and packaging
|
||||||
|
layout. API 1.0 themes continue to load unchanged.
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
# EZPPLauncher Theme API 2.0
|
||||||
|
|
||||||
|
API 2.0 is a declarative theme format. Instead of shipping a JavaScript bundle that
|
||||||
|
mounts its own UI, a theme describes its background, effects, audio, fonts and
|
||||||
|
freeform layers in `theme.json`. The launcher renders it, and the built-in advanced
|
||||||
|
editor can create and edit these themes without writing code.
|
||||||
|
|
||||||
|
API 1.0 themes continue to work unchanged. Pick the API that fits the theme:
|
||||||
|
|
||||||
|
| | API 1.0 | API 2.0 |
|
||||||
|
| ----------------------- | ------------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| Rendering | Svelte bundle (`entry`) | Launcher-rendered |
|
||||||
|
| Files | `theme.json`, `bundle.js`, `style.css`, `assets/` | `theme.json`, `assets/` |
|
||||||
|
| Created with | `create-ezpplauncher-theme` | Launcher editor or `create-ezpplauncher-theme` |
|
||||||
|
| Scripts / arbitrary DOM | Yes | No (data only, optional CSS) |
|
||||||
|
| Cross-platform | Depends on the bundle | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package layout
|
||||||
|
|
||||||
|
A packaged `.ezpplauncher-theme` file is a zip with this structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
theme.json
|
||||||
|
assets/
|
||||||
|
background.webm
|
||||||
|
layer.png
|
||||||
|
music.mp3
|
||||||
|
font.woff2
|
||||||
|
preview.png # optional thumbnail
|
||||||
|
```
|
||||||
|
|
||||||
|
An installed theme lives in:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Windows
|
||||||
|
C:\Users\<user>\.ezpplauncher\themes\<folder>\
|
||||||
|
|
||||||
|
# macOS / Linux
|
||||||
|
~/.ezpplauncher/themes/<folder>/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `theme.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Midnight",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "2.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"description": "A dark gradient with drifting stars.",
|
||||||
|
"preview": "preview.png",
|
||||||
|
"background": {
|
||||||
|
"type": "gradient",
|
||||||
|
"from": "#181825",
|
||||||
|
"to": "#3b1d4f",
|
||||||
|
"angle": 135,
|
||||||
|
"opacity": 1
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"color": "#000000",
|
||||||
|
"opacity": 0.2,
|
||||||
|
"vignette": 0.35
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"type": "stars",
|
||||||
|
"density": 40,
|
||||||
|
"speed": 1,
|
||||||
|
"color": "#ffd6f2"
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "Sora Variable",
|
||||||
|
"heading": "Bebas Neue"
|
||||||
|
},
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"family": "My Display Font",
|
||||||
|
"src": "display.woff2",
|
||||||
|
"weight": "400",
|
||||||
|
"style": "normal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "tagline",
|
||||||
|
"type": "text",
|
||||||
|
"text": "MIDNIGHT",
|
||||||
|
"x": 640,
|
||||||
|
"y": 84,
|
||||||
|
"width": 300,
|
||||||
|
"height": 72,
|
||||||
|
"fontFamily": "Bebas Neue",
|
||||||
|
"fontSize": 46,
|
||||||
|
"fontWeight": 400,
|
||||||
|
"color": "#ffffff",
|
||||||
|
"align": "right",
|
||||||
|
"verticalAlign": "center",
|
||||||
|
"letterSpacing": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"audio": {
|
||||||
|
"src": "music.mp3",
|
||||||
|
"loop": true
|
||||||
|
},
|
||||||
|
"accentColor": "#ff006e",
|
||||||
|
"css": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
| ------------- | ------ | -------- | --------------------------------------------------- |
|
||||||
|
| `name` | string | yes | Display name; also used for the install folder |
|
||||||
|
| `version` | string | yes | Semver, used for update checks |
|
||||||
|
| `apiVersion` | string | yes | Must start with `2` (use `"2.0"`) |
|
||||||
|
| `author` | string | yes | Shown in the theme manager |
|
||||||
|
| `description` | string | no | Shown on the theme card |
|
||||||
|
| `nsfw` | bool | no | Marks the theme as adult content (`18+` badge) |
|
||||||
|
| `preview` | string | no | Card thumbnail; the editor generates `preview.png` |
|
||||||
|
| `accentColor` | string | no | Hex color used for focus rings and active UI states |
|
||||||
|
| `css` | string | no | Extra CSS injected while the theme is active |
|
||||||
|
|
||||||
|
### Background
|
||||||
|
|
||||||
|
`background.type` selects the layer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "color", "color": "#181825", "opacity": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "gradient", "from": "#181825", "to": "#3b1d4f", "angle": 135, "opacity": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "image", "src": "background.png", "fit": "cover", "opacity": 1, "blur": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"src": "background.webm",
|
||||||
|
"fit": "cover",
|
||||||
|
"opacity": 1,
|
||||||
|
"blur": 0,
|
||||||
|
"muted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `src` is relative to the theme's `assets/` folder.
|
||||||
|
- `fit` is `cover` (default) or `contain`.
|
||||||
|
- `opacity` is clamped to `0`–`1`, `blur` to `0`–`40` pixels.
|
||||||
|
- Background videos autoplay and loop. They are muted by default; set `muted` to `false`
|
||||||
|
to play their audio.
|
||||||
|
- Prefer **WebM** for video backgrounds and video layers. It is supported by the
|
||||||
|
launcher and keeps theme packages much smaller than MP4. MP4, MOV and M4V also
|
||||||
|
work.
|
||||||
|
|
||||||
|
### Overlay
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "color": "#000000", "opacity": 0.2, "vignette": 0.35 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`opacity` controls the flat dim layer and `vignette` darkens the edges. Both are
|
||||||
|
clamped to `0`–`1`.
|
||||||
|
|
||||||
|
### Effects
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "stars", "density": 40, "speed": 1, "color": "#ffd6f2" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported types: `none`, `stars`, `snow`, `petals`, `bubbles`.
|
||||||
|
|
||||||
|
- `density` is clamped to `0`–`120` particles.
|
||||||
|
- `speed` is clamped to `0.2`–`3`.
|
||||||
|
- Effects are disabled when the user turns on **Reduce animations**.
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "src": "music.mp3", "loop": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`src` is relative to `assets/`. Music uses the launcher's volume control and is
|
||||||
|
silent in the theme editor preview.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
`typography.body` changes the font used by the launcher UI. `typography.heading`
|
||||||
|
changes headings such as the launcher title.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"typography": {
|
||||||
|
"body": "Montserrat Variable",
|
||||||
|
"heading": "Playfair Display Variable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These families ship with the launcher through `@fontsource` and can be used
|
||||||
|
without adding any files:
|
||||||
|
|
||||||
|
| Family | Style |
|
||||||
|
| --------------------------- | ------- |
|
||||||
|
| `Sora Variable` | Sans |
|
||||||
|
| `Montserrat Variable` | Sans |
|
||||||
|
| `Bebas Neue` | Display |
|
||||||
|
| `Playfair Display Variable` | Serif |
|
||||||
|
| `Space Mono` | Mono |
|
||||||
|
| `JetBrains Mono Variable` | Mono |
|
||||||
|
|
||||||
|
Custom fonts can be packaged with the theme. The launcher generates the
|
||||||
|
`@font-face` rules from `fonts`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fonts": [
|
||||||
|
{ "family": "My Display Font", "src": "display.woff2", "weight": "400", "style": "normal" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `src` is relative to `assets/`.
|
||||||
|
- Supported formats: WOFF2 (recommended), WOFF, TTF and OTF.
|
||||||
|
- `weight` defaults to `400`; `style` defaults to `normal`.
|
||||||
|
- Use the `family` value in `typography` and in text layers.
|
||||||
|
|
||||||
|
The editor's **Fonts** tab browses the full Fontsource catalog (2,000+ families)
|
||||||
|
and downloads the selected family, weight, style and subset as a WOFF2 into the
|
||||||
|
theme's `assets/` folder. It adds the matching `fonts` entry automatically, so
|
||||||
|
saved and exported themes stay self-contained.
|
||||||
|
|
||||||
|
### Freeform layers
|
||||||
|
|
||||||
|
`elements` are rendered above the launcher UI, but only on the **Home** screen.
|
||||||
|
The launcher canvas is a fixed **1000 × 650** coordinate space. Elements can extend
|
||||||
|
the home screen with text, images, videos and shapes, and they are
|
||||||
|
pointer-transparent in the running launcher so they never block buttons.
|
||||||
|
|
||||||
|
The order of the `elements` array controls stacking: the last element is drawn on
|
||||||
|
top. Themes can contain up to 100 layers.
|
||||||
|
|
||||||
|
Every layer supports:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| ---------- | ------- | ------------------------------------------------ |
|
||||||
|
| `id` | string | Unique within the theme |
|
||||||
|
| `name` | string? | Optional editor label; does not affect rendering |
|
||||||
|
| `type` | string | `text`, `image`, `video` or `shape` |
|
||||||
|
| `x`, `y` | number | Position in the 1000 × 650 stage |
|
||||||
|
| `width` | number | 24–1000 |
|
||||||
|
| `height` | number | 24–650 |
|
||||||
|
| `rotation` | number | Degrees, `-360`–`360` |
|
||||||
|
| `opacity` | number | `0`–`1` |
|
||||||
|
| `hidden` | boolean | Keep the layer in the file but do not render it |
|
||||||
|
| `locked` | boolean | Prevent moving/resizing the layer in the editor |
|
||||||
|
|
||||||
|
#### Text
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "greeting",
|
||||||
|
"type": "text",
|
||||||
|
"text": "Welcome back!",
|
||||||
|
"x": 360,
|
||||||
|
"y": 220,
|
||||||
|
"width": 420,
|
||||||
|
"height": 90,
|
||||||
|
"fontFamily": "Bebas Neue",
|
||||||
|
"fontSize": 42,
|
||||||
|
"fontWeight": 600,
|
||||||
|
"color": "#ffffff",
|
||||||
|
"align": "center",
|
||||||
|
"verticalAlign": "center",
|
||||||
|
"lineHeight": 1.2,
|
||||||
|
"letterSpacing": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`align` is `left`, `center` or `right`; `verticalAlign` is `top`, `center` or
|
||||||
|
`bottom`. Font size is clamped to `6`–`240`.
|
||||||
|
|
||||||
|
#### Image and video
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "logo",
|
||||||
|
"type": "image",
|
||||||
|
"src": "logo.png",
|
||||||
|
"x": 700,
|
||||||
|
"y": 420,
|
||||||
|
"width": 220,
|
||||||
|
"height": 140,
|
||||||
|
"fit": "contain",
|
||||||
|
"radius": 18,
|
||||||
|
"opacity": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ambient",
|
||||||
|
"type": "video",
|
||||||
|
"src": "ambient.webm",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1000,
|
||||||
|
"height": 650,
|
||||||
|
"fit": "cover",
|
||||||
|
"opacity": 0.35
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Video layers autoplay and loop. They are muted by default; set `muted` to `false` to
|
||||||
|
play their audio. WebM is recommended for size. Use an `opacity` below `1` if the video
|
||||||
|
should blend into the launcher UI.
|
||||||
|
|
||||||
|
#### Shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "glow",
|
||||||
|
"type": "shape",
|
||||||
|
"shape": "ellipse",
|
||||||
|
"x": 680,
|
||||||
|
"y": 60,
|
||||||
|
"width": 260,
|
||||||
|
"height": 260,
|
||||||
|
"fill": "#ff006e",
|
||||||
|
"opacity": 0.18,
|
||||||
|
"borderWidth": 0,
|
||||||
|
"borderColor": "#ffffff"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`shape` is `rectangle`, `ellipse` or `line`. `radius` rounds rectangle corners.
|
||||||
|
`borderWidth` is clamped to `0`–`40`.
|
||||||
|
|
||||||
|
### Custom CSS
|
||||||
|
|
||||||
|
The `css` field is appended while the theme is active. Theme authors can use it for
|
||||||
|
small tweaks, but it is not required. Example:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--radius: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the advanced editor
|
||||||
|
|
||||||
|
1. Open **Themes** in the launcher.
|
||||||
|
2. Select **Create theme**, or the pencil button on an installed API 2.0 theme.
|
||||||
|
3. The editor opens in its **own resizable window**, not a modal. The center is a
|
||||||
|
full launcher home canvas at 1000 × 650.
|
||||||
|
4. Use the left toolbar to add text, image, video and shape layers. Drag layers on
|
||||||
|
the canvas, resize with the corner handles, or edit exact values in the right
|
||||||
|
inspector. Shift/Cmd-click a layer or drag a marquee over empty canvas to
|
||||||
|
select several layers. Multi-selections can be dragged together and aligned
|
||||||
|
or distributed from the right panel. Layers snap to canvas edges, the canvas
|
||||||
|
center, and other layers while dragging; hold Alt to disable snapping.
|
||||||
|
Right-click a layer for quick duplicate, ordering, hide/lock and delete
|
||||||
|
actions, or right-click empty canvas to add a new layer.
|
||||||
|
5. The layer list supports double-click rename, hide/show, lock/unlock and
|
||||||
|
drag-to-reorder. Hidden layers remain in the package, while locked layers
|
||||||
|
cannot be moved or resized on the canvas.
|
||||||
|
6. Use the **Theme** tab for metadata, background, effects, typography and custom
|
||||||
|
CSS. Open the **Fonts** tab to search and download from every Fontsource
|
||||||
|
family, or import WOFF2, WOFF, TTF and OTF files. Downloaded fonts are copied
|
||||||
|
into the theme's `assets/` folder when you save.
|
||||||
|
7. Select **Save theme** to write `theme.json` and copy new assets into
|
||||||
|
`~/.ezpplauncher/themes`. A fresh `assets/preview.png` thumbnail is rendered
|
||||||
|
from the theme and the launcher applies the saved theme immediately. Unsaved
|
||||||
|
edits are autosaved as a local draft and offered for recovery after a crash.
|
||||||
|
8. Select **Export** to create a shareable `.ezpplauncher-theme` package. A theme
|
||||||
|
check runs first and reports missing assets, oversized media, missing fonts
|
||||||
|
and unused imports. Export saves and refreshes the preview, then reports the
|
||||||
|
final package size.
|
||||||
|
|
||||||
|
Keyboard shortcuts:
|
||||||
|
|
||||||
|
- `Cmd/Ctrl+Z` undoes the last change.
|
||||||
|
- `Cmd/Ctrl+Shift+Z` or `Ctrl+Y` redoes it.
|
||||||
|
- `Cmd/Ctrl+S` saves.
|
||||||
|
- `Cmd/Ctrl+D` duplicates the selected layer or multi-selection.
|
||||||
|
- `Cmd/Ctrl+A` selects every visible layer.
|
||||||
|
- Shift/Cmd-click toggles a layer in the current selection.
|
||||||
|
- Arrow keys nudge the selected layer(s) by 1 px; hold Shift for 10 px.
|
||||||
|
- `Delete` removes the selected layer(s).
|
||||||
|
- Hold Alt while dragging to skip snapping.
|
||||||
|
- `Esc` clears the selection or closes an open editor dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating API 2.0 themes with the CLI
|
||||||
|
|
||||||
|
The `create-ezpplauncher-theme` CLI includes a **declarative (API 2.0)** template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx git+https://git.ez-pp.farm/EZPPFarm/create-ezpplauncher-theme.git my-theme
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose `declarative (API 2.0)`, edit `theme.json`, add files to `assets/`, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces `dist/my_theme.ezpplauncher-theme`. The template also includes
|
||||||
|
`preview.html` for a standalone browser preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx serve .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compatibility and limits
|
||||||
|
|
||||||
|
- API 2.0 packages must include `theme.json`; `entry` and `style` are not used.
|
||||||
|
- Asset paths are resolved inside `assets/`; path traversal is rejected by the
|
||||||
|
import commands.
|
||||||
|
- Exports include only `assets/` files referenced by `theme.json` (background,
|
||||||
|
audio, fonts, layers and preview). Orphaned files left in the folder are
|
||||||
|
omitted, which keeps packages small. API 1.0 themes are exported whole because
|
||||||
|
their bundles can load files dynamically.
|
||||||
|
- The editor writes `preview.png` whenever you save or export. The generated
|
||||||
|
thumbnail renders the background, effects, launcher UI, theme fonts and
|
||||||
|
freeform layers; custom CSS is not applied to the thumbnail.
|
||||||
|
- The theme editor only writes inside `~/.ezpplauncher/themes`.
|
||||||
|
- API 1.0 themes are still loaded through their existing `mountTheme` bundle API.
|
||||||
|
- API 2.0 themes cannot run JavaScript. Use custom CSS for small visual tweaks, or
|
||||||
|
use API 1.0 when a theme needs a fully custom UI.
|
||||||
|
- Freeform layers are decorative, home-screen-only and pointer-transparent; they
|
||||||
|
cannot add interactive controls.
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
"name": "ezpplauncher",
|
"name": "ezpplauncher",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-fetch/fetch": "^1.1.21",
|
"@better-fetch/fetch": "^1.1.21",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource-variable/montserrat": "^5.3.0",
|
||||||
|
"@fontsource-variable/playfair-display": "^5.3.0",
|
||||||
"@fontsource-variable/sora": "^5.2.8",
|
"@fontsource-variable/sora": "^5.2.8",
|
||||||
|
"@fontsource/bebas-neue": "^5.3.0",
|
||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@number-flow/svelte": "^0.4.0",
|
"@number-flow/svelte": "^0.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
@@ -22,10 +25,10 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-to-webcomponent": "^2.0.1",
|
|
||||||
"semver": "^7.8.1",
|
"semver": "^7.8.1",
|
||||||
"sileo": "^0.1.5",
|
"sileo": "^0.1.5",
|
||||||
"svelte-confetti": "^2.3.2",
|
"svelte-confetti": "^2.3.2",
|
||||||
|
"torph": "^0.1.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -42,24 +45,20 @@
|
|||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"autoprefixer": "10.5.0",
|
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.17.1",
|
"eslint-plugin-svelte": "^3.17.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"mode-watcher": "^1.1.0",
|
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^4.0.1",
|
"prettier-plugin-svelte": "^4.0.1",
|
||||||
"sass-embedded": "^1.100.0",
|
"sass-embedded": "^1.100.0",
|
||||||
"svelte": "^5.55.9",
|
"svelte": "^5.55.9",
|
||||||
"svelte-check": "^4.4.8",
|
"svelte-check": "^4.4.8",
|
||||||
"svelte-sonner": "^1.1.1",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.60.0",
|
"typescript-eslint": "^8.60.0",
|
||||||
@@ -121,8 +120,16 @@
|
|||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||||
|
|
||||||
|
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/[email protected]", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
|
||||||
|
|
||||||
|
"@fontsource-variable/montserrat": ["@fontsource-variable/[email protected]", "", {}, "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA=="],
|
||||||
|
|
||||||
|
"@fontsource-variable/playfair-display": ["@fontsource-variable/[email protected]", "", {}, "sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA=="],
|
||||||
|
|
||||||
"@fontsource-variable/sora": ["@fontsource-variable/[email protected]", "", {}, "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w=="],
|
"@fontsource-variable/sora": ["@fontsource-variable/[email protected]", "", {}, "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w=="],
|
||||||
|
|
||||||
|
"@fontsource/bebas-neue": ["@fontsource/[email protected]", "", {}, "sha512-nCxMEb9Bg9sjNWm9TIOsvHKDMLRUfEZPWpLgYu6m9KavZZVSA/WoHzI5wROwqA+eNwEi/PNBAcB5Co7HA3S0Cw=="],
|
||||||
|
|
||||||
"@fontsource/space-mono": ["@fontsource/[email protected]", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
|
"@fontsource/space-mono": ["@fontsource/[email protected]", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
@@ -187,8 +194,6 @@
|
|||||||
|
|
||||||
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
"@r2wc/core": ["@r2wc/[email protected]", "", {}, "sha512-aPBnND92Itl+SWWbWyyxdFFF0+RqKB6dptGHEdiPB8ZvnHWHlVzOfEvbEcyUYGtB6HBdsfkVuBiaGYyBFVTzVQ=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="],
|
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="],
|
||||||
@@ -261,8 +266,6 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||||
|
|
||||||
"@tailwindcss/typography": ["@tailwindcss/[email protected]", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
||||||
@@ -353,30 +356,22 @@
|
|||||||
|
|
||||||
"aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
"aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||||
|
|
||||||
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
|
|
||||||
|
|
||||||
"axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
"axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"base64-js": ["[email protected]", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["[email protected]", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="],
|
|
||||||
|
|
||||||
"bits-ui": ["[email protected]", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA=="],
|
"bits-ui": ["[email protected]", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA=="],
|
||||||
|
|
||||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|
||||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
|
||||||
|
|
||||||
"buffer": ["[email protected]", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
"buffer": ["[email protected]", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
|
||||||
|
|
||||||
"chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
@@ -407,12 +402,8 @@
|
|||||||
|
|
||||||
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="],
|
|
||||||
|
|
||||||
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||||
|
|
||||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
|
||||||
|
|
||||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"eslint": ["[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
|
"eslint": ["[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
|
||||||
@@ -457,8 +448,6 @@
|
|||||||
|
|
||||||
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||||
|
|
||||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
|
||||||
|
|
||||||
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.34.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ=="],
|
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.34.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ=="],
|
||||||
|
|
||||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
@@ -499,7 +488,7 @@
|
|||||||
|
|
||||||
"javascript-natural-sort": ["[email protected]", "", {}, "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="],
|
"javascript-natural-sort": ["[email protected]", "", {}, "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
@@ -563,8 +552,6 @@
|
|||||||
|
|
||||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
"mode-watcher": ["[email protected]", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g=="],
|
|
||||||
|
|
||||||
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.34.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA=="],
|
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.34.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA=="],
|
||||||
|
|
||||||
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw=="],
|
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw=="],
|
||||||
@@ -583,8 +570,6 @@
|
|||||||
|
|
||||||
"node-addon-api": ["[email protected]", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
"node-addon-api": ["[email protected]", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||||
|
|
||||||
"node-releases": ["[email protected]", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
|
||||||
|
|
||||||
"number-flow": ["[email protected]", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="],
|
"number-flow": ["[email protected]", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="],
|
||||||
|
|
||||||
"obug": ["[email protected]", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
"obug": ["[email protected]", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
@@ -617,9 +602,7 @@
|
|||||||
|
|
||||||
"postcss-scss": ["[email protected]", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
"postcss-scss": ["[email protected]", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
||||||
|
|
||||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
"postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
|
||||||
|
|
||||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
|
||||||
|
|
||||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
@@ -635,8 +618,6 @@
|
|||||||
|
|
||||||
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||||
|
|
||||||
"react-to-webcomponent": ["[email protected]", "", { "dependencies": { "@r2wc/core": "^1.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jpVztcA0SbV6TnBRV6EFuU+hmuuSIxgEww3wRcbneT0ytqn57y9ygeRfUmhzz8+bYg53u1h4COKk8iAextHE+Q=="],
|
|
||||||
|
|
||||||
"readable-stream": ["[email protected]", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
"readable-stream": ["[email protected]", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
"readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
@@ -723,8 +704,6 @@
|
|||||||
|
|
||||||
"svelte-eslint-parser": ["[email protected]", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-hhvSH6kRj46UzrBVO5TaotD+Iuvruj5ccKBcO4wAhVcPTLmIc/c32D8UllBTYO0on4LzYuM0rNzf1lM/gBlkSQ=="],
|
"svelte-eslint-parser": ["[email protected]", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-hhvSH6kRj46UzrBVO5TaotD+Iuvruj5ccKBcO4wAhVcPTLmIc/c32D8UllBTYO0on4LzYuM0rNzf1lM/gBlkSQ=="],
|
||||||
|
|
||||||
"svelte-sonner": ["[email protected]", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw=="],
|
|
||||||
|
|
||||||
"svelte-toolbelt": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
"svelte-toolbelt": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||||
|
|
||||||
"sync-child-process": ["[email protected]", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
|
"sync-child-process": ["[email protected]", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
|
||||||
@@ -739,14 +718,14 @@
|
|||||||
|
|
||||||
"tailwindcss": ["[email protected]", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
"tailwindcss": ["[email protected]", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||||
|
|
||||||
"tailwindcss-animate": ["[email protected]", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
|
||||||
|
|
||||||
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"torph": ["[email protected]", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=5", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-DOirbURFAedEZlK6Mx9jcAc7/gbDBUZNyXQwHUmQRXyUsbB3STYwb2PjiAy9iMc1eOWoQLfhm54DuPhWBPEibg=="],
|
||||||
|
|
||||||
"totalist": ["[email protected]", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
"totalist": ["[email protected]", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||||
@@ -763,8 +742,6 @@
|
|||||||
|
|
||||||
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
|
||||||
|
|
||||||
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
@@ -801,8 +778,6 @@
|
|||||||
|
|
||||||
"@parcel/watcher/detect-libc": ["[email protected]", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
"@parcel/watcher/detect-libc": ["[email protected]", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||||
|
|
||||||
"@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||||
@@ -837,10 +812,6 @@
|
|||||||
|
|
||||||
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"mode-watcher/runed": ["[email protected]", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg=="],
|
|
||||||
|
|
||||||
"mode-watcher/svelte-toolbelt": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
|
|
||||||
|
|
||||||
"sass/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"sass/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
"svelte-eslint-parser/eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
"svelte-eslint-parser/eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
@@ -849,10 +820,6 @@
|
|||||||
|
|
||||||
"svelte-eslint-parser/espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
"svelte-eslint-parser/espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
"svelte-eslint-parser/postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
|
|
||||||
|
|
||||||
"svelte-sonner/runed": ["[email protected]", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
|
||||||
|
|
||||||
"@eslint/config-array/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
"@eslint/config-array/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||||
|
|
||||||
"@jridgewell/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
"@jridgewell/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||||
@@ -865,8 +832,6 @@
|
|||||||
|
|
||||||
"eslint/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
"eslint/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||||
|
|
||||||
"mode-watcher/svelte-toolbelt/runed": ["[email protected]", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
|
|
||||||
|
|
||||||
"sass/chokidar/readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"sass/chokidar/readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
"svelte-eslint-parser/espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"svelte-eslint-parser/espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src\\app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "gray"
|
"baseColor": "gray"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default ts.config(
|
export default ts.config(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
|
{
|
||||||
|
// Tauri build output is ignored by src-tauri/.gitignore, not the root file.
|
||||||
|
ignores: ['src-tauri/target/**', 'src-tauri/gen/**'],
|
||||||
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
|
|||||||
@@ -19,10 +19,13 @@
|
|||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-fetch/fetch": "^1.1.21",
|
"@better-fetch/fetch": "^1.1.21",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource-variable/montserrat": "^5.3.0",
|
||||||
|
"@fontsource-variable/playfair-display": "^5.3.0",
|
||||||
"@fontsource-variable/sora": "^5.2.8",
|
"@fontsource-variable/sora": "^5.2.8",
|
||||||
|
"@fontsource/bebas-neue": "^5.3.0",
|
||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@number-flow/svelte": "^0.4.0",
|
"@number-flow/svelte": "^0.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
@@ -35,10 +38,10 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-to-webcomponent": "^2.0.1",
|
|
||||||
"semver": "^7.8.1",
|
"semver": "^7.8.1",
|
||||||
"sileo": "^0.1.5",
|
"sileo": "^0.1.5",
|
||||||
"svelte-confetti": "^2.3.2",
|
"svelte-confetti": "^2.3.2",
|
||||||
|
"torph": "^0.1.3",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,28 +58,24 @@
|
|||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"autoprefixer": "10.5.0",
|
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.17.1",
|
"eslint-plugin-svelte": "^3.17.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"mode-watcher": "^1.1.0",
|
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^4.0.1",
|
"prettier-plugin-svelte": "^4.0.1",
|
||||||
"sass-embedded": "^1.100.0",
|
"sass-embedded": "^1.100.0",
|
||||||
"svelte": "^5.55.9",
|
"svelte": "^5.55.9",
|
||||||
"svelte-check": "^4.4.8",
|
"svelte-check": "^4.4.8",
|
||||||
"svelte-sonner": "^1.1.1",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.60.0",
|
"typescript-eslint": "^8.60.0",
|
||||||
"vite": "^8.0.14",
|
"vite": "^8.0.14",
|
||||||
"vite-plugin-devtools-json": "^1.0.0"
|
"vite-plugin-devtools-json": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,23 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_log-sys"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_logger"
|
||||||
|
version = "0.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||||
|
dependencies = [
|
||||||
|
"android_log-sys",
|
||||||
|
"env_filter",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1189,6 +1206,16 @@ dependencies = [
|
|||||||
"syn 2.0.113",
|
"syn 2.0.113",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1248,9 +1275,16 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hardware-id",
|
"hardware-id",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"log",
|
||||||
"md5",
|
"md5",
|
||||||
|
"objc2 0.6.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"open",
|
"open",
|
||||||
|
"percent-encoding",
|
||||||
"presenceforge",
|
"presenceforge",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1265,9 +1299,11 @@ dependencies = [
|
|||||||
"tauri-plugin-cors-fetch",
|
"tauri-plugin-cors-fetch",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"widestring",
|
"widestring",
|
||||||
@@ -1304,6 +1340,15 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fern"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -1993,6 +2038,12 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2016,6 +2067,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -2766,6 +2818,15 @@ dependencies = [
|
|||||||
"syn 2.0.113",
|
"syn 2.0.113",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_threads"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -4995,6 +5056,27 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-log"
|
||||||
|
version = "2.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86f2e3c40b77fd1eb7f8e1b6900fb036aee2da1ea2d3783c91255a82c636649d"
|
||||||
|
dependencies = [
|
||||||
|
"android_logger",
|
||||||
|
"fern",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"swift-rs",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-shell"
|
name = "tauri-plugin-shell"
|
||||||
version = "2.3.5"
|
version = "2.3.5"
|
||||||
@@ -5216,7 +5298,9 @@ dependencies = [
|
|||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
"itoa",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"libc",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
|
"num_threads",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ aes-gcm = "0.10.3"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
log = "0.4"
|
||||||
|
time = { version = "0.3", features = ["local-offset"] }
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
||||||
|
http-body-util = "0.1.3"
|
||||||
|
http-range = "0.1.5"
|
||||||
|
hyper = { version = "1.8.1", features = ["server", "http1"] }
|
||||||
|
hyper-util = { version = "0.1.19", features = ["tokio"] }
|
||||||
|
percent-encoding = "2.3.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc2 = "0.6.3"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.56.0"
|
winreg = "0.56.0"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capabilities for the main launcher and theme editor windows",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main",
|
||||||
|
"theme-editor"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"cors-fetch:default",
|
"cors-fetch:default",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#fff</color>
|
|
||||||
</resources>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@@ -7,36 +7,40 @@ use tauri_plugin_fs::FsExt;
|
|||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod logging;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub mod macos;
|
||||||
pub mod osudb;
|
pub mod osudb;
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
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, 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,
|
||||||
open_url_in_browser, opened_urls, presence_connect, presence_disconnect,
|
list_theme_backups, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder,
|
||||||
presence_is_connected, presence_update_button, presence_update_status,
|
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||||
presence_update_user, read_theme_info, replace_ui_files, run_open_tablet_driver, run_osu,
|
presence_connect, presence_disconnect, presence_is_connected, presence_update_button,
|
||||||
run_osu_updater, set_osu_config_values, set_osu_user_config_values,
|
presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
|
||||||
stop_open_tablet_driver, valid_osu_folder,
|
resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater,
|
||||||
|
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
||||||
|
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
||||||
|
valid_osu_folder,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
#[cfg(target_os = "linux")]
|
let mut builder = tauri::Builder::default().plugin(logging::plugin());
|
||||||
unsafe {
|
|
||||||
// std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default();
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
@@ -67,6 +71,15 @@ pub fn run() {
|
|||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
log::info!(
|
||||||
|
target: "launcher",
|
||||||
|
"EZPPLauncher {} starting on {} ({}) — logs: {}",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH,
|
||||||
|
logging::logs_directory().display()
|
||||||
|
);
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────
|
||||||
let home = std::env::var("HOME")
|
let home = std::env::var("HOME")
|
||||||
.or_else(|_| std::env::var("USERPROFILE"))
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
@@ -97,19 +110,48 @@ pub fn run() {
|
|||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
config: Mutex::new(cfg),
|
config: Mutex::new(cfg),
|
||||||
opened_files: Mutex::new(opened_files),
|
opened_files: Mutex::new(opened_files),
|
||||||
|
editor_theme_folder: Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WebKit's custom-protocol media pipeline is unreliable (and
|
||||||
|
// loads large WebMs into memory), so Linux and macOS use a real
|
||||||
|
// loopback HTTP server with byte-range support instead.
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
match commands::theme_root().and_then(|root| {
|
||||||
|
let media = theme_server::media_root()?;
|
||||||
|
theme_server::start(root, media)
|
||||||
|
}) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => log::warn!(
|
||||||
|
target: "launcher",
|
||||||
|
"Could not start the theme asset server: {error}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||||
|
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = macos::allow_media_autoplay(&window) {
|
||||||
|
log::warn!(target: "launcher", "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
opened_urls,
|
opened_urls,
|
||||||
get_hwid,
|
get_hwid,
|
||||||
find_osu_installation,
|
find_osu_installation,
|
||||||
|
pick_osu_installation,
|
||||||
|
resolve_osu_folder,
|
||||||
valid_osu_folder,
|
valid_osu_folder,
|
||||||
get_beatmapsets_count,
|
get_beatmapsets_count,
|
||||||
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,
|
||||||
@@ -123,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,
|
||||||
@@ -137,8 +180,19 @@ 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,
|
||||||
|
export_theme,
|
||||||
|
open_theme_folder,
|
||||||
|
open_theme_editor,
|
||||||
|
theme_editor_context,
|
||||||
|
theme_asset_base,
|
||||||
|
theme_preview_url,
|
||||||
|
optimize_theme_media,
|
||||||
config_get,
|
config_get,
|
||||||
config_set,
|
config_set,
|
||||||
config_exists,
|
config_exists,
|
||||||
@@ -148,7 +202,12 @@ pub fn run() {
|
|||||||
run_open_tablet_driver,
|
run_open_tablet_driver,
|
||||||
stop_open_tablet_driver,
|
stop_open_tablet_driver,
|
||||||
is_open_tablet_driver_running,
|
is_open_tablet_driver_running,
|
||||||
is_open_tablet_driver_path_valid
|
is_open_tablet_driver_path_valid,
|
||||||
|
log_frontend,
|
||||||
|
set_logging_enabled,
|
||||||
|
open_logs_folder,
|
||||||
|
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())
|
||||||
@@ -158,6 +217,20 @@ pub fn run() {
|
|||||||
.expect("error while building tauri application");
|
.expect("error while building tauri application");
|
||||||
|
|
||||||
app.run(|app, event| {
|
app.run(|app, event| {
|
||||||
|
// The content view can be replaced again while the webview is being
|
||||||
|
// built, so re-apply the corner mask once the app is ready.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let tauri::RunEvent::Ready = &event {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||||
|
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = macos::allow_media_autoplay(&window) {
|
||||||
|
log::warn!(target: "launcher", "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
presence::handle_run_event(app, &event);
|
presence::handle_run_event(app, &event);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
//! Rotating file logs for the launcher, osu! and the patcher.
|
||||||
|
//!
|
||||||
|
//! Each stream writes to `~/.ezpplauncher/logs`:
|
||||||
|
//!
|
||||||
|
//! - `launcher.log` receives everything, including frontend console output.
|
||||||
|
//! - `osu.log` receives osu! and its updater, including stdout/stderr.
|
||||||
|
//! - `patcher.log` receives patcher output.
|
||||||
|
//!
|
||||||
|
//! A stream's file is only created when it actually has a line to write, so a
|
||||||
|
//! launch without osu!/patcher output never leaves empty files behind. When a
|
||||||
|
//! new session starts, or any stream reaches 5 MiB, all three active files are
|
||||||
|
//! bundled into one `logs_<date>_<time>.zip` and fresh files are started. The
|
||||||
|
//! five most recent bundles are kept, so a support report only needs one file.
|
||||||
|
//! `launcher.log` intentionally includes the osu!/patcher entries too. File
|
||||||
|
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
|
||||||
|
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use log::Level;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::Runtime;
|
||||||
|
use tauri::plugin::TauriPlugin;
|
||||||
|
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
||||||
|
use tokio::process::Child;
|
||||||
|
|
||||||
|
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
|
||||||
|
const KEPT_LOG_BUNDLES: usize = 5;
|
||||||
|
const MAX_LINE_CHARS: usize = 16_000;
|
||||||
|
|
||||||
|
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||||
|
|
||||||
|
pub fn set_logging_enabled(enabled: bool) {
|
||||||
|
ENABLED.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logging_enabled() -> bool {
|
||||||
|
ENABLED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the persisted setting before the config database is initialized, so a
|
||||||
|
/// disabled logger does not write a startup file before the frontend loads.
|
||||||
|
fn initial_logging_enabled() -> bool {
|
||||||
|
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
|
||||||
|
|
||||||
|
if !db_path.exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(connection) =
|
||||||
|
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(raw) = connection.query_row(
|
||||||
|
"SELECT value FROM config WHERE key = 'logging_enabled'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::from_str::<bool>(&raw).unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logs_directory() -> PathBuf {
|
||||||
|
let home = std::env::var_os("HOME")
|
||||||
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
|
||||||
|
home.join(".ezpplauncher").join("logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Stream {
|
||||||
|
Launcher,
|
||||||
|
Osu,
|
||||||
|
Patcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream {
|
||||||
|
const ALL: [Stream; 3] = [Stream::Launcher, Stream::Osu, Stream::Patcher];
|
||||||
|
|
||||||
|
fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Stream::Launcher => 0,
|
||||||
|
Stream::Osu => 1,
|
||||||
|
Stream::Patcher => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Stream::Launcher => "launcher.log",
|
||||||
|
Stream::Osu => "osu.log",
|
||||||
|
Stream::Patcher => "patcher.log",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared state for the three log files. Rotating any stream closes and
|
||||||
|
/// bundles all of them, so every bundle always holds the same session.
|
||||||
|
struct LogSet {
|
||||||
|
dir: PathBuf,
|
||||||
|
state: Mutex<LogState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LogState {
|
||||||
|
started: bool,
|
||||||
|
files: [Option<File>; 3],
|
||||||
|
sizes: [u64; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogSet {
|
||||||
|
fn new(dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
state: Mutex::new(LogState {
|
||||||
|
started: false,
|
||||||
|
files: [None, None, None],
|
||||||
|
sizes: [0; 3],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, stream: Stream, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
|
||||||
|
if !state.started {
|
||||||
|
state.started = true;
|
||||||
|
bundle_active_logs(&self.dir)?;
|
||||||
|
prune_bundles(&self.dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = stream.index();
|
||||||
|
|
||||||
|
if state.files[index].is_none() {
|
||||||
|
self.open_stream(&mut state, stream)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.sizes[index] > 0
|
||||||
|
&& state.sizes[index].saturating_add(buf.len() as u64) > MAX_LOG_SIZE
|
||||||
|
{
|
||||||
|
self.rotate(&mut state, stream)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = state.files[index].as_mut().expect("stream log is open");
|
||||||
|
file.write_all(buf)?;
|
||||||
|
state.sizes[index] += buf.len() as u64;
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self, stream: Stream) -> io::Result<()> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
|
||||||
|
match state.files[stream.index()].as_mut() {
|
||||||
|
Some(file) => file.flush(),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_stream(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
|
||||||
|
fs::create_dir_all(&self.dir)?;
|
||||||
|
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(self.dir.join(stream.file_name()))?;
|
||||||
|
let index = stream.index();
|
||||||
|
state.sizes[index] = file.metadata()?.len();
|
||||||
|
state.files[index] = Some(file);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
|
||||||
|
for file in state.files.iter_mut() {
|
||||||
|
if let Some(mut file) = file.take() {
|
||||||
|
let _ = file.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_active_logs(&self.dir)?;
|
||||||
|
prune_bundles(&self.dir)?;
|
||||||
|
|
||||||
|
let index = stream.index();
|
||||||
|
state.sizes[index] = 0;
|
||||||
|
self.open_stream(state, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionWriter {
|
||||||
|
logs: Arc<LogSet>,
|
||||||
|
stream: Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for SessionWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
if !logging_enabled() {
|
||||||
|
return Ok(buf.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logs.write(self.stream, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
if !logging_enabled() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logs.flush(self.stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zip the active logs that have content and remove them afterwards.
|
||||||
|
fn bundle_active_logs(dir: &Path) -> io::Result<()> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for stream in Stream::ALL {
|
||||||
|
let path = dir.join(stream.file_name());
|
||||||
|
|
||||||
|
match path.metadata() {
|
||||||
|
Ok(metadata) if metadata.len() > 0 => {
|
||||||
|
entries.push((path, stream.file_name().to_string()));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
let bundle_path = unique_bundle_path(dir);
|
||||||
|
|
||||||
|
let result = (|| -> io::Result<()> {
|
||||||
|
let file = File::create(&bundle_path)?;
|
||||||
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
|
||||||
|
for (path, name) in &entries {
|
||||||
|
zip.start_file(name.clone(), options).map_err(|error| {
|
||||||
|
io::Error::other(format!("Could not add {name} to the log bundle: {error}"))
|
||||||
|
})?;
|
||||||
|
let mut source = File::open(path)?;
|
||||||
|
io::copy(&mut source, &mut zip)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.finish().map_err(|error| {
|
||||||
|
io::Error::other(format!("Could not finish the log bundle: {error}"))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
let _ = fs::remove_file(&bundle_path);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, _) in entries {
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_bundle_path(dir: &Path) -> PathBuf {
|
||||||
|
let stamp = archive_timestamp();
|
||||||
|
let mut path = dir.join(format!("logs_{stamp}.zip"));
|
||||||
|
let mut suffix = 1;
|
||||||
|
|
||||||
|
while path.exists() {
|
||||||
|
path = dir.join(format!("logs_{stamp}-{suffix}.zip"));
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_timestamp() -> String {
|
||||||
|
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
|
||||||
|
now.year(),
|
||||||
|
u8::from(now.month()),
|
||||||
|
now.day(),
|
||||||
|
now.hour(),
|
||||||
|
now.minute(),
|
||||||
|
now.second()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_bundles(dir: &Path) -> io::Result<()> {
|
||||||
|
let mut bundles = fs::read_dir(dir)?
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let entry = entry.ok()?;
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = path.file_name()?.to_str()?;
|
||||||
|
|
||||||
|
if !file_name.starts_with("logs_") || !file_name.ends_with(".zip") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = entry.metadata().ok()?.modified().ok()?;
|
||||||
|
Some((path, modified))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
bundles.sort_by_key(|(_, modified)| *modified);
|
||||||
|
|
||||||
|
if bundles.len() > KEPT_LOG_BUNDLES {
|
||||||
|
for (path, _) in bundles.iter().take(bundles.len() - KEPT_LOG_BUNDLES) {
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_target(set: &Arc<LogSet>, stream: Stream) -> Target {
|
||||||
|
let writer = SessionWriter {
|
||||||
|
logs: Arc::clone(set),
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
let dispatch = tauri_plugin_log::fern::Dispatch::new().chain(
|
||||||
|
tauri_plugin_log::fern::Output::writer(Box::new(writer), "\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
Target::new(TargetKind::Dispatch(dispatch))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the log plugin. File targets are filtered by logging target so osu!
|
||||||
|
/// and patcher output stay separate while still being copied to the main log.
|
||||||
|
pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
set_logging_enabled(initial_logging_enabled());
|
||||||
|
|
||||||
|
let logs_dir = logs_directory();
|
||||||
|
let set = Arc::new(LogSet::new(logs_dir));
|
||||||
|
|
||||||
|
let launcher = file_target(&set, Stream::Launcher);
|
||||||
|
let osu =
|
||||||
|
file_target(&set, Stream::Osu).filter(|metadata| metadata.target().starts_with("osu"));
|
||||||
|
let patcher = file_target(&set, Stream::Patcher)
|
||||||
|
.filter(|metadata| metadata.target().starts_with("patcher"));
|
||||||
|
|
||||||
|
tauri_plugin_log::Builder::new()
|
||||||
|
.targets([Target::new(TargetKind::Stdout), launcher, osu, patcher])
|
||||||
|
.level(if cfg!(debug_assertions) {
|
||||||
|
log::LevelFilter::Debug
|
||||||
|
} else {
|
||||||
|
log::LevelFilter::Info
|
||||||
|
})
|
||||||
|
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LogBundleInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub modified: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LogBundleEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the rotating ZIP bundles, newest first, for the in-app log viewer.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
|
||||||
|
let dir = logs_directory();
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bundles = fs::read_dir(&dir)
|
||||||
|
.map_err(|error| format!("Could not read the logs folder: {error}"))?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = path.file_name()?.to_str()?.to_string();
|
||||||
|
if !name.starts_with("logs_") || !name.ends_with(".zip") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let metadata = entry.metadata().ok()?;
|
||||||
|
let modified = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(LogBundleInfo {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
name,
|
||||||
|
size: metadata.len(),
|
||||||
|
modified,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
bundles.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||||
|
Ok(bundles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the launcher/osu/patcher logs out of one bundle.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
|
||||||
|
let dir = logs_directory();
|
||||||
|
let requested = fs::canonicalize(&path)
|
||||||
|
.map_err(|error| format!("Could not open the log bundle: {error}"))?;
|
||||||
|
let root = fs::canonicalize(&dir)
|
||||||
|
.map_err(|error| format!("Could not resolve the logs folder: {error}"))?;
|
||||||
|
if !requested.starts_with(&root) {
|
||||||
|
return Err("The log bundle is outside the logs folder".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::open(&requested)
|
||||||
|
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
|
||||||
|
let mut archive =
|
||||||
|
zip::ZipArchive::new(file).map_err(|error| format!("Invalid log bundle: {error}"))?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for index in 0..archive.len() {
|
||||||
|
let mut entry = archive
|
||||||
|
.by_index(index)
|
||||||
|
.map_err(|error| format!("Could not read a log entry: {error}"))?;
|
||||||
|
if !entry.name().ends_with(".log") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
entry
|
||||||
|
.by_ref()
|
||||||
|
.take(2 * 1024 * 1024)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|error| format!("Could not read a log entry: {error}"))?;
|
||||||
|
let mut content = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
|
if entry.size() > bytes.len() as u64 {
|
||||||
|
content.push_str("\n… log truncated in the viewer …\n");
|
||||||
|
}
|
||||||
|
entries.push(LogBundleEntry {
|
||||||
|
name: entry.name().to_string(),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||||
|
pub fn capture_osu_output(child: &mut Child) {
|
||||||
|
capture_output(child, "osu");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||||
|
pub fn capture_patcher_output(child: &mut Child) {
|
||||||
|
capture_output(child, "patcher");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_output(child: &mut Child, target: &'static str) {
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
stream_lines(stdout, "stdout", target);
|
||||||
|
}
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
stream_lines(stderr, "stderr", target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_lines<R>(reader: R, stream: &'static str, target: &'static str)
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(reader).lines();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match lines.next_line().await {
|
||||||
|
Ok(Some(line)) => {
|
||||||
|
let line = truncate(line.trim_end_matches(['\r', '\n']));
|
||||||
|
if !line.is_empty() {
|
||||||
|
log::log!(target: target, Level::Info, "[{stream}] {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(error) => {
|
||||||
|
log::log!(
|
||||||
|
target: target,
|
||||||
|
Level::Warn,
|
||||||
|
"Could not read {stream} output: {error}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(mut value: &str) -> String {
|
||||||
|
if value.chars().count() <= MAX_LINE_CHARS {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
value = &value[..value
|
||||||
|
.char_indices()
|
||||||
|
.nth(MAX_LINE_CHARS)
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.unwrap_or(value.len())];
|
||||||
|
format!("{value}…")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ezpp-logging-{}-{name}-{id}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_line(logs: &Arc<LogSet>, stream: Stream, line: &str) {
|
||||||
|
let mut writer = SessionWriter {
|
||||||
|
logs: Arc::clone(logs),
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
writer.write_all(line.as_bytes()).unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_file_only_after_first_line() {
|
||||||
|
let dir = temp_dir("lazy");
|
||||||
|
let logs = Arc::new(LogSet::new(dir.clone()));
|
||||||
|
|
||||||
|
assert!(!dir.join("osu.log").exists());
|
||||||
|
|
||||||
|
write_line(&logs, Stream::Osu, "[stdout] first\n");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join("osu.log")).unwrap(),
|
||||||
|
"[stdout] first\n"
|
||||||
|
);
|
||||||
|
assert!(!dir.join("launcher.log").exists());
|
||||||
|
assert!(!dir.join("patcher.log").exists());
|
||||||
|
|
||||||
|
drop(logs);
|
||||||
|
fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundles_all_streams_when_a_new_session_starts() {
|
||||||
|
let dir = temp_dir("bundle");
|
||||||
|
|
||||||
|
let previous = Arc::new(LogSet::new(dir.clone()));
|
||||||
|
write_line(&previous, Stream::Launcher, "launcher previous\n");
|
||||||
|
write_line(&previous, Stream::Osu, "osu previous\n");
|
||||||
|
write_line(&previous, Stream::Patcher, "patcher previous\n");
|
||||||
|
drop(previous);
|
||||||
|
|
||||||
|
let current = Arc::new(LogSet::new(dir.clone()));
|
||||||
|
write_line(¤t, Stream::Launcher, "launcher current\n");
|
||||||
|
|
||||||
|
// Active files only contain the new session.
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join("launcher.log")).unwrap(),
|
||||||
|
"launcher current\n"
|
||||||
|
);
|
||||||
|
assert!(!dir.join("osu.log").exists());
|
||||||
|
assert!(!dir.join("patcher.log").exists());
|
||||||
|
|
||||||
|
let bundles = fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zip"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(bundles.len(), 1);
|
||||||
|
|
||||||
|
let file = File::open(&bundles[0]).unwrap();
|
||||||
|
let mut zip = zip::ZipArchive::new(file).unwrap();
|
||||||
|
assert_eq!(zip.len(), 3);
|
||||||
|
|
||||||
|
let mut names = (0..zip.len())
|
||||||
|
.map(|index| zip.by_index(index).unwrap().name().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, ["launcher.log", "osu.log", "patcher.log"]);
|
||||||
|
|
||||||
|
let mut launcher = String::new();
|
||||||
|
zip.by_name("launcher.log")
|
||||||
|
.unwrap()
|
||||||
|
.read_to_string(&mut launcher)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(launcher, "launcher previous\n");
|
||||||
|
|
||||||
|
drop(zip);
|
||||||
|
drop(current);
|
||||||
|
fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,907 @@
|
|||||||
|
//! macOS support for osu! stable running inside a Wine wrapper.
|
||||||
|
//!
|
||||||
|
//! osu! stable is a Windows application, so on macOS it has to run through a
|
||||||
|
//! Wine-based wrapper. The launcher supports Wineskin wrappers (the common
|
||||||
|
//! `osu!.app` wine skin), Whisky bottles and CrossOver bottles.
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::{Duration as StdDuration, Instant};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
|
use crate::utils::is_valid_osu_folder;
|
||||||
|
|
||||||
|
/// Round the borderless launcher window and make its corners transparent.
|
||||||
|
///
|
||||||
|
/// macOS does not round undecorated windows on its own, so the webview has to
|
||||||
|
/// be clipped with the content view's layer. The window itself is made
|
||||||
|
/// transparent so the area outside that clip shows the desktop instead of a
|
||||||
|
/// square background.
|
||||||
|
pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), String> {
|
||||||
|
use objc2::msg_send;
|
||||||
|
use objc2::runtime::AnyObject;
|
||||||
|
|
||||||
|
let ns_window = window.ns_window().map_err(|error| error.to_string())? as *mut AnyObject;
|
||||||
|
if ns_window.is_null() {
|
||||||
|
return Err("The native window is not available".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let _: () = msg_send![ns_window, setOpaque: false];
|
||||||
|
let clear: *mut AnyObject = msg_send![objc2::class!(NSColor), clearColor];
|
||||||
|
let _: () = msg_send![ns_window, setBackgroundColor: clear];
|
||||||
|
let _: () = msg_send![ns_window, setHasShadow: true];
|
||||||
|
|
||||||
|
let content_view: *mut AnyObject = msg_send![ns_window, contentView];
|
||||||
|
if content_view.is_null() {
|
||||||
|
return Err("The native content view is not available".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: () = msg_send![content_view, setWantsLayer: true];
|
||||||
|
let layer: *mut AnyObject = msg_send![content_view, layer];
|
||||||
|
if layer.is_null() {
|
||||||
|
return Err("The native content view has no layer".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: () = msg_send![layer, setCornerRadius: radius];
|
||||||
|
let _: () = msg_send![layer, setMasksToBounds: true];
|
||||||
|
let _: () = msg_send![ns_window, invalidateShadow];
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow theme media to start without a click. WKWebView otherwise blocks
|
||||||
|
/// autoplay for anything with an audio track, which makes a theme's background
|
||||||
|
/// video sit frozen until the user interacts with the launcher.
|
||||||
|
pub fn allow_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> {
|
||||||
|
use objc2::msg_send;
|
||||||
|
use objc2::runtime::AnyObject;
|
||||||
|
|
||||||
|
window
|
||||||
|
.with_webview(|webview| unsafe {
|
||||||
|
let webview = webview.inner() as *mut AnyObject;
|
||||||
|
if webview.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration: *mut AnyObject = msg_send![webview, configuration];
|
||||||
|
if configuration.is_null() {
|
||||||
|
return;
|
||||||
|
} // WKAudiovisualMediaTypesNone. This is a public WKWebView setting.
|
||||||
|
let _: () =
|
||||||
|
msg_send![configuration, setMediaTypesRequiringUserActionForPlayback: 0usize];
|
||||||
|
let current: usize = msg_send![configuration, mediaTypesRequiringUserActionForPlayback];
|
||||||
|
log::debug!(
|
||||||
|
target: "launcher",
|
||||||
|
"Theme media autoplay policy: {current}"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("Could not enable theme media autoplay: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a native open panel that accepts `.app` bundles as well as folders.
|
||||||
|
///
|
||||||
|
/// AppKit treats application bundles as packages (directories) by default, so a
|
||||||
|
/// regular folder picker descends into `osu!.app` instead of selecting it. This
|
||||||
|
/// panel turns that behaviour off, letting the user pick the wrapper itself,
|
||||||
|
/// while still allowing a plain Wine prefix folder to be selected.
|
||||||
|
pub fn pick_osu_installation() -> Result<Option<PathBuf>, String> {
|
||||||
|
use objc2::msg_send;
|
||||||
|
use objc2::rc::autoreleasepool;
|
||||||
|
use objc2::runtime::AnyObject;
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
|
||||||
|
unsafe fn ns_string(value: &str) -> Result<*mut AnyObject, String> {
|
||||||
|
let value = CString::new(value).map_err(|_| "The path contains a NUL byte".to_string())?;
|
||||||
|
let string: *mut AnyObject =
|
||||||
|
unsafe { msg_send![objc2::class!(NSString), stringWithUTF8String: value.as_ptr()] };
|
||||||
|
if string.is_null() {
|
||||||
|
return Err("Could not create the native dialog text".to_string());
|
||||||
|
}
|
||||||
|
Ok(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoreleasepool(|_| unsafe {
|
||||||
|
let panel: *mut AnyObject = msg_send![objc2::class!(NSOpenPanel), openPanel];
|
||||||
|
if panel.is_null() {
|
||||||
|
return Err("Could not create the native file picker".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: () = msg_send![panel, setCanChooseFiles: true];
|
||||||
|
let _: () = msg_send![panel, setCanChooseDirectories: true];
|
||||||
|
// Keep `.app` bundles selectable instead of navigating into them.
|
||||||
|
let _: () = msg_send![panel, setTreatsFilePackagesAsDirectories: false];
|
||||||
|
let _: () = msg_send![panel, setAllowsMultipleSelection: false];
|
||||||
|
let _: () = msg_send![panel, setCanCreateDirectories: false];
|
||||||
|
|
||||||
|
let title = ns_string("Select osu!.app")?;
|
||||||
|
let _: () = msg_send![panel, setTitle: title];
|
||||||
|
let message = ns_string("Pick your osu!.app wrapper, or a Wine prefix folder.")?;
|
||||||
|
let _: () = msg_send![panel, setMessage: message];
|
||||||
|
let prompt = ns_string("Select")?;
|
||||||
|
let _: () = msg_send![panel, setPrompt: prompt];
|
||||||
|
|
||||||
|
let applications = ns_string("/Applications")?;
|
||||||
|
let directory_url: *mut AnyObject =
|
||||||
|
msg_send![objc2::class!(NSURL), fileURLWithPath: applications];
|
||||||
|
if !directory_url.is_null() {
|
||||||
|
let _: () = msg_send![panel, setDirectoryURL: directory_url];
|
||||||
|
}
|
||||||
|
|
||||||
|
// NSModalResponseOK
|
||||||
|
let response: i64 = msg_send![panel, runModal];
|
||||||
|
if response != 1 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: *mut AnyObject = msg_send![panel, URL];
|
||||||
|
if url.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let path: *mut AnyObject = msg_send![url, path];
|
||||||
|
if path.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let utf8: *const std::os::raw::c_char = msg_send![path, UTF8String];
|
||||||
|
if utf8.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = CStr::from_ptr(utf8).to_string_lossy().into_owned();
|
||||||
|
Ok(Some(PathBuf::from(path)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WineRuntime {
|
||||||
|
prefix: PathBuf,
|
||||||
|
wine_bin: PathBuf,
|
||||||
|
wine_root: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WineDiagnostics {
|
||||||
|
pub prefix: PathBuf,
|
||||||
|
pub wine_bin: PathBuf,
|
||||||
|
pub app_bundle: Option<PathBuf>,
|
||||||
|
pub net8_installed: bool,
|
||||||
|
pub wine_version: Option<String>,
|
||||||
|
pub prefix_arch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WineDiagnostics {
|
||||||
|
/// Inspect the wrapper without starting it, for the launcher's diagnostics panel.
|
||||||
|
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
|
||||||
|
let runtime = WineRuntime::for_osu_folder(osu_folder)?;
|
||||||
|
let net8_installed = [
|
||||||
|
runtime
|
||||||
|
.prefix
|
||||||
|
.join("drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"),
|
||||||
|
runtime
|
||||||
|
.prefix
|
||||||
|
.join("drive_c/Program Files (x86)/dotnet/shared/Microsoft.WindowsDesktop.App"),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|path| {
|
||||||
|
fs::read_dir(path)
|
||||||
|
.ok()
|
||||||
|
.map(|entries| {
|
||||||
|
entries.filter_map(|entry| entry.ok()).any(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.map(|name| name.starts_with("8."))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
prefix: runtime.prefix.clone(),
|
||||||
|
wine_bin: runtime.wine_bin.clone(),
|
||||||
|
app_bundle: app_bundle_for(&runtime.prefix),
|
||||||
|
net8_installed,
|
||||||
|
wine_version: wine_version_for(&runtime.wine_bin),
|
||||||
|
prefix_arch: prefix_architecture(&runtime.prefix),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WineRuntime {
|
||||||
|
/// Build a runtime description for an osu! folder inside a Wine prefix.
|
||||||
|
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
|
||||||
|
if !osu_folder.join("osu!.exe").is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"osu!.exe was not found in {}",
|
||||||
|
osu_folder.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = prefix_for(osu_folder).ok_or_else(|| {
|
||||||
|
"This osu! installation is not inside a Wine prefix. On macOS, choose the osu! folder inside your Wineskin, Whisky or CrossOver wrapper.".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let app = app_bundle_for(&prefix);
|
||||||
|
let (wine_bin, wine_root) = find_wine_bin(&prefix, app.as_deref()).ok_or_else(|| {
|
||||||
|
"Could not find a Wine runtime for this osu! installation. Make sure the Wineskin/Whisky/CrossOver wrapper is still installed.".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
prefix,
|
||||||
|
wine_bin,
|
||||||
|
wine_root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a Windows executable through the wrapper's Wine runtime.
|
||||||
|
pub fn spawn(&self, exe: &Path, args: &[&str]) -> Result<Child, String> {
|
||||||
|
let loader = self.loader_for(exe);
|
||||||
|
if !loader.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"Wine loader not found at {}. The wrapper may be incomplete.",
|
||||||
|
loader.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut command = Command::new(&loader);
|
||||||
|
command.arg(exe).args(args);
|
||||||
|
command.current_dir(exe.parent().unwrap_or(&self.prefix));
|
||||||
|
command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
command.env("WINEPREFIX", &self.prefix);
|
||||||
|
command.env("WINEDEBUG", "-all");
|
||||||
|
command.env("WINEDLLOVERRIDES", "mscoree,mshtml=");
|
||||||
|
command.env("FREETYPE_PROPERTIES", "truetype:interpreter-version=35");
|
||||||
|
|
||||||
|
if let Some(wine_root) = &self.wine_root {
|
||||||
|
let mut library_paths = vec![
|
||||||
|
wine_root.join("lib"),
|
||||||
|
wine_root.join("lib64"),
|
||||||
|
wine_root.join("lib32on64"),
|
||||||
|
wine_root.clone(),
|
||||||
|
PathBuf::from("/opt/local/lib"),
|
||||||
|
PathBuf::from("/usr/lib"),
|
||||||
|
PathBuf::from("/usr/libexec"),
|
||||||
|
PathBuf::from("/usr/lib/system"),
|
||||||
|
PathBuf::from("/opt/X11/lib"),
|
||||||
|
];
|
||||||
|
if let Some(existing) = std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH") {
|
||||||
|
library_paths.extend(std::env::split_paths(&existing));
|
||||||
|
}
|
||||||
|
if let Ok(library_path) = std::env::join_paths(library_paths) {
|
||||||
|
command.env("DYLD_FALLBACK_LIBRARY_PATH", &library_path);
|
||||||
|
command.env("WINESKIN_LIB_PATH_FOR_FALLBACK", &library_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths = vec![self.wine_bin.clone()];
|
||||||
|
if let Some(existing) = std::env::var_os("PATH") {
|
||||||
|
paths.extend(std::env::split_paths(&existing));
|
||||||
|
}
|
||||||
|
if let Ok(path) = std::env::join_paths(paths) {
|
||||||
|
command.env("PATH", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let executable_name = exe
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "the Windows application".to_string());
|
||||||
|
|
||||||
|
command.spawn().map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to start {executable_name} through Wine ({}): {}",
|
||||||
|
loader.display(),
|
||||||
|
error
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every process that belongs to this Wine prefix.
|
||||||
|
pub async fn kill_prefix(&self) {
|
||||||
|
let wineserver = self.wine_bin.join("wineserver");
|
||||||
|
if !wineserver.is_file() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = Command::new(wineserver)
|
||||||
|
.arg("-k")
|
||||||
|
.env("WINEPREFIX", &self.prefix)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loader_for(&self, exe: &Path) -> PathBuf {
|
||||||
|
let is_64_bit = pe_machine(exe) == Some(0x8664);
|
||||||
|
let candidates: &[&str] = if is_64_bit {
|
||||||
|
&["wine64", "wine"]
|
||||||
|
} else {
|
||||||
|
&["wine32on64", "wine", "wine64"]
|
||||||
|
};
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| self.wine_bin.join(candidate))
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
.unwrap_or_else(|| self.wine_bin.join(candidates[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run osu! (`-devserver ez-pp.farm`) and wait until the game exits.
|
||||||
|
pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> {
|
||||||
|
let folder = Path::new(osu_folder);
|
||||||
|
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
|
||||||
|
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
let mut game = runtime
|
||||||
|
.spawn(&folder.join("osu!.exe"), &["-devserver", "ez-pp.farm"])
|
||||||
|
.map_err(|error| {
|
||||||
|
log::error!(target: "osu", "Could not start osu!: {error}");
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(pid) = game.id() {
|
||||||
|
log::info!(target: "osu", "osu! started through Wine (pid {pid})");
|
||||||
|
}
|
||||||
|
crate::logging::capture_osu_output(&mut game);
|
||||||
|
|
||||||
|
if patch {
|
||||||
|
if let Err(error) = start_patcher(&runtime, folder).await {
|
||||||
|
log::warn!(target: "patcher", "Could not start the patcher: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match game.wait().await {
|
||||||
|
Ok(status) => log::info!(target: "osu", "osu! exited with {status}"),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!(target: "osu", "Could not wait for osu!: {error}");
|
||||||
|
return Err(format!("Failed to wait for osu!: {}", error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the Windows patcher through the same Wine prefix as osu!.
|
||||||
|
async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), String> {
|
||||||
|
let patcher_exe = osu_folder
|
||||||
|
.join("EZPPLauncher")
|
||||||
|
.join("patcher")
|
||||||
|
.join("osu!.patcher.exe");
|
||||||
|
|
||||||
|
if !patcher_exe.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"Patcher was requested but {} does not exist",
|
||||||
|
patcher_exe.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_osu_game().await;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
target: "patcher",
|
||||||
|
"Starting the patcher from {}",
|
||||||
|
patcher_exe.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut patcher = runtime.spawn(&patcher_exe, &[])?;
|
||||||
|
if let Some(pid) = patcher.id() {
|
||||||
|
log::info!(target: "patcher", "Patcher started through Wine (pid {pid})");
|
||||||
|
}
|
||||||
|
crate::logging::capture_patcher_output(&mut patcher);
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match patcher.wait().await {
|
||||||
|
Ok(status) => log::info!(target: "patcher", "Patcher exited with {status}"),
|
||||||
|
Err(error) => log::warn!(target: "patcher", "Could not wait for the patcher: {error}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the Wine-hosted osu! process exists, then give it time to create
|
||||||
|
/// its game window. Wine does not expose the window title to the launcher here,
|
||||||
|
/// so the process is the best signal available.
|
||||||
|
async fn wait_for_osu_game() {
|
||||||
|
let mut system = sysinfo::System::new_all();
|
||||||
|
let deadline = Instant::now() + StdDuration::from_secs(30);
|
||||||
|
let mut detected = false;
|
||||||
|
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||||
|
if system
|
||||||
|
.processes()
|
||||||
|
.values()
|
||||||
|
.any(crate::utils::is_osu_game_process)
|
||||||
|
{
|
||||||
|
detected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !detected {
|
||||||
|
log::warn!(
|
||||||
|
target: "patcher",
|
||||||
|
"Could not detect the osu! process; starting the patcher anyway"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wine gives no reliable window-ready signal, so leave the game a moment
|
||||||
|
// to finish initializing before the patcher attaches to it.
|
||||||
|
sleep(Duration::from_secs(8)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `osu!.exe -repair`, wait for the updater and close the game it starts.
|
||||||
|
pub async fn run_osu_updater(osu_folder: &str) -> Result<(), String> {
|
||||||
|
let folder = Path::new(osu_folder);
|
||||||
|
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
|
||||||
|
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
let mut updater = runtime
|
||||||
|
.spawn(&folder.join("osu!.exe"), &["-repair"])
|
||||||
|
.map_err(|error| {
|
||||||
|
log::error!(target: "osu", "Could not start the osu! updater: {error}");
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(pid) = updater.id() {
|
||||||
|
log::info!(target: "osu", "osu! updater started through Wine (pid {pid})");
|
||||||
|
}
|
||||||
|
crate::logging::capture_osu_output(&mut updater);
|
||||||
|
|
||||||
|
let mut system = sysinfo::System::new_all();
|
||||||
|
let deadline = Instant::now() + StdDuration::from_secs(300);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if updater
|
||||||
|
.try_wait()
|
||||||
|
.map_err(|error| format!("Failed to check the osu! updater: {}", error))?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
log::info!(target: "osu", "The osu! updater exited");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||||
|
if system
|
||||||
|
.processes()
|
||||||
|
.values()
|
||||||
|
.any(crate::utils::is_osu_game_process)
|
||||||
|
{
|
||||||
|
// The updater relaunched osu! to finish the update. Close it so the
|
||||||
|
// launcher can start the game itself with the correct server.
|
||||||
|
log::info!(target: "osu", "The updater relaunched osu!; closing the Wine prefix");
|
||||||
|
runtime.kill_prefix().await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
log::warn!(target: "osu", "Timed out waiting for the osu! updater; closing the Wine prefix");
|
||||||
|
runtime.kill_prefix().await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match updater.wait().await {
|
||||||
|
Ok(status) => log::info!(target: "osu", "osu! updater exited with {status}"),
|
||||||
|
Err(error) => log::warn!(target: "osu", "Could not wait for the osu! updater: {error}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a user-selected path (osu! folder, `.app` bundle, prefix or drive_c)
|
||||||
|
/// to a valid osu! installation folder.
|
||||||
|
pub fn resolve_osu_folder(folder: &Path) -> Option<PathBuf> {
|
||||||
|
if is_valid_osu_folder(folder) {
|
||||||
|
return Some(folder.to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prefixes = Vec::new();
|
||||||
|
|
||||||
|
if folder.file_name() == Some(OsStr::new("drive_c")) {
|
||||||
|
if let Some(parent) = canonical_prefix(folder) {
|
||||||
|
prefixes.push(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder.join("drive_c").is_dir() {
|
||||||
|
if let Some(parent) = canonical_prefix(&folder.join("drive_c")) {
|
||||||
|
prefixes.push(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder.extension() == Some(OsStr::new("app")) || folder.join("Contents").is_dir() {
|
||||||
|
for drive_c in [
|
||||||
|
folder.join("Contents/Resources/drive_c"),
|
||||||
|
folder.join("Contents/SharedSupport/drive_c"),
|
||||||
|
folder.join("Contents/SharedSupport/prefix/drive_c"),
|
||||||
|
] {
|
||||||
|
if drive_c.is_dir() {
|
||||||
|
if let Some(parent) = canonical_prefix(&drive_c) {
|
||||||
|
prefixes.push(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut drive_dirs = Vec::new();
|
||||||
|
find_drive_c_dirs(folder, 5, &mut drive_dirs);
|
||||||
|
prefixes.extend(
|
||||||
|
drive_dirs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|drive_c| drive_c.parent().map(Path::to_path_buf)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prefix) = prefix_for(folder) {
|
||||||
|
prefixes.push(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes.sort();
|
||||||
|
prefixes.dedup();
|
||||||
|
|
||||||
|
prefixes
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|prefix| find_osu_folder_in_prefix(&prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-detect an osu! installation in the usual macOS wrapper locations.
|
||||||
|
pub fn find_osu_installation() -> Option<String> {
|
||||||
|
for app in app_bundles() {
|
||||||
|
if let Some(folder) = resolve_osu_folder(&app) {
|
||||||
|
return Some(folder.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for prefix in bottle_prefixes() {
|
||||||
|
if let Some(folder) = find_osu_folder_in_prefix(&prefix) {
|
||||||
|
return Some(folder.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_osu_folder_in_prefix(prefix: &Path) -> Option<PathBuf> {
|
||||||
|
let drive_c = prefix.join("drive_c");
|
||||||
|
if !drive_c.is_dir() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(folder) = common_osu_folders(&drive_c)
|
||||||
|
.into_iter()
|
||||||
|
.find(|folder| is_valid_osu_folder(folder))
|
||||||
|
{
|
||||||
|
return Some(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
collect_osu_folders(&drive_c, 0, 6, &mut candidates);
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|folder| is_valid_osu_folder(folder))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn common_osu_folders(drive_c: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut folders = vec![
|
||||||
|
drive_c.join("osu!"),
|
||||||
|
drive_c.join("Program Files").join("osu!"),
|
||||||
|
drive_c.join("Program Files (x86)").join("osu!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Ok(users) = fs::read_dir(drive_c.join("users")) {
|
||||||
|
for user in users.flatten() {
|
||||||
|
let user_path = user.path();
|
||||||
|
if user_path.is_dir() {
|
||||||
|
folders.push(user_path.join("AppData").join("Local").join("osu!"));
|
||||||
|
folders.push(user_path.join("AppData").join("Roaming").join("osu!"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folders
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_osu_folders(dir: &Path, depth: usize, max_depth: usize, out: &mut Vec<PathBuf>) {
|
||||||
|
if depth > max_depth {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir.join("osu!.exe").is_file() {
|
||||||
|
out.push(dir.to_path_buf());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"windows" | "programdata" | "$recycle.bin" | "system volume information"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_osu_folders(&path, depth + 1, max_depth, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_drive_c_dirs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||||
|
if depth == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
// Wineskin wrappers often add a convenience drive_c symlink at
|
||||||
|
// the app root. Resolve it to the real prefix so WINEPREFIX points
|
||||||
|
// at Contents/Resources instead of the bundle itself.
|
||||||
|
if path.file_name() == Some(OsStr::new("drive_c")) {
|
||||||
|
if let Ok(canonical) = fs::canonicalize(&path) {
|
||||||
|
out.push(canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.file_name() == Some(OsStr::new("drive_c")) {
|
||||||
|
out.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not descend into nested apps (for example Wineskin.app).
|
||||||
|
if path.extension() == Some(OsStr::new("app")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
find_drive_c_dirs(&path, depth - 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_for(osu_folder: &Path) -> Option<PathBuf> {
|
||||||
|
let drive_c = osu_folder
|
||||||
|
.ancestors()
|
||||||
|
.find(|ancestor| ancestor.file_name() == Some(OsStr::new("drive_c")))?;
|
||||||
|
canonical_prefix(drive_c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_prefix(drive_c: &Path) -> Option<PathBuf> {
|
||||||
|
let canonical = fs::canonicalize(drive_c).ok()?;
|
||||||
|
canonical.parent().map(Path::to_path_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_bundle_for(prefix: &Path) -> Option<PathBuf> {
|
||||||
|
prefix
|
||||||
|
.ancestors()
|
||||||
|
.find(|ancestor| ancestor.extension() == Some(OsStr::new("app")))
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_bundles() -> Vec<PathBuf> {
|
||||||
|
let mut roots = vec![PathBuf::from("/Applications")];
|
||||||
|
if let Some(home) = home_dir() {
|
||||||
|
roots.push(home.join("Applications"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
for root in roots {
|
||||||
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension() == Some(OsStr::new("app")) {
|
||||||
|
apps.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apps.sort();
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bottle_prefixes() -> Vec<PathBuf> {
|
||||||
|
let Some(home) = home_dir() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let bottle_roots = [
|
||||||
|
home.join("Library/Containers/com.isaacmarovitz.Whisky/Bottles"),
|
||||||
|
home.join("Library/Application Support/CrossOver/Bottles"),
|
||||||
|
home.join("Library/Application Support/Whisky/Bottles"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut prefixes = Vec::new();
|
||||||
|
for root in bottle_roots {
|
||||||
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.join("drive_c").is_dir() {
|
||||||
|
prefixes.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes.sort();
|
||||||
|
prefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_wine_bin(prefix: &Path, app: Option<&Path>) -> Option<(PathBuf, Option<PathBuf>)> {
|
||||||
|
let mut candidates: Vec<(PathBuf, Option<PathBuf>)> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(app) = app {
|
||||||
|
for root in [
|
||||||
|
app.join("Contents/SharedSupport/wine"),
|
||||||
|
app.join("Contents/Resources/WineskinEngine.bundle/Wine"),
|
||||||
|
app.join("Contents/Frameworks/wswine.bundle"),
|
||||||
|
] {
|
||||||
|
push_wine_root(&mut candidates, root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for root in [
|
||||||
|
prefix.join("WineskinEngine.bundle/Wine"),
|
||||||
|
prefix.join("Wine"),
|
||||||
|
] {
|
||||||
|
push_wine_root(&mut candidates, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(home) = home_dir() {
|
||||||
|
for root in [
|
||||||
|
home.join("Applications/Whisky.app/Contents/Resources/wine"),
|
||||||
|
home.join("Applications/Whisky.app/Contents/Libraries/Wine"),
|
||||||
|
] {
|
||||||
|
push_wine_root(&mut candidates, root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for root in [
|
||||||
|
PathBuf::from("/Applications/Whisky.app/Contents/Resources/wine"),
|
||||||
|
PathBuf::from("/Applications/Whisky.app/Contents/Libraries/Wine"),
|
||||||
|
PathBuf::from("/Applications/CrossOver.app/Contents/SharedSupport/CrossOver"),
|
||||||
|
] {
|
||||||
|
push_wine_root(&mut candidates, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
for bin in [
|
||||||
|
PathBuf::from("/opt/homebrew/bin"),
|
||||||
|
PathBuf::from("/usr/local/bin"),
|
||||||
|
] {
|
||||||
|
if has_wine_loader(&bin) {
|
||||||
|
candidates.push((bin, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for binary in ["wine64", "wine32on64", "wine"] {
|
||||||
|
if let Some(path) = find_in_path(binary) {
|
||||||
|
if let Some(bin) = path.parent() {
|
||||||
|
candidates.push((bin.to_path_buf(), None));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.into_iter().find(|(bin, _)| has_wine_loader(bin))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_wine_root(candidates: &mut Vec<(PathBuf, Option<PathBuf>)>, root: PathBuf) {
|
||||||
|
let bin = root.join("bin");
|
||||||
|
if has_wine_loader(&bin) {
|
||||||
|
candidates.push((bin, Some(root)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_wine_loader(bin: &Path) -> bool {
|
||||||
|
["wine32on64", "wine64", "wine"]
|
||||||
|
.iter()
|
||||||
|
.any(|loader| bin.join(loader).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wine_version_for(bin: &Path) -> Option<String> {
|
||||||
|
let loader = ["wine64", "wine", "wine32on64"]
|
||||||
|
.iter()
|
||||||
|
.map(|name| bin.join(name))
|
||||||
|
.find(|path| path.is_file())?;
|
||||||
|
let output = std::process::Command::new(loader)
|
||||||
|
.arg("--version")
|
||||||
|
.env("WINEDEBUG", "-all")
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
(!version.is_empty()).then_some(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_architecture(prefix: &Path) -> Option<String> {
|
||||||
|
if prefix.join("drive_c/windows/syswow64").is_dir() {
|
||||||
|
Some("win64".to_string())
|
||||||
|
} else if prefix.join("drive_c/windows/system32").is_dir() {
|
||||||
|
Some("win32".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_in_path(binary: &str) -> Option<PathBuf> {
|
||||||
|
let path = std::env::var_os("PATH")?;
|
||||||
|
std::env::split_paths(&path)
|
||||||
|
.map(|dir| dir.join(binary))
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pe_machine(exe: &Path) -> Option<u16> {
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
|
||||||
|
let mut file = fs::File::open(exe).ok()?;
|
||||||
|
let mut signature = [0u8; 2];
|
||||||
|
file.read_exact(&mut signature).ok()?;
|
||||||
|
if &signature != b"MZ" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.seek(SeekFrom::Start(0x3c)).ok()?;
|
||||||
|
let mut offset = [0u8; 4];
|
||||||
|
file.read_exact(&mut offset).ok()?;
|
||||||
|
file.seek(SeekFrom::Start(u32::from_le_bytes(offset) as u64))
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let mut header = [0u8; 6];
|
||||||
|
file.read_exact(&mut header).ok()?;
|
||||||
|
if &header[..4] != b"PE\0\0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(u16::from_le_bytes([header[4], header[5]]))
|
||||||
|
}
|
||||||
@@ -104,12 +104,12 @@ impl PresenceActor {
|
|||||||
self.reconnecting = false;
|
self.reconnecting = false;
|
||||||
match result {
|
match result {
|
||||||
ReconnectResult::Connected(client) => {
|
ReconnectResult::Connected(client) => {
|
||||||
println!("Presence: reconnect succeeded.");
|
log::info!(target: "launcher", "Presence: reconnect succeeded");
|
||||||
self.client = Some(client);
|
self.client = Some(client);
|
||||||
self.push_presence().await;
|
self.push_presence().await;
|
||||||
}
|
}
|
||||||
ReconnectResult::Failed => {
|
ReconnectResult::Failed => {
|
||||||
eprintln!("Presence: all reconnect attempts exhausted.");
|
log::warn!(target: "launcher", "Presence: all reconnect attempts exhausted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,10 @@ impl PresenceActor {
|
|||||||
let _ = resp.send(true);
|
let _ = resp.send(true);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
eprintln!("Presence: initial connect failed; spawning reconnect task.");
|
log::warn!(
|
||||||
|
target: "launcher",
|
||||||
|
"Presence: initial connect failed; spawning reconnect task"
|
||||||
|
);
|
||||||
let _ = resp.send(false);
|
let _ = resp.send(false);
|
||||||
self.spawn_reconnect_task();
|
self.spawn_reconnect_task();
|
||||||
}
|
}
|
||||||
@@ -145,7 +148,7 @@ impl PresenceActor {
|
|||||||
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
|
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
|
||||||
if let Some(mut client) = self.client.take() {
|
if let Some(mut client) = self.client.take() {
|
||||||
let _ = client.clear_activity().await;
|
let _ = client.clear_activity().await;
|
||||||
println!("Presence: disconnected.");
|
log::info!(target: "launcher", "Presence: disconnected");
|
||||||
}
|
}
|
||||||
let _ = resp.send(());
|
let _ = resp.send(());
|
||||||
}
|
}
|
||||||
@@ -182,9 +185,12 @@ impl PresenceActor {
|
|||||||
|
|
||||||
let activity = builder.build();
|
let activity = builder.build();
|
||||||
|
|
||||||
/* println!("Presence: updating activity: {:?}", activity); */
|
|
||||||
if let Err(e) = client.set_activity(&activity).await {
|
if let Err(e) = client.set_activity(&activity).await {
|
||||||
eprintln!("Presence: set_activity failed ({:?}); will reconnect.", e);
|
log::warn!(
|
||||||
|
target: "launcher",
|
||||||
|
"Presence: set_activity failed ({:?}); will reconnect",
|
||||||
|
e
|
||||||
|
);
|
||||||
self.client = None;
|
self.client = None;
|
||||||
self.spawn_reconnect_task();
|
self.spawn_reconnect_task();
|
||||||
}
|
}
|
||||||
@@ -201,9 +207,12 @@ impl PresenceActor {
|
|||||||
const MAX_ATTEMPTS: u32 = 5;
|
const MAX_ATTEMPTS: u32 = 5;
|
||||||
for attempt in 1..=MAX_ATTEMPTS {
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
let delay = Duration::from_secs(5 * attempt as u64);
|
let delay = Duration::from_secs(5 * attempt as u64);
|
||||||
println!(
|
log::info!(
|
||||||
|
target: "launcher",
|
||||||
"Presence: reconnect attempt {}/{} in {:?}…",
|
"Presence: reconnect attempt {}/{} in {:?}…",
|
||||||
attempt, MAX_ATTEMPTS, delay
|
attempt,
|
||||||
|
MAX_ATTEMPTS,
|
||||||
|
delay
|
||||||
);
|
);
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
|
|
||||||
@@ -221,16 +230,16 @@ async fn try_connect_once() -> Option<AsyncDiscordIpcClient> {
|
|||||||
match AsyncDiscordIpcClient::new("1032772293220384808").await {
|
match AsyncDiscordIpcClient::new("1032772293220384808").await {
|
||||||
Ok(mut client) => match client.connect().await {
|
Ok(mut client) => match client.connect().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
println!("Presence: IPC connected.");
|
log::info!(target: "launcher", "Presence: IPC connected");
|
||||||
Some(client)
|
Some(client)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Presence: IPC connect error: {:?}", e);
|
log::warn!(target: "launcher", "Presence: IPC connect error: {:?}", e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Presence: failed to create client: {:?}", e);
|
log::warn!(target: "launcher", "Presence: failed to create client: {:?}", e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,7 +265,11 @@ pub async fn connect() -> bool {
|
|||||||
|
|
||||||
pub async fn disconnect() {
|
pub async fn disconnect() {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
if PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await.is_ok() {
|
if PRESENCE_TX
|
||||||
|
.send(PresenceCommand::Disconnect(tx))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
|
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,4 +356,4 @@ pub fn handle_run_event(_app: &AppHandle, event: &RunEvent) {
|
|||||||
disconnect().await;
|
disconnect().await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,4 +6,6 @@ use std::sync::Mutex;
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<Config>,
|
pub config: Mutex<Config>,
|
||||||
pub opened_files: Mutex<Vec<PathBuf>>,
|
pub opened_files: Mutex<Vec<PathBuf>>,
|
||||||
|
/// Theme folder requested by the main window when it opens the editor.
|
||||||
|
pub editor_theme_folder: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
//! Loopback HTTP server for theme assets on Linux and macOS.
|
||||||
|
//!
|
||||||
|
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
|
||||||
|
//! protocol, and WKWebView's range handling makes large media stall there.
|
||||||
|
//! A real loopback HTTP server gives WebKit normal status codes, range
|
||||||
|
//! responses and MIME types, so v1 and v2 theme assets load the same way they
|
||||||
|
//! do in a browser without loading a whole video into a blob first.
|
||||||
|
|
||||||
|
use http_body_util::Full;
|
||||||
|
use http_range::HttpRange;
|
||||||
|
use hyper::body::{Bytes, Incoming};
|
||||||
|
use hyper::header::{
|
||||||
|
ACCEPT_RANGES, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
|
||||||
|
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
|
||||||
|
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ORIGIN, RANGE, VARY,
|
||||||
|
};
|
||||||
|
use hyper::server::conn::http1;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
type Body = Full<Bytes>;
|
||||||
|
|
||||||
|
/// Keep individual responses small enough that a media seek cannot allocate
|
||||||
|
/// an unbounded buffer. WebKit asks for the next range as it plays.
|
||||||
|
const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
struct ThemeServer {
|
||||||
|
port: u16,
|
||||||
|
root: PathBuf,
|
||||||
|
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Bind the server on a random loopback port. The root is the themes folder.
|
||||||
|
pub fn start(root: PathBuf, media_root: PathBuf) -> Result<u16, String> {
|
||||||
|
if let Some(server) = SERVER.get() {
|
||||||
|
return Ok(server.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = std::fs::canonicalize(&root)
|
||||||
|
.map_err(|error| format!("Could not resolve the themes folder: {error}"))?;
|
||||||
|
let media_root = std::fs::canonicalize(&media_root)
|
||||||
|
.map_err(|error| format!("Could not resolve the theme media cache: {error}"))?;
|
||||||
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.map_err(|error| format!("Could not configure the theme asset server: {error}"))?;
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|error| format!("Could not read the theme asset server address: {error}"))?
|
||||||
|
.port();
|
||||||
|
|
||||||
|
let previews = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let server = ThemeServer {
|
||||||
|
port,
|
||||||
|
root: root.clone(),
|
||||||
|
previews: previews.clone(),
|
||||||
|
};
|
||||||
|
let _ = SERVER.set(server);
|
||||||
|
|
||||||
|
let root = Arc::new(root);
|
||||||
|
let media_root = Arc::new(media_root);
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let listener = match TcpListener::from_std(listener) {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!(target: "launcher", "Theme asset server failed: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
target: "launcher",
|
||||||
|
"Theme asset server listening on http://127.0.0.1:{port}/themes"
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, _) = match listener.accept().await {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!(target: "launcher", "Theme asset server accept error: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let root = root.clone();
|
||||||
|
let media_root = media_root.clone();
|
||||||
|
let previews = previews.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let service = service_fn(move |request| {
|
||||||
|
handle(request, root.clone(), media_root.clone(), previews.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(error) = http1::Builder::new()
|
||||||
|
.serve_connection(TokioIo::new(stream), service)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!(target: "launcher", "Theme asset connection ended: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url() -> Option<String> {
|
||||||
|
SERVER
|
||||||
|
.get()
|
||||||
|
.map(|server| format!("http://127.0.0.1:{}/themes", server.port))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache folder for optimized theme media, next to the themes folder.
|
||||||
|
pub fn media_root() -> Result<PathBuf, String> {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
|
.map_err(|_| "Could not locate the home directory".to_string())?;
|
||||||
|
let root = PathBuf::from(home)
|
||||||
|
.join(".ezpplauncher")
|
||||||
|
.join("media-cache");
|
||||||
|
std::fs::create_dir_all(&root)
|
||||||
|
.map_err(|error| format!("Failed to create the theme media cache: {error}"))?;
|
||||||
|
std::fs::canonicalize(&root)
|
||||||
|
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a loopback theme URL back to the file it serves. Only paths inside the
|
||||||
|
/// configured themes folder are accepted.
|
||||||
|
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
|
||||||
|
let server = SERVER.get()?;
|
||||||
|
let url = url::Url::parse(value).ok()?;
|
||||||
|
if url.scheme() != "http"
|
||||||
|
|| url.host_str() != Some("127.0.0.1")
|
||||||
|
|| url.port() != Some(server.port)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = percent_encoding::percent_decode_str(url.path())
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.to_string();
|
||||||
|
let relative = path.strip_prefix("/themes/")?;
|
||||||
|
safe_path(&server.root, relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public URL for a file in the optimized media cache.
|
||||||
|
pub fn media_url(file_name: &str) -> Option<String> {
|
||||||
|
let server = SERVER.get()?;
|
||||||
|
Some(format!(
|
||||||
|
"http://127.0.0.1:{}/media/{}",
|
||||||
|
server.port, file_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a file picked in the theme editor so its preview can be served
|
||||||
|
/// over HTTP too. The token is only a lookup key; unregistered paths are never
|
||||||
|
/// readable through the server.
|
||||||
|
pub fn preview_url(path: &Path) -> Result<String, String> {
|
||||||
|
let server = SERVER
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "The theme asset server is not running".to_string())?;
|
||||||
|
let path = std::fs::canonicalize(path)
|
||||||
|
.map_err(|error| format!("Could not resolve the preview asset: {error}"))?;
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err("The preview asset is not a file".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(path.to_string_lossy().as_bytes());
|
||||||
|
hasher.update(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
let token = hex::encode(&hasher.finalize()[..16]);
|
||||||
|
|
||||||
|
server
|
||||||
|
.previews
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Could not register the preview asset".to_string())?
|
||||||
|
.insert(token.clone(), path);
|
||||||
|
|
||||||
|
Ok(format!("http://127.0.0.1:{}/preview/{token}", server.port))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
root: Arc<PathBuf>,
|
||||||
|
media_root: Arc<PathBuf>,
|
||||||
|
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||||
|
) -> Result<Response<Body>, Infallible> {
|
||||||
|
let origin = request
|
||||||
|
.headers()
|
||||||
|
.get(ORIGIN)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(allowed_origin);
|
||||||
|
|
||||||
|
if request.method() == Method::OPTIONS {
|
||||||
|
return Ok(preflight_response(origin.as_deref()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method() != Method::GET && request.method() != Method::HEAD {
|
||||||
|
return Ok(text_response(
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
"Method not allowed",
|
||||||
|
origin.as_deref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let range = request
|
||||||
|
.headers()
|
||||||
|
.get(RANGE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
|
||||||
|
let path = percent_encoding::percent_decode_str(request.uri().path())
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
target: "launcher",
|
||||||
|
"Theme asset request: {path} range={range:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let file = if let Some(relative) = path.strip_prefix("/themes/") {
|
||||||
|
safe_path(&root, relative)
|
||||||
|
} else if let Some(relative) = path.strip_prefix("/media/") {
|
||||||
|
safe_path(&media_root, relative)
|
||||||
|
} else if let Some(token) = path.strip_prefix("/preview/") {
|
||||||
|
previews
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|previews| previews.get(token).cloned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match file {
|
||||||
|
Some(file) => {
|
||||||
|
serve_file(request.method(), &file, range.as_deref(), origin.as_deref()).await
|
||||||
|
}
|
||||||
|
None => Ok(text_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Not found",
|
||||||
|
origin.as_deref(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(response.unwrap_or_else(|message| {
|
||||||
|
log::warn!(target: "launcher", "Theme asset request failed: {message}");
|
||||||
|
text_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&message,
|
||||||
|
origin.as_deref(),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_path(root: &Path, relative: &str) -> Option<PathBuf> {
|
||||||
|
let relative = relative.trim_start_matches('/');
|
||||||
|
if relative.is_empty()
|
||||||
|
|| relative
|
||||||
|
.split('/')
|
||||||
|
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = root.join(relative);
|
||||||
|
let canonical = std::fs::canonicalize(candidate).ok()?;
|
||||||
|
canonical.starts_with(root).then_some(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_file(
|
||||||
|
method: &Method,
|
||||||
|
path: &Path,
|
||||||
|
range_header: Option<&str>,
|
||||||
|
origin: Option<&str>,
|
||||||
|
) -> Result<Response<Body>, String> {
|
||||||
|
let metadata = tokio::fs::metadata(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Could not read theme asset: {error}"))?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Ok(text_response(StatusCode::NOT_FOUND, "Not found", origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
let length = metadata.len();
|
||||||
|
let range = match range_header {
|
||||||
|
Some(header) => match parse_range(header, length) {
|
||||||
|
Ok(range) => range,
|
||||||
|
Err(()) => return Ok(range_not_satisfiable(length, origin)),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, start, end) = match range {
|
||||||
|
Some((start, end)) => (StatusCode::PARTIAL_CONTENT, start, end),
|
||||||
|
None => (StatusCode::OK, 0, length.saturating_sub(1)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body_length = if length == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
end.saturating_sub(start) + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = if method == Method::HEAD || body_length == 0 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let mut file = tokio::fs::File::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Could not open theme asset: {error}"))?;
|
||||||
|
if start > 0 {
|
||||||
|
file.seek(SeekFrom::Start(start))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Could not seek theme asset: {error}"))?;
|
||||||
|
}
|
||||||
|
let mut buffer = vec![0_u8; body_length as usize];
|
||||||
|
file.read_exact(&mut buffer)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Could not read theme asset: {error}"))?;
|
||||||
|
buffer
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(CONTENT_TYPE, content_type(path))
|
||||||
|
.header(CONTENT_LENGTH, body_length.to_string())
|
||||||
|
.header(ACCEPT_RANGES, "bytes")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||||
|
|
||||||
|
if status == StatusCode::PARTIAL_CONTENT {
|
||||||
|
builder = builder.header(CONTENT_RANGE, format!("bytes {start}-{end}/{length}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
builder = builder
|
||||||
|
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||||
|
.header(VARY, "Origin")
|
||||||
|
.header(
|
||||||
|
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||||
|
"Content-Range, Content-Length, Accept-Ranges",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.body(Full::new(body.into()))
|
||||||
|
.map_err(|error| format!("Could not build theme asset response: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range(header: &str, length: u64) -> Result<Option<(u64, u64)>, ()> {
|
||||||
|
if length == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ranges = HttpRange::parse(header, length).map_err(|_| ())?;
|
||||||
|
if ranges.len() != 1 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let range = &ranges[0];
|
||||||
|
let start = range.start;
|
||||||
|
let end = (start + range.length - 1).min(length - 1);
|
||||||
|
if start >= length || end < start {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allocate the entire file for a single open-ended range request.
|
||||||
|
Ok(Some((start, end.min(start + MAX_RANGE_BYTES - 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_not_satisfiable(length: u64, origin: Option<&str>) -> Response<Body> {
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header(CONTENT_RANGE, format!("bytes */{length}"))
|
||||||
|
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.header(CONTENT_LENGTH, "0");
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
builder = builder
|
||||||
|
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||||
|
.header(VARY, "Origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.expect("range response is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preflight_response(origin: Option<&str>) -> Response<Body> {
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.header(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
|
||||||
|
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
|
||||||
|
.header(
|
||||||
|
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||||
|
"Content-Range, Content-Length, Accept-Ranges",
|
||||||
|
)
|
||||||
|
.header(ACCESS_CONTROL_MAX_AGE, "600");
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
builder = builder
|
||||||
|
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||||
|
.header(VARY, "Origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.expect("preflight response is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_response(status: StatusCode, message: &str, origin: Option<&str>) -> Response<Body> {
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.header(CONTENT_LENGTH, message.len().to_string())
|
||||||
|
.header("X-Content-Type-Options", "nosniff");
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
builder = builder
|
||||||
|
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||||
|
.header(VARY, "Origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.body(Full::new(Bytes::copy_from_slice(message.as_bytes())))
|
||||||
|
.expect("text response is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_origin(origin: &str) -> Option<String> {
|
||||||
|
if origin == "tauri://localhost"
|
||||||
|
|| origin == "http://tauri.localhost"
|
||||||
|
|| origin == "https://tauri.localhost"
|
||||||
|
|| origin == "null"
|
||||||
|
|| origin.starts_with("http://localhost:")
|
||||||
|
|| origin.starts_with("http://127.0.0.1:")
|
||||||
|
{
|
||||||
|
Some(origin.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(path: &Path) -> &'static str {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"html" | "htm" => "text/html; charset=utf-8",
|
||||||
|
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||||
|
"css" => "text/css; charset=utf-8",
|
||||||
|
"json" => "application/json; charset=utf-8",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"avif" => "image/avif",
|
||||||
|
"ico" => "image/x-icon",
|
||||||
|
"webm" => "video/webm",
|
||||||
|
"mp4" | "m4v" => "video/mp4",
|
||||||
|
"mov" => "video/quicktime",
|
||||||
|
"ogv" => "video/ogg",
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"ogg" | "oga" => "audio/ogg",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"m4a" => "audio/mp4",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"woff2" => "font/woff2",
|
||||||
|
"woff" => "font/woff",
|
||||||
|
"ttf" => "font/ttf",
|
||||||
|
"otf" => "font/otf",
|
||||||
|
"wasm" => "application/wasm",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,112 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
use sysinfo::Pid;
|
use sysinfo::Pid;
|
||||||
|
|
||||||
|
pub const OSU_FOLDER_FILES: &[&str] = &[
|
||||||
|
"avcodec-51.dll",
|
||||||
|
"avformat-52.dll",
|
||||||
|
"avutil-49.dll",
|
||||||
|
"bass.dll",
|
||||||
|
"bass_fx.dll",
|
||||||
|
"collection.db",
|
||||||
|
"d3dcompiler_47.dll",
|
||||||
|
"libEGL.dll",
|
||||||
|
"libGLESv2.dll",
|
||||||
|
"Microsoft.Ink.dll",
|
||||||
|
"OpenTK.dll",
|
||||||
|
"osu!.cfg",
|
||||||
|
"osu!.db",
|
||||||
|
"osu!.exe",
|
||||||
|
"osu!auth.dll",
|
||||||
|
"osu!gameplay.dll",
|
||||||
|
"osu!seasonal.dll",
|
||||||
|
"osu!ui.dll",
|
||||||
|
"presence.db",
|
||||||
|
"pthreadGC2.dll",
|
||||||
|
"scores.db",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn is_valid_osu_folder<P: AsRef<Path>>(folder_path: P) -> bool {
|
||||||
|
let folder_path = folder_path.as_ref();
|
||||||
|
folder_path.join("osu!.exe").is_file()
|
||||||
|
&& check_folder_completeness(folder_path, OSU_FOLDER_FILES) >= 70.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the process is osu! (either running natively or through Wine/Proton).
|
||||||
|
pub fn is_osu_process(process: &sysinfo::Process) -> bool {
|
||||||
|
let name = process.name().to_string_lossy().to_ascii_lowercase();
|
||||||
|
if name.ends_with("osu!.exe") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(exe) = process.exe() {
|
||||||
|
if exe
|
||||||
|
.file_name()
|
||||||
|
.map(|file_name| file_name.to_string_lossy().to_ascii_lowercase() == "osu!.exe")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.cmd().iter().any(|arg| {
|
||||||
|
arg.to_string_lossy()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("osu!.exe")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`is_osu_process`], but excludes the osu! updater started with `-repair`.
|
||||||
|
pub fn is_osu_game_process(process: &sysinfo::Process) -> bool {
|
||||||
|
is_osu_process(process)
|
||||||
|
&& !process.cmd().iter().any(|arg| {
|
||||||
|
arg.to_string_lossy()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("-repair")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_osu_user_config_path<P: AsRef<Path>>(folder_path: P) -> Option<PathBuf> {
|
||||||
|
let folder_path = folder_path.as_ref();
|
||||||
|
if !folder_path.is_dir() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(current_user) = std::env::var("USERNAME").or_else(|_| std::env::var("USER")) {
|
||||||
|
let config_path = folder_path.join(format!("osu!.{}.cfg", current_user));
|
||||||
|
if config_path.is_file() {
|
||||||
|
return Some(config_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Windows username inside a Wine prefix does not always match the host
|
||||||
|
// username (especially on macOS), so fall back to the most recent osu!.*.cfg.
|
||||||
|
let mut candidates = fs::read_dir(folder_path)
|
||||||
|
.ok()?
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
|
||||||
|
if !file_name.starts_with("osu!.") || !file_name.ends_with(".cfg") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if file_name == "osu!.cfg" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = entry
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.modified())
|
||||||
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
Some((modified, path))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
candidates.into_iter().next().map(|(_, path)| path)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files: &[&str]) -> f32 {
|
pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files: &[&str]) -> f32 {
|
||||||
let mut found = 0;
|
let mut found = 0;
|
||||||
for file in required_files {
|
for file in required_files {
|
||||||
@@ -20,20 +125,7 @@ pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files:
|
|||||||
pub fn get_osu_user_config<P: AsRef<Path>>(
|
pub fn get_osu_user_config<P: AsRef<Path>>(
|
||||||
osu_folder_path: P,
|
osu_folder_path: P,
|
||||||
) -> Option<std::collections::HashMap<String, String>> {
|
) -> Option<std::collections::HashMap<String, String>> {
|
||||||
if !osu_folder_path.as_ref().exists() {
|
let osu_config_path = find_osu_user_config_path(osu_folder_path)?;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_user = std::env::var("USERNAME")
|
|
||||||
.or_else(|_| std::env::var("USER"))
|
|
||||||
.unwrap_or_else(|_| "Admin".to_string());
|
|
||||||
let osu_config_path = osu_folder_path
|
|
||||||
.as_ref()
|
|
||||||
.join(format!("osu!.{}.cfg", current_user));
|
|
||||||
println!("Looking for user config at: {:?}", osu_config_path);
|
|
||||||
if !osu_config_path.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut config_map = std::collections::HashMap::new();
|
let mut config_map = std::collections::HashMap::new();
|
||||||
if let Ok(contents) = std::fs::read_to_string(osu_config_path) {
|
if let Ok(contents) = std::fs::read_to_string(osu_config_path) {
|
||||||
@@ -44,21 +136,15 @@ pub fn get_osu_user_config<P: AsRef<Path>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Some(config_map);
|
Some(config_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_osu_user_config_vals(
|
pub fn set_osu_user_config_vals(
|
||||||
osu_folder_path: &str,
|
osu_folder_path: &str,
|
||||||
key_values: &[(&str, Option<&str>)],
|
key_values: &[(&str, Option<&str>)],
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let current_user = std::env::var("USERNAME")
|
let osu_config_path = find_osu_user_config_path(osu_folder_path)
|
||||||
.or_else(|_| std::env::var("USER"))
|
.ok_or_else(|| "osu! user config file does not exist".to_string())?;
|
||||||
.unwrap_or_else(|_| "Admin".to_string());
|
|
||||||
let osu_config_path = Path::new(osu_folder_path).join(format!("osu!.{}.cfg", current_user));
|
|
||||||
|
|
||||||
if !osu_config_path.exists() {
|
|
||||||
return Err("osu! user config file does not exist".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut lines = fs::read_to_string(&osu_config_path)
|
let mut lines = fs::read_to_string(&osu_config_path)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -178,7 +264,7 @@ pub fn get_osu_config<P: AsRef<Path>>(
|
|||||||
return Some(config_map);
|
return Some(config_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||||
let mut search_paths =
|
let mut search_paths =
|
||||||
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect::<Vec<_>>();
|
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect::<Vec<_>>();
|
||||||
@@ -203,22 +289,22 @@ pub fn get_osuwinello_path() -> Option<PathBuf> {
|
|||||||
.find(|path| path.is_file())
|
.find(|path| path.is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn is_osuwinello_available() -> bool {
|
pub fn is_osuwinello_available() -> bool {
|
||||||
get_osuwinello_path().is_some()
|
get_osuwinello_path().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn is_osuwinello_available() -> bool {
|
pub fn is_osuwinello_available() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn is_wmctrl_available() -> bool {
|
pub fn is_wmctrl_available() -> bool {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
Command::new("wmctrl")
|
Command::new("wmctrl")
|
||||||
@@ -229,12 +315,12 @@ pub fn is_wmctrl_available() -> bool {
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn is_wmctrl_available() -> bool {
|
pub fn is_wmctrl_available() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
let find_title = || -> Option<String> {
|
let find_title = || -> Option<String> {
|
||||||
@@ -266,6 +352,11 @@ pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
|||||||
find_title().unwrap_or_default()
|
find_title().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn get_window_title_by_pid(_pid: Pid) -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn get_window_title_by_pid(pid: Pid) -> String {
|
pub fn get_window_title_by_pid(pid: Pid) -> String {
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
@@ -330,11 +421,16 @@ pub async fn is_net8_installed() -> bool {
|
|||||||
match output_result {
|
match output_result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
eprintln!(
|
log::warn!(
|
||||||
"Error: `dotnet --list-runtimes` failed with status: {}",
|
target: "launcher",
|
||||||
|
"`dotnet --list-runtimes` failed with status: {}",
|
||||||
output.status
|
output.status
|
||||||
);
|
);
|
||||||
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
|
log::warn!(
|
||||||
|
target: "launcher",
|
||||||
|
"dotnet stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,24 +28,27 @@
|
|||||||
"security": {
|
"security": {
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' tauri: customprotocol: asset:",
|
"default-src": "'self' tauri: customprotocol: asset:",
|
||||||
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost https://analytics.ez-pp.farm",
|
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
|
||||||
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost",
|
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:*",
|
||||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
|
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http://127.0.0.1:* https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
|
||||||
"connect-src": "'self' ipc: http://ipc.localhost https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh",
|
"connect-src": "'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh https://api.fontsource.org https://cdn.jsdelivr.net",
|
||||||
"media-src": "'self' asset: http://asset.localhost https://asset.localhost blob:",
|
"media-src": "'self' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* blob:",
|
||||||
"font-src": [
|
"font-src": [
|
||||||
"'self'",
|
"'self'",
|
||||||
"asset:",
|
"asset:",
|
||||||
"blob:",
|
"blob:",
|
||||||
"data:",
|
"data:",
|
||||||
"http://asset.localhost",
|
"http://asset.localhost",
|
||||||
"https://asset.localhost"
|
"https://asset.localhost",
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
"https://cdn.jsdelivr.net"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": [
|
"scope": [
|
||||||
"$HOME/.ezpplauncher/themes/**"
|
"$HOME/.ezpplauncher/themes/**",
|
||||||
|
"$HOME/.ezpplauncher/font-cache/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["app", "dmg"],
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
@import "@fontsource-variable/sora";
|
@import "@fontsource-variable/sora";
|
||||||
@import "@fontsource/space-mono";
|
@import "@fontsource/space-mono";
|
||||||
|
@import "@fontsource/bebas-neue";
|
||||||
|
@import "@fontsource-variable/montserrat";
|
||||||
|
@import "@fontsource-variable/playfair-display";
|
||||||
|
@import "@fontsource-variable/jetbrains-mono";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type ConfettiCannonProps = {
|
||||||
|
colors?: string[];
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { colors = ['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24'], count = 820 }: ConfettiCannonProps =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let canvas = $state<HTMLCanvasElement>();
|
||||||
|
|
||||||
|
type Particle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
spin: number;
|
||||||
|
flutterPhase: number;
|
||||||
|
flutterSpeed: number;
|
||||||
|
swayPhase: number;
|
||||||
|
swaySpeed: number;
|
||||||
|
swayAmount: number;
|
||||||
|
terminalVelocity: number;
|
||||||
|
wind: number;
|
||||||
|
color: string;
|
||||||
|
delay: number;
|
||||||
|
age: number;
|
||||||
|
lifetime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const element = canvas;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const context = element.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
|
||||||
|
const random = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let dpr = 1;
|
||||||
|
let frame = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
let elapsed = 0;
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
|
||||||
|
const spawn = () => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
width = rect.width || window.innerWidth;
|
||||||
|
height = rect.height || window.innerHeight;
|
||||||
|
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
element.width = Math.max(1, Math.round(width * dpr));
|
||||||
|
element.height = Math.max(1, Math.round(height * dpr));
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
particles = Array.from({ length: count }, (_, index) => {
|
||||||
|
const fromLeft = index % 2 === 0;
|
||||||
|
const direction = fromLeft ? 1 : -1;
|
||||||
|
const streamer = Math.random() < 0.35;
|
||||||
|
const pieceWidth = streamer ? random(3, 5) : random(5, 8);
|
||||||
|
const pieceHeight = streamer ? random(12, 24) : random(7, 14);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: fromLeft ? random(0, 28) : width - random(0, 28),
|
||||||
|
y: height - random(0, 16),
|
||||||
|
// Aim inward and up from each bottom corner. Horizontal drag and
|
||||||
|
// gravity turn the straight launch into a natural arc.
|
||||||
|
vx: direction * random(160, 900),
|
||||||
|
vy: -random(1050, 1450),
|
||||||
|
width: pieceWidth,
|
||||||
|
height: pieceHeight,
|
||||||
|
rotation: random(0, Math.PI * 2),
|
||||||
|
spin: random(-4, 4),
|
||||||
|
flutterPhase: random(0, Math.PI * 2),
|
||||||
|
flutterSpeed: random(2.5, 6.5),
|
||||||
|
swayPhase: random(0, Math.PI * 2),
|
||||||
|
swaySpeed: random(2.2, 4.2),
|
||||||
|
swayAmount: random(4, 14),
|
||||||
|
terminalVelocity: random(130, 230),
|
||||||
|
wind: random(-8, 8),
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
delay: random(0, 0.25),
|
||||||
|
age: 0,
|
||||||
|
lifetime: random(7, 9.5),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const paint = (time: number) => {
|
||||||
|
if (lastTime === 0) lastTime = time;
|
||||||
|
const delta = Math.min((time - lastTime) / 1000, 0.05);
|
||||||
|
lastTime = time;
|
||||||
|
elapsed += delta;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
let alive = false;
|
||||||
|
|
||||||
|
for (const particle of particles) {
|
||||||
|
if (elapsed < particle.delay) {
|
||||||
|
alive = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (particle.age >= particle.lifetime) continue;
|
||||||
|
|
||||||
|
particle.age += delta;
|
||||||
|
if (particle.age >= particle.lifetime) continue;
|
||||||
|
alive = true;
|
||||||
|
|
||||||
|
particle.vx *= Math.exp(-0.5 * delta);
|
||||||
|
|
||||||
|
if (particle.vy < 0) {
|
||||||
|
// The upward burst is slowed by gravity...
|
||||||
|
particle.vy += 1400 * delta;
|
||||||
|
} else {
|
||||||
|
// ...then the paper drifts down at its own slow terminal speed
|
||||||
|
// instead of accelerating like a solid object.
|
||||||
|
particle.vy += (particle.terminalVelocity - particle.vy) * (1 - Math.exp(-2.6 * delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.x += (particle.vx + particle.wind) * delta;
|
||||||
|
particle.y += particle.vy * delta;
|
||||||
|
particle.rotation += particle.spin * delta;
|
||||||
|
|
||||||
|
if (particle.x < -80 || particle.x > width + 80 || particle.y > height + 120) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeStart = particle.lifetime - 1.8;
|
||||||
|
const alpha =
|
||||||
|
particle.age > fadeStart ? Math.max(0, 1 - (particle.age - fadeStart) / 1.8) : 1;
|
||||||
|
if (alpha <= 0) continue;
|
||||||
|
|
||||||
|
const flutter = Math.cos(particle.flutterPhase + particle.age * particle.flutterSpeed);
|
||||||
|
const sway = Math.sin(particle.age * particle.swaySpeed + particle.swayPhase);
|
||||||
|
const scaleX = 0.15 + Math.abs(flutter) * 0.85;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.translate(particle.x + sway * particle.swayAmount, particle.y + sway * 3.5);
|
||||||
|
context.rotate(particle.rotation + flutter * 0.5);
|
||||||
|
context.scale(scaleX, 1);
|
||||||
|
context.globalAlpha = alpha;
|
||||||
|
context.fillStyle = particle.color;
|
||||||
|
context.fillRect(
|
||||||
|
-particle.width / 2,
|
||||||
|
-particle.height / 2,
|
||||||
|
particle.width,
|
||||||
|
particle.height
|
||||||
|
);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alive) {
|
||||||
|
frame = requestAnimationFrame(paint);
|
||||||
|
} else {
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn();
|
||||||
|
frame = requestAnimationFrame(paint);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-0 z-20 h-full w-full"
|
||||||
|
></canvas>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
let previewCount = 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
family: string;
|
||||||
|
src: string;
|
||||||
|
weight?: string;
|
||||||
|
style?: 'normal' | 'italic';
|
||||||
|
text?: string;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
family,
|
||||||
|
src,
|
||||||
|
weight = '400',
|
||||||
|
style = 'normal',
|
||||||
|
text = family,
|
||||||
|
class: className = '',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let host = $state<HTMLSpanElement>();
|
||||||
|
let visible = $state(false);
|
||||||
|
let renderedFamily = $state<string>();
|
||||||
|
let face: FontFace | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!host) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
visible = true;
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
{ rootMargin: '180px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(host);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!visible || !src) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const candidate = new FontFace(previewFamily, `url("${src}")`, {
|
||||||
|
weight,
|
||||||
|
style,
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
const loaded = await candidate.load();
|
||||||
|
if (cancelled) return;
|
||||||
|
document.fonts.add(loaded);
|
||||||
|
face = loaded;
|
||||||
|
renderedFamily = previewFamily;
|
||||||
|
} catch {
|
||||||
|
// A preview failure should leave the row readable in the fallback font.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (face && face.family === previewFamily) {
|
||||||
|
document.fonts.delete(face);
|
||||||
|
face = undefined;
|
||||||
|
}
|
||||||
|
if (renderedFamily === previewFamily) renderedFamily = undefined;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={host}
|
||||||
|
class={className}
|
||||||
|
style:font-family={renderedFamily
|
||||||
|
? `"${renderedFamily}", ui-sans-serif, system-ui, sans-serif`
|
||||||
|
: undefined}
|
||||||
|
style:font-weight={weight}
|
||||||
|
style:font-style={style}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<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 Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||||
|
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
||||||
|
import type { ThemeFontChoice } from '$lib/themeFonts';
|
||||||
|
import type { ThemeV2Element } from '$lib/themeV2';
|
||||||
|
import { Image as ImageIcon, RefreshCw, Type } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
element,
|
||||||
|
fontOptions,
|
||||||
|
onreplace,
|
||||||
|
}: {
|
||||||
|
element: ThemeV2Element;
|
||||||
|
fontOptions: ThemeFontChoice[];
|
||||||
|
onreplace: (kind: 'image' | 'video') => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||||
|
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<div class="flex items-center gap-2 border-b border-theme-800 pb-3">
|
||||||
|
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
|
||||||
|
{#if element.type === 'text'}
|
||||||
|
<Type class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<ImageIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-xs font-semibold capitalize text-theme-50">{element.type} layer</p>
|
||||||
|
<p class="truncate font-mono text-[10px] text-muted-foreground">{element.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-name" class={label}>Layer name</Label>
|
||||||
|
<Input
|
||||||
|
id="element-name"
|
||||||
|
class={field}
|
||||||
|
value={element.name ?? ''}
|
||||||
|
placeholder="Optional label, for example Logo"
|
||||||
|
oninput={(event) => (element.name = (event.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-x" class={label}>X</Label>
|
||||||
|
<Input id="element-x" class={field} type="number" step="1" bind:value={element.x} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-y" class={label}>Y</Label>
|
||||||
|
<Input id="element-y" class={field} type="number" step="1" bind:value={element.y} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-width" class={label}>Width</Label>
|
||||||
|
<Input
|
||||||
|
id="element-width"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="24"
|
||||||
|
max="1000"
|
||||||
|
bind:value={element.width}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-height" class={label}>Height</Label>
|
||||||
|
<Input
|
||||||
|
id="element-height"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="24"
|
||||||
|
max="650"
|
||||||
|
bind:value={element.height}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-rotation" class={label}>Rotation</Label>
|
||||||
|
<Input
|
||||||
|
id="element-rotation"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="-360"
|
||||||
|
max="360"
|
||||||
|
bind:value={element.rotation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class={label}>Opacity</Label>
|
||||||
|
<span class="text-[10px] text-muted-foreground"
|
||||||
|
>{Math.round((element.opacity ?? 1) * 100)}%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={element.opacity ?? 1}
|
||||||
|
onValueChange={(value) => (element.opacity = value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 border-t border-theme-800 pt-3">
|
||||||
|
<div class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 px-2 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
id="element-hidden"
|
||||||
|
checked={element.hidden === true}
|
||||||
|
onCheckedChange={(checked) => (element.hidden = checked === true)}
|
||||||
|
/>
|
||||||
|
<Label for="element-hidden" class="text-[10px] font-normal text-muted-foreground">
|
||||||
|
Hidden
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 px-2 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
id="element-locked"
|
||||||
|
checked={element.locked === true}
|
||||||
|
onCheckedChange={(checked) => (element.locked = checked === true)}
|
||||||
|
/>
|
||||||
|
<Label for="element-locked" class="text-[10px] font-normal text-muted-foreground">
|
||||||
|
Locked
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if element.type === 'text'}
|
||||||
|
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-text" class={label}>Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="element-text"
|
||||||
|
class="min-h-20 resize-y border-theme-800 bg-theme-950 text-xs normal-case tracking-normal text-theme-50"
|
||||||
|
bind:value={element.text}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-font" class={label}>Font</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={element.fontFamily ?? 'Sora Variable'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) element.fontFamily = value;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="element-font"
|
||||||
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
|
>
|
||||||
|
{element.fontFamily ?? 'Sora Variable'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
|
{#each fontOptions as font (font.family)}
|
||||||
|
<Select.Item value={font.family} label={`${font.label} · ${font.category}`} />
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-font-size" class={label}>Size</Label>
|
||||||
|
<Input
|
||||||
|
id="element-font-size"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="6"
|
||||||
|
max="240"
|
||||||
|
bind:value={element.fontSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-font-weight" class={label}>Weight</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={String(element.fontWeight ?? 400)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) element.fontWeight = Number(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="element-font-weight"
|
||||||
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
|
>
|
||||||
|
{element.fontWeight ?? 400}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
|
{#each [100, 200, 300, 400, 500, 600, 700, 800, 900] as weight (weight)}
|
||||||
|
<Select.Item value={String(weight)} label={String(weight)} />
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-align" class={label}>Align</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={element.align ?? 'left'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) element.align = value as 'left' | 'center' | 'right';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="element-align"
|
||||||
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
|
>
|
||||||
|
{element.align ?? 'left'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
|
<Select.Item value="left" label="Left" />
|
||||||
|
<Select.Item value="center" label="Center" />
|
||||||
|
<Select.Item value="right" label="Right" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-vertical-align" class={label}>Vertical</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={element.verticalAlign ?? 'top'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) element.verticalAlign = value as 'top' | 'center' | 'bottom';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="element-vertical-align"
|
||||||
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
|
>
|
||||||
|
{element.verticalAlign ?? 'top'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
|
<Select.Item value="top" label="Top" />
|
||||||
|
<Select.Item value="center" label="Middle" />
|
||||||
|
<Select.Item value="bottom" label="Bottom" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-line-height" class={label}>Line height</Label>
|
||||||
|
<Input
|
||||||
|
id="element-line-height"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="0.7"
|
||||||
|
max="3"
|
||||||
|
step="0.05"
|
||||||
|
bind:value={element.lineHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-letter-spacing" class={label}>Spacing</Label>
|
||||||
|
<Input
|
||||||
|
id="element-letter-spacing"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="-20"
|
||||||
|
max="40"
|
||||||
|
step="0.5"
|
||||||
|
bind:value={element.letterSpacing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-color" class={label}>Color</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
class="h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
|
||||||
|
type="color"
|
||||||
|
bind:value={element.color}
|
||||||
|
/>
|
||||||
|
<Input id="element-color" class="{field} flex-1 font-mono" bind:value={element.color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if element.type === 'image' || element.type === 'video'}
|
||||||
|
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||||
|
<div class="rounded-md border border-theme-800 bg-black/20 p-2">
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Asset</p>
|
||||||
|
<p class="mt-1 truncate font-mono text-[10px] text-theme-100">{element.src}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
class="h-8 w-full gap-2 text-xs"
|
||||||
|
onclick={() => onreplace(element.type as 'image' | 'video')}
|
||||||
|
>
|
||||||
|
<RefreshCw class="size-3.5" />
|
||||||
|
Replace {element.type}
|
||||||
|
</Button>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-fit" class={label}>Fit</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={element.fit ?? 'cover'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) element.fit = value as 'cover' | 'contain';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="element-fit"
|
||||||
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
|
>
|
||||||
|
{element.fit ?? 'cover'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
|
<Select.Item value="cover" label="Cover" />
|
||||||
|
<Select.Item value="contain" label="Contain" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-radius" class={label}>Radius</Label>
|
||||||
|
<Input
|
||||||
|
id="element-radius"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
bind:value={element.radius}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if element.type === 'video'}
|
||||||
|
<div class="flex items-start gap-2 rounded-md border border-theme-800 bg-black/20 p-2">
|
||||||
|
<Checkbox
|
||||||
|
id="element-muted"
|
||||||
|
class="mt-0.5"
|
||||||
|
checked={element.muted !== false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
element.muted = checked === true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label for="element-muted" class="text-[10px] font-normal text-theme-100">
|
||||||
|
Mute video
|
||||||
|
</Label>
|
||||||
|
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||||
|
Unmuted videos may wait for a click before they start, because of autoplay policies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||||
|
Videos autoplay and loop. WebM keeps theme packages small; MP4 also works.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-shape" class={label}>Shape</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={element.shape}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) element.shape = value as 'rectangle' | 'ellipse' | 'line';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="element-shape"
|
||||||
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
|
>
|
||||||
|
{element.shape}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
|
<Select.Item value="rectangle" label="Rectangle" />
|
||||||
|
<Select.Item value="ellipse" label="Ellipse" />
|
||||||
|
<Select.Item value="line" label="Line" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-fill" class={label}>Fill</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
class="h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
|
||||||
|
type="color"
|
||||||
|
bind:value={element.fill}
|
||||||
|
/>
|
||||||
|
<Input id="element-fill" class="{field} flex-1 font-mono" bind:value={element.fill} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if element.shape !== 'ellipse'}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-shape-radius" class={label}>Corner radius</Label>
|
||||||
|
<Input
|
||||||
|
id="element-shape-radius"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
bind:value={element.radius}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-border-width" class={label}>Border</Label>
|
||||||
|
<Input
|
||||||
|
id="element-border-width"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="40"
|
||||||
|
bind:value={element.borderWidth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-border-color" class={label}>Border color</Label>
|
||||||
|
<Input
|
||||||
|
id="element-border-color"
|
||||||
|
class="h-8 w-full cursor-pointer border-theme-800 bg-theme-950 p-0.5"
|
||||||
|
type="color"
|
||||||
|
bind:value={element.borderColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Logo from '$assets/logo.png';
|
||||||
|
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||||
|
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||||
|
import { fontStack } from '$lib/themeFonts';
|
||||||
|
import { readableTextColor, themeFontFaceCss, type ThemeV2Definition } from '$lib/themeV2';
|
||||||
|
import { Brush, House, Music, Paintbrush, Play, Settings, User, Wifi } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
definition,
|
||||||
|
assets = '',
|
||||||
|
assetOverrides = {},
|
||||||
|
selectedIds = [],
|
||||||
|
interactive = false,
|
||||||
|
scale = 1,
|
||||||
|
onselect,
|
||||||
|
onselectmany,
|
||||||
|
onmove,
|
||||||
|
onpatch,
|
||||||
|
oncontext,
|
||||||
|
}: {
|
||||||
|
definition: ThemeV2Definition;
|
||||||
|
assets?: string;
|
||||||
|
assetOverrides?: Record<string, string>;
|
||||||
|
selectedIds?: string[];
|
||||||
|
interactive?: boolean;
|
||||||
|
scale?: number;
|
||||||
|
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||||
|
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||||
|
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||||
|
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const bodyFont = $derived(definition.typography?.body);
|
||||||
|
const headingFont = $derived(definition.typography?.heading);
|
||||||
|
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
|
||||||
|
const accentText = $derived(readableTextColor(accent));
|
||||||
|
const stageStyle = $derived(
|
||||||
|
[
|
||||||
|
`font-family:${fontStack(bodyFont)}`,
|
||||||
|
`--primary:${accent}`,
|
||||||
|
`--primary-foreground:${accentText}`,
|
||||||
|
`--ring:${accent}`,
|
||||||
|
`--color-primary:${accent}`,
|
||||||
|
`--color-primary-foreground:${accentText}`,
|
||||||
|
`--color-primary-500:${accent}`,
|
||||||
|
`--color-ring:${accent}`,
|
||||||
|
].join(';')
|
||||||
|
);
|
||||||
|
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
|
||||||
|
const elements = $derived(definition.elements ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html `<style>${fontFaces}</style>`}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||||
|
style={stageStyle}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
|
||||||
|
|
||||||
|
<div class="relative z-10 grid h-full grid-cols-[0.085fr_1fr]">
|
||||||
|
<aside
|
||||||
|
class="flex flex-col items-center gap-2 border-r border-theme-900 bg-black/40 p-3 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
|
||||||
|
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
|
||||||
|
>
|
||||||
|
3.2.7
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary/50 p-3 ring-1 ring-inset ring-white/15"
|
||||||
|
>
|
||||||
|
<House class="size-5 text-theme-200" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||||
|
>
|
||||||
|
<Paintbrush class="size-5 text-theme-200" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||||
|
>
|
||||||
|
<Settings class="size-5 text-theme-200" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
|
||||||
|
>
|
||||||
|
<User class="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="relative flex min-w-0 flex-col">
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="mt-12 mb-auto flex flex-col px-6">
|
||||||
|
<span
|
||||||
|
class="text-4xl font-bold drop-shadow-lg"
|
||||||
|
style="font-family:{fontStack(headingFont, bodyFont)}"
|
||||||
|
>
|
||||||
|
EZPPLauncher
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-muted-foreground drop-shadow-lg">Hello Guest!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative h-50 shrink-0">
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 m-1 flex h-48 w-72 flex-col items-center justify-center gap-3 rounded-md bg-black/40 p-3 ring-1 ring-inset ring-white/5 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-1 text-center">
|
||||||
|
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
Log in with your EZPPFarm account to track your rank, PP, accuracy and playcount.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div
|
||||||
|
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex h-7 w-full items-center justify-center rounded-md border border-theme-800 text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-4 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Music class="size-3.5 text-blue-400" />
|
||||||
|
<span class="text-[11px] font-medium text-white/70">1.2k</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Brush class="size-3.5 text-amber-400" />
|
||||||
|
<span class="text-[11px] font-medium text-white/70">42</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Wifi class="size-3.5 text-emerald-400" />
|
||||||
|
<span class="text-[11px] font-medium text-white/70">19ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-4 w-px bg-white/10"></div>
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 text-[10px] text-white/50"
|
||||||
|
>
|
||||||
|
stable
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/50"
|
||||||
|
>
|
||||||
|
20260101.0
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-primary/20 bg-primary/20 px-2 py-0.5 text-[10px] text-primary"
|
||||||
|
>
|
||||||
|
Default skin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-12 items-center rounded-full bg-primary shadow-lg shadow-primary/25">
|
||||||
|
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
|
||||||
|
>
|
||||||
|
<Play class="ml-0.5 size-4 fill-current text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-52 pl-1 pr-6 text-center">
|
||||||
|
<p class="text-sm font-bold leading-tight text-primary-foreground">Launch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ThemeV2Elements
|
||||||
|
{elements}
|
||||||
|
{assets}
|
||||||
|
{assetOverrides}
|
||||||
|
{interactive}
|
||||||
|
{selectedIds}
|
||||||
|
{scale}
|
||||||
|
{onselect}
|
||||||
|
{onselectmany}
|
||||||
|
{onmove}
|
||||||
|
{onpatch}
|
||||||
|
{oncontext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { custom_theme_volume } from '$lib/global';
|
||||||
|
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||||
|
import type { ThemeV2Definition } from '$lib/themeV2';
|
||||||
|
import { themeAssetUrl } from '$lib/themeV2';
|
||||||
|
import { reduceAnimations } from '$lib/userSettings';
|
||||||
|
import { trackMediaContainer } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
definition,
|
||||||
|
assets = '',
|
||||||
|
assetOverrides = {},
|
||||||
|
preview = false,
|
||||||
|
}: {
|
||||||
|
definition: ThemeV2Definition;
|
||||||
|
assets?: string;
|
||||||
|
assetOverrides?: Record<string, string>;
|
||||||
|
preview?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
||||||
|
|
||||||
|
const assetUrl = (path: string | undefined) => {
|
||||||
|
if (!path) return '';
|
||||||
|
return assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const background = $derived(definition.background);
|
||||||
|
const overlay = $derived(definition.overlay);
|
||||||
|
const effects = $derived(definition.effects);
|
||||||
|
|
||||||
|
const random = (seed: number) => {
|
||||||
|
const value = Math.sin(seed * 12.9898) * 43758.5453;
|
||||||
|
return value - Math.floor(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const particles = $derived.by(() => {
|
||||||
|
if ($reduceAnimations) return [];
|
||||||
|
if (!effects || effects.type === 'none') return [];
|
||||||
|
|
||||||
|
const count = Math.min(120, Math.max(0, Math.round(effects.density ?? 30)));
|
||||||
|
const speed = Math.max(0.2, effects.speed ?? 1);
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const value = (offset: number) => random(index + 1 + offset);
|
||||||
|
const isStar = effects.type === 'stars';
|
||||||
|
const size = isStar ? 1 + value(1) * 2.5 : 4 + value(1) * 7;
|
||||||
|
const duration = (isStar ? 2 + value(2) * 3 : 9 + value(2) * 11) / speed;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
left: value(3) * 100,
|
||||||
|
top: isStar ? value(4) * 100 : value(4) * 15,
|
||||||
|
size,
|
||||||
|
duration,
|
||||||
|
delay: value(5) * duration,
|
||||||
|
drift: -90 + value(6) * 180,
|
||||||
|
opacity: 0.25 + value(7) * 0.65,
|
||||||
|
rotate: value(8) * 360,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function trackMedia(node: HTMLElement) {
|
||||||
|
if (preview) return {};
|
||||||
|
return trackMediaContainer(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `muted` is not reliably reflected after load, so keep the live property in
|
||||||
|
// sync when the theme toggles it in the editor.
|
||||||
|
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||||
|
node.muted = muted;
|
||||||
|
return {
|
||||||
|
update(value: boolean) {
|
||||||
|
node.muted = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!audioElement || !definition.audio) return;
|
||||||
|
audioElement.volume = $custom_theme_volume;
|
||||||
|
if (!preview) {
|
||||||
|
audioElement.play().catch(() => {
|
||||||
|
// Autoplay can be rejected until the window has been interacted with.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
|
||||||
|
{#if background.type === 'color'}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0"
|
||||||
|
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
||||||
|
></div>
|
||||||
|
{:else if background.type === 'gradient'}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0"
|
||||||
|
style="background-image:linear-gradient({background.angle ??
|
||||||
|
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
|
||||||
|
></div>
|
||||||
|
{:else if background.type === 'image'}
|
||||||
|
{#if background.src}
|
||||||
|
<img
|
||||||
|
class="absolute inset-0 h-full w-full"
|
||||||
|
src={assetUrl(background.src)}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||||
|
1};filter:{background.blur
|
||||||
|
? `blur(${background.blur}px)`
|
||||||
|
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else if background.type === 'video'}
|
||||||
|
{#if background.src}
|
||||||
|
<video
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
use:themeMediaSource={assetUrl(background.src)}
|
||||||
|
use:themeVideoLifecycle
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted={background.muted !== false}
|
||||||
|
use:applyMuted={background.muted !== false}
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||||
|
1};filter:{background.blur
|
||||||
|
? `blur(${background.blur}px)`
|
||||||
|
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
|
||||||
|
></video>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if overlay && (overlay.opacity ?? 0) > 0}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 pointer-events-none"
|
||||||
|
style="background-color:{overlay.color ?? '#000000'};opacity:{overlay.opacity ?? 0}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if overlay && (overlay.vignette ?? 0) > 0}
|
||||||
|
<div
|
||||||
|
class="vignette absolute inset-0 pointer-events-none"
|
||||||
|
style="opacity:{overlay.vignette ?? 0}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if effects && effects.type !== 'none'}
|
||||||
|
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
{#each particles as particle (particle.id)}
|
||||||
|
<span
|
||||||
|
class="particle {effects.type}"
|
||||||
|
style="left:{particle.left}%;top:{particle.top}%;width:{particle.size}px;height:{particle.size}px;opacity:{particle.opacity};--particle-color:{effects.color ??
|
||||||
|
'#ffffff'};--particle-drift:{particle.drift}px;--particle-duration:{particle.duration}s;--particle-delay:-{particle.delay}s;--particle-rotate:{particle.rotate}deg;animation-duration:{particle.duration}s;animation-delay:-{particle.delay}s;"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if definition.audio && !preview}
|
||||||
|
<audio
|
||||||
|
bind:this={audioElement}
|
||||||
|
use:themeMediaSource={assetUrl(definition.audio.src)}
|
||||||
|
loop={definition.audio.loop !== false}
|
||||||
|
autoplay
|
||||||
|
></audio>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.vignette {
|
||||||
|
background: radial-gradient(ellipse at center, transparent 38%, rgba(0, 0, 0, 0.92) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
background: var(--particle-color);
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
|
||||||
|
&.snow {
|
||||||
|
border-radius: 9999px;
|
||||||
|
filter: blur(0.5px);
|
||||||
|
animation-name: v2-snow;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.petals {
|
||||||
|
border-radius: 70% 0 70% 0;
|
||||||
|
animation-name: v2-petals;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubbles {
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--particle-color);
|
||||||
|
background: color-mix(in srgb, var(--particle-color) 16%, transparent);
|
||||||
|
animation-name: v2-bubbles;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.stars {
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 0 6px var(--particle-color);
|
||||||
|
animation-name: v2-stars;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v2-snow {
|
||||||
|
from {
|
||||||
|
transform: translate3d(0, -12vh, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate3d(var(--particle-drift), 112vh, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v2-petals {
|
||||||
|
from {
|
||||||
|
transform: translate3d(0, -12vh, 0) rotate(var(--particle-rotate));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate3d(calc(var(--particle-drift) * 0.55), 50vh, 0)
|
||||||
|
rotate(calc(var(--particle-rotate) + 180deg));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate3d(var(--particle-drift), 112vh, 0)
|
||||||
|
rotate(calc(var(--particle-rotate) + 360deg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v2-bubbles {
|
||||||
|
from {
|
||||||
|
transform: translate3d(0, 110vh, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate3d(var(--particle-drift), -12vh, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v2-stars {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.15;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fontStack } from '$lib/themeFonts';
|
||||||
|
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||||
|
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
|
||||||
|
|
||||||
|
let {
|
||||||
|
elements = [],
|
||||||
|
assets = '',
|
||||||
|
assetOverrides = {},
|
||||||
|
interactive = false,
|
||||||
|
selectedIds = [],
|
||||||
|
scale = 1,
|
||||||
|
onselect,
|
||||||
|
onselectmany,
|
||||||
|
onmove,
|
||||||
|
onpatch,
|
||||||
|
oncontext,
|
||||||
|
}: {
|
||||||
|
elements?: ThemeV2Element[];
|
||||||
|
assets?: string;
|
||||||
|
assetOverrides?: Record<string, string>;
|
||||||
|
interactive?: boolean;
|
||||||
|
selectedIds?: string[];
|
||||||
|
scale?: number;
|
||||||
|
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||||
|
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||||
|
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||||
|
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||||
|
const visibleElements = $derived(elements.filter((element) => !element.hidden));
|
||||||
|
|
||||||
|
// Keep the live muted property in sync, including live editor toggles.
|
||||||
|
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||||
|
node.muted = muted;
|
||||||
|
return {
|
||||||
|
update(value: boolean) {
|
||||||
|
node.muted = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElementOrigin = {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
ids: string[];
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
origins: ElementOrigin[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
|
||||||
|
|
||||||
|
type ResizeState = {
|
||||||
|
id: string;
|
||||||
|
direction: ResizeDirection;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
|
originWidth: number;
|
||||||
|
originHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarqueeState = {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
additive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
|
||||||
|
const SNAP_DISTANCE = 6;
|
||||||
|
|
||||||
|
let overlay = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
let drag = $state<DragState | null>(null);
|
||||||
|
let resize = $state<ResizeState | null>(null);
|
||||||
|
let marquee = $state<MarqueeState | null>(null);
|
||||||
|
let snapGuides = $state<{ x?: number; y?: number }>({});
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
const selectedSet = $derived(new Set(selectedIds));
|
||||||
|
|
||||||
|
const isSelected = (id: string) => interactive && selectedSet.has(id);
|
||||||
|
|
||||||
|
const modifierPressed = (event: PointerEvent | KeyboardEvent) =>
|
||||||
|
event.shiftKey || event.metaKey || event.ctrlKey;
|
||||||
|
|
||||||
|
function selectElement(id: string | undefined, additive = false) {
|
||||||
|
onselect?.(id, additive);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginDrag(event: PointerEvent, element: ThemeV2Element) {
|
||||||
|
if (!interactive || event.button !== 0) return;
|
||||||
|
if ((event.target as HTMLElement).dataset.resize) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
ids: origins.map((origin) => origin.id),
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
origins,
|
||||||
|
};
|
||||||
|
snapGuides = {};
|
||||||
|
window.addEventListener('pointermove', moveDrag);
|
||||||
|
window.addEventListener('pointerup', endDrag, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBounds(origins: ElementOrigin[]) {
|
||||||
|
const left = Math.min(...origins.map((origin) => origin.x));
|
||||||
|
const top = Math.min(...origins.map((origin) => origin.y));
|
||||||
|
const right = Math.max(...origins.map((origin) => origin.x + origin.width));
|
||||||
|
const bottom = Math.max(...origins.map((origin) => origin.y + origin.height));
|
||||||
|
return { left, top, right, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestSnap(
|
||||||
|
lines: number[],
|
||||||
|
candidates: number[]
|
||||||
|
): { delta: number; guide: number } | undefined {
|
||||||
|
let best: { delta: number; guide: number } | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const delta = candidate - line;
|
||||||
|
if (Math.abs(delta) > SNAP_DISTANCE) continue;
|
||||||
|
if (!best || Math.abs(delta) < Math.abs(best.delta)) {
|
||||||
|
best = { delta, guide: candidate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(event: PointerEvent) {
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
const bounds = groupBounds(drag.origins);
|
||||||
|
const rawDx = (event.clientX - drag.startX) / scale;
|
||||||
|
const rawDy = (event.clientY - drag.startY) / scale;
|
||||||
|
let dx = clamp(rawDx, -bounds.left, 1000 - bounds.right);
|
||||||
|
let dy = clamp(rawDy, -bounds.top, 650 - bounds.bottom);
|
||||||
|
|
||||||
|
if (!event.altKey) {
|
||||||
|
const others = visibleElements.filter((element) => !drag!.ids.includes(element.id));
|
||||||
|
const xCandidates = [
|
||||||
|
0,
|
||||||
|
500,
|
||||||
|
1000,
|
||||||
|
...others.flatMap((element) => [
|
||||||
|
element.x,
|
||||||
|
element.x + element.width / 2,
|
||||||
|
element.x + element.width,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const yCandidates = [
|
||||||
|
0,
|
||||||
|
325,
|
||||||
|
650,
|
||||||
|
...others.flatMap((element) => [
|
||||||
|
element.y,
|
||||||
|
element.y + element.height / 2,
|
||||||
|
element.y + element.height,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const xSnap = closestSnap(
|
||||||
|
[bounds.left + dx, bounds.centerX + dx, bounds.right + dx],
|
||||||
|
xCandidates
|
||||||
|
);
|
||||||
|
const ySnap = closestSnap(
|
||||||
|
[bounds.top + dy, bounds.centerY + dy, bounds.bottom + dy],
|
||||||
|
yCandidates
|
||||||
|
);
|
||||||
|
|
||||||
|
if (xSnap) dx += xSnap.delta;
|
||||||
|
if (ySnap) dy += ySnap.delta;
|
||||||
|
snapGuides = { x: xSnap?.guide, y: ySnap?.guide };
|
||||||
|
} else {
|
||||||
|
snapGuides = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
dx = clamp(dx, -bounds.left, 1000 - bounds.right);
|
||||||
|
dy = clamp(dy, -bounds.top, 650 - bounds.bottom);
|
||||||
|
|
||||||
|
onmove?.(
|
||||||
|
drag.origins.map((origin) => ({
|
||||||
|
id: origin.id,
|
||||||
|
x: Math.round(clamp(origin.x + dx, 0, 1000 - origin.width)),
|
||||||
|
y: Math.round(clamp(origin.y + dy, 0, 650 - origin.height)),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
drag = null;
|
||||||
|
snapGuides = {};
|
||||||
|
window.removeEventListener('pointermove', moveDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginResize(event: PointerEvent, element: ThemeV2Element, direction: ResizeDirection) {
|
||||||
|
if (!interactive || element.locked) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
resize = {
|
||||||
|
id: element.id,
|
||||||
|
direction,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
originX: element.x,
|
||||||
|
originY: element.y,
|
||||||
|
originWidth: element.width,
|
||||||
|
originHeight: element.height,
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', moveResize);
|
||||||
|
window.addEventListener('pointerup', endResize, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveResize(event: PointerEvent) {
|
||||||
|
if (!resize) return;
|
||||||
|
const dx = (event.clientX - resize.startX) / scale;
|
||||||
|
const dy = (event.clientY - resize.startY) / scale;
|
||||||
|
const west = resize.direction.includes('w');
|
||||||
|
const north = resize.direction.includes('n');
|
||||||
|
const width = Math.round(clamp(resize.originWidth + (west ? -dx : dx), 24, 1000));
|
||||||
|
const height = Math.round(clamp(resize.originHeight + (north ? -dy : dy), 24, 650));
|
||||||
|
onpatch?.(resize.id, {
|
||||||
|
x: west
|
||||||
|
? Math.round(clamp(resize.originX + resize.originWidth - width, 0, 1000 - width))
|
||||||
|
: resize.originX,
|
||||||
|
y: north
|
||||||
|
? Math.round(clamp(resize.originY + resize.originHeight - height, 0, 650 - height))
|
||||||
|
: resize.originY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize() {
|
||||||
|
resize = null;
|
||||||
|
window.removeEventListener('pointermove', moveResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginMarquee(event: PointerEvent) {
|
||||||
|
if (!interactive || event.button !== 0 || event.target !== event.currentTarget || !overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = overlay.getBoundingClientRect();
|
||||||
|
const startX = clamp((event.clientX - rect.left) / scale, 0, 1000);
|
||||||
|
const startY = clamp((event.clientY - rect.top) / scale, 0, 650);
|
||||||
|
const additive = modifierPressed(event);
|
||||||
|
|
||||||
|
if (!additive) onselectmany?.([], false);
|
||||||
|
|
||||||
|
marquee = { startX, startY, x: startX, y: startY, width: 0, height: 0, additive };
|
||||||
|
window.addEventListener('pointermove', moveMarquee);
|
||||||
|
window.addEventListener('pointerup', endMarquee, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveMarquee(event: PointerEvent) {
|
||||||
|
if (!marquee || !overlay) return;
|
||||||
|
const rect = overlay.getBoundingClientRect();
|
||||||
|
const currentX = clamp((event.clientX - rect.left) / scale, 0, 1000);
|
||||||
|
const currentY = clamp((event.clientY - rect.top) / scale, 0, 650);
|
||||||
|
|
||||||
|
marquee = {
|
||||||
|
...marquee,
|
||||||
|
x: Math.min(marquee.startX, currentX),
|
||||||
|
y: Math.min(marquee.startY, currentY),
|
||||||
|
width: Math.abs(currentX - marquee.startX),
|
||||||
|
height: Math.abs(currentY - marquee.startY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function endMarquee() {
|
||||||
|
if (!marquee) return;
|
||||||
|
const selection = marquee;
|
||||||
|
marquee = null;
|
||||||
|
window.removeEventListener('pointermove', moveMarquee);
|
||||||
|
|
||||||
|
if (selection.width < 3 && selection.height < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = visibleElements
|
||||||
|
.filter(
|
||||||
|
(element) =>
|
||||||
|
!element.locked &&
|
||||||
|
element.x < selection.x + selection.width &&
|
||||||
|
element.x + element.width > selection.x &&
|
||||||
|
element.y < selection.y + selection.height &&
|
||||||
|
element.y + element.height > selection.y
|
||||||
|
)
|
||||||
|
.map((element) => element.id);
|
||||||
|
|
||||||
|
onselectmany?.(hits, selection.additive);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={overlay}
|
||||||
|
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
|
||||||
|
role="presentation"
|
||||||
|
onpointerdown={beginMarquee}
|
||||||
|
oncontextmenu={(event) => {
|
||||||
|
if (!interactive || event.target !== event.currentTarget) return;
|
||||||
|
// The surrounding bits-ui ContextMenu.Trigger opens on the bubbling
|
||||||
|
// contextmenu event and prevents the native menu itself. Calling
|
||||||
|
// preventDefault() here makes the trigger treat the event as handled
|
||||||
|
// (it bails when defaultPrevented is set), leaving no menu at all.
|
||||||
|
oncontext?.(undefined, event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each visibleElements as element (element.id)}
|
||||||
|
{@const selected = isSelected(element.id)}
|
||||||
|
{@const singleSelected = selected && selectedIds.length === 1}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div
|
||||||
|
class="absolute select-none"
|
||||||
|
class:cursor-move={interactive && !element.locked}
|
||||||
|
class:cursor-default={interactive && element.locked}
|
||||||
|
style="left:{element.x}px;top:{element.y}px;width:{element.width}px;height:{element.height}px;transform:rotate({element.rotation ??
|
||||||
|
0}deg);touch-action:none;outline:{selected
|
||||||
|
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
|
||||||
|
: 'none'};outline-offset:2px;"
|
||||||
|
role={interactive ? 'button' : undefined}
|
||||||
|
tabindex={interactive ? 0 : -1}
|
||||||
|
aria-label={interactive ? `Select ${element.type} layer` : undefined}
|
||||||
|
onpointerdown={(event) => beginDrag(event, element)}
|
||||||
|
oncontextmenu={(event) => {
|
||||||
|
if (!interactive) return;
|
||||||
|
// Let the event bubble so the editor's ContextMenu.Trigger can open.
|
||||||
|
oncontext?.(element.id, event);
|
||||||
|
}}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectElement(element.id, modifierPressed(event));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- Opacity lives on the content wrapper so selection chrome stays fully opaque. -->
|
||||||
|
<div class="h-full w-full" style="opacity:{element.opacity ?? 1};">
|
||||||
|
{#if element.type === 'text'}
|
||||||
|
<div
|
||||||
|
class="h-full w-full overflow-hidden"
|
||||||
|
style="display:flex;align-items:{element.verticalAlign === 'center'
|
||||||
|
? 'center'
|
||||||
|
: element.verticalAlign === 'bottom'
|
||||||
|
? 'flex-end'
|
||||||
|
: 'flex-start'};justify-content:{element.align === 'center'
|
||||||
|
? 'center'
|
||||||
|
: element.align === 'right'
|
||||||
|
? 'flex-end'
|
||||||
|
: 'flex-start'};text-align:{element.align ?? 'left'};"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
|
28}px;font-weight:{element.fontWeight ?? 600};color:{element.color ??
|
||||||
|
'#ffffff'};line-height:{element.lineHeight ??
|
||||||
|
1.2};letter-spacing:{element.letterSpacing ??
|
||||||
|
0}px;white-space:pre-wrap;overflow-wrap:anywhere;"
|
||||||
|
>
|
||||||
|
{element.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else if element.type === 'image'}
|
||||||
|
<img
|
||||||
|
class="pointer-events-none h-full w-full"
|
||||||
|
src={assetUrl(element.src)}
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
style="object-fit:{element.fit ?? 'cover'};border-radius:{element.radius ?? 0}px;"
|
||||||
|
/>
|
||||||
|
{:else if element.type === 'video'}
|
||||||
|
<video
|
||||||
|
class="pointer-events-none h-full w-full"
|
||||||
|
use:themeMediaSource={assetUrl(element.src)}
|
||||||
|
use:themeVideoLifecycle
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted={element.muted !== false}
|
||||||
|
use:applyMuted={element.muted !== false}
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
aria-hidden="true"
|
||||||
|
style="object-fit:{element.fit ?? 'cover'};border-radius:{element.radius ?? 0}px;"
|
||||||
|
></video>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-full w-full"
|
||||||
|
style="background:{element.fill ?? '#ff006e'};border-radius:{element.shape === 'ellipse'
|
||||||
|
? '50%'
|
||||||
|
: `${element.radius ?? 16}px`};border:{element.borderWidth
|
||||||
|
? `${element.borderWidth}px solid ${element.borderColor ?? '#ffffff'}`
|
||||||
|
: 'none'};"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if singleSelected && !element.locked}
|
||||||
|
{#each resizeDirections as direction (direction)}
|
||||||
|
<span
|
||||||
|
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
|
||||||
|
data-resize={direction}
|
||||||
|
style="{direction.includes('n') ? 'top:-7px' : 'bottom:-7px'};{direction.includes('w')
|
||||||
|
? 'left:-7px'
|
||||||
|
: 'right:-7px'};cursor:{direction === 'nw' || direction === 'se'
|
||||||
|
? 'nwse-resize'
|
||||||
|
: 'nesw-resize'};"
|
||||||
|
onpointerdown={(event) => beginResize(event, element, direction)}
|
||||||
|
role="presentation"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
<span
|
||||||
|
class="absolute -top-7 left-0 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground shadow"
|
||||||
|
>
|
||||||
|
{element.type}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selected && selectedIds.length > 1}
|
||||||
|
<span
|
||||||
|
class="absolute -top-5 left-0 rounded bg-primary/90 px-1 py-0.5 text-[8px] font-semibold text-primary-foreground"
|
||||||
|
>
|
||||||
|
selected
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if snapGuides.x !== undefined}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 z-30 w-px bg-primary/80"
|
||||||
|
style="left:{snapGuides.x}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if snapGuides.y !== undefined}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 z-30 h-px bg-primary/80"
|
||||||
|
style="top:{snapGuides.y}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if marquee}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute z-30 border border-primary bg-primary/10"
|
||||||
|
style="left:{marquee.x}px;top:{marquee.y}px;width:{marquee.width}px;height:{marquee.height}px;"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
const prop: { beatmapId: number } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="absolute w-screen h-screen overflow-hidden pointer-events-none rounded">
|
|
||||||
<div
|
|
||||||
style="background: url(https://osu.direct/api/media/background/{prop.beatmapId})"
|
|
||||||
class="absolute top-0 left-0 w-full h-full !bg-cover -z-10 pointer-events-none blur opacity-10 rounded"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
@@ -53,6 +53,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
|
<!-- href is a pass-through prop and may point to an external URL -->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
|
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { cn } from '$lib/utils';
|
|
||||||
import { Heart } from '@lucide/svelte';
|
|
||||||
|
|
||||||
interface Sparkle {
|
|
||||||
id: string;
|
|
||||||
x: string;
|
|
||||||
y: string;
|
|
||||||
color: string;
|
|
||||||
delay: number;
|
|
||||||
scale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
className,
|
|
||||||
colors = {
|
|
||||||
first: '#9E7AFF',
|
|
||||||
second: '#FE8BBB',
|
|
||||||
},
|
|
||||||
count = 10,
|
|
||||||
}: {
|
|
||||||
className: string;
|
|
||||||
colors?: { first: string; second: string };
|
|
||||||
count?: number;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Helper to check if two sparkles are too close
|
|
||||||
function isTooClose(x1: number, y1: number, x2: number, y2: number, minDist: number) {
|
|
||||||
const dx = x1 - x2;
|
|
||||||
const dy = y1 - y2;
|
|
||||||
return Math.sqrt(dx * dx + dy * dy) < minDist;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate sparkles with minimum distance between them
|
|
||||||
let sparkles: Sparkle[] = [];
|
|
||||||
const minDistance = 25; // in percentage points (adjust as needed)
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
while (sparkles.length < count && attempts < count * 20) {
|
|
||||||
attempts++;
|
|
||||||
let starX = Math.random() * 100;
|
|
||||||
let starY = Math.random() * 100;
|
|
||||||
let tooClose = sparkles.some((s) =>
|
|
||||||
isTooClose(Number.parseFloat(s.x), Number.parseFloat(s.y), starX, starY, minDistance)
|
|
||||||
);
|
|
||||||
if (tooClose) continue;
|
|
||||||
|
|
||||||
let color = Math.random() > 0.5 ? colors.first : colors.second;
|
|
||||||
let delay = Math.random() * 3;
|
|
||||||
let scale = Math.random() * 1 + 0.3;
|
|
||||||
let id = `${starX}-${starY}-${Date.now()}-${Math.random()}`;
|
|
||||||
sparkles.push({
|
|
||||||
id,
|
|
||||||
x: `${starX}%`,
|
|
||||||
y: `${starY}%`,
|
|
||||||
color,
|
|
||||||
delay,
|
|
||||||
scale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class={cn('relative', className)}>
|
|
||||||
<span class={cn('relative inline-block', className)}>
|
|
||||||
{#each sparkles as item (item.id)}
|
|
||||||
<div
|
|
||||||
class="sparkle pointer-events-none absolute z-20 top-0 left-0"
|
|
||||||
id={item.id}
|
|
||||||
style="left: {item.x}; top: {item.y}; color: {item.color}; animation-delay: {item.delay}s; transform: scale({item.scale});"
|
|
||||||
>
|
|
||||||
<!-- <svg width="13" height="13" viewBox="0 0 21 21">
|
|
||||||
<path
|
|
||||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
|
||||||
fill={item.color}
|
|
||||||
/>
|
|
||||||
</svg> -->
|
|
||||||
<Heart fill={item.color} size={14} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
.sparkle {
|
|
||||||
animation: sparkle-fade 1.2s linear infinite;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sparkle-fade {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0) rotate(-50deg);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0) rotate(50deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(0),
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
disabled = false,
|
||||||
|
class: className,
|
||||||
|
onValueChange,
|
||||||
|
onValueCommit,
|
||||||
|
}: {
|
||||||
|
ref?: HTMLSpanElement | null;
|
||||||
|
value?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
onValueChange?: (value: number) => void;
|
||||||
|
onValueCommit?: (value: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
type="single"
|
||||||
|
bind:value
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
{disabled}
|
||||||
|
{onValueChange}
|
||||||
|
{onValueCommit}
|
||||||
|
class={cn(
|
||||||
|
'bg-primary/20 relative flex h-1.5 w-full touch-none select-none items-center rounded-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range class="bg-primary absolute h-full rounded-full" />
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
index={0}
|
||||||
|
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-4 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Root>
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as Toaster } from './sonner.svelte';
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { mode } from 'mode-watcher';
|
|
||||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
|
|
||||||
|
|
||||||
let { ...restProps }: SonnerProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Sonner
|
|
||||||
theme={mode.current}
|
|
||||||
class="toaster group !z-[1000]"
|
|
||||||
toastOptions={{
|
|
||||||
classes: {
|
|
||||||
toast:
|
|
||||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
|
||||||
description: 'group-[.toast]:text-muted-foreground',
|
|
||||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
|
||||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import Root from './switch.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Switch,
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { cn } from '$lib/utils.js';
|
|
||||||
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
checked = $bindable(false),
|
|
||||||
...restProps
|
|
||||||
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SwitchPrimitive.Root
|
|
||||||
bind:ref
|
|
||||||
bind:checked
|
|
||||||
class={cn(
|
|
||||||
'focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
<SwitchPrimitive.Thumb
|
|
||||||
class={cn(
|
|
||||||
'bg-background pointer-events-none block size-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitive.Root>
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import type { WithElementRef } from 'bits-ui';
|
||||||
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLTextareaAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-20 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
></textarea>
|
||||||