chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard

This commit is contained in:
HorizonCode committed 2026-10-29 10:53:12 +01:00
1 parent 417f54a94a
commit 87202de656
97 files changed
+10683 -1137

No files matched your search

+58 -7
View File
@@ -14,28 +14,60 @@ 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, fonts, live launcher preview and export
--- ---
## 💻 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!, so that prefix
> needs a .NET runtime installed.
### 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.
---
## 🪵 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
or open the folder from **Settings → Logging**.
On Windows the folder is `%USERPROFILE%\.ezpplauncher\logs`.
--- ---
@@ -47,6 +79,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 +94,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
``` ```
--- ---
@@ -71,7 +109,9 @@ src-tauri/target/release/ezpplauncher.exe
``` ```
├── src/ # Frontend (Svelte) ├── src/ # Frontend (Svelte)
├── src-tauri/ # Backend (Rust) ├── src-tauri/ # Backend (Rust)
│ ├── src/macos.rs # Wine/Wineskin support for macOS
│ ├── tauri.conf.json # Tauri configuration │ ├── tauri.conf.json # Tauri configuration
│ └── tauri.macos.conf.json # macOS bundle targets (app + dmg)
├── package.json # Bun project config ├── package.json # Bun project config
└── README.md └── README.md
``` ```
@@ -80,4 +120,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.
+424
View File
@@ -0,0 +1,424 @@
# 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 |
| `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` |
#### 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.
5. Use the **Theme** tab for metadata, background, effects, typography and custom
CSS. Open the **Fonts** tab to search and download from every Fontsource
family, or import WOFF2, WOFF, TTF and OTF files. Downloaded fonts are copied
into the theme's `assets/` folder when you save.
6. Select **Save theme** to write `theme.json` and copy new assets into
`~/.ezpplauncher/themes`. A fresh `assets/preview.png` thumbnail is rendered
from the theme and the launcher applies the saved theme immediately.
7. Select **Export** to create a shareable `.ezpplauncher-theme` package. Export
saves and refreshes the preview first, so the package always includes the
current thumbnail.
Keyboard shortcuts:
- `Cmd/Ctrl+Z` undoes the last change.
- `Cmd/Ctrl+Shift+Z` or `Ctrl+Y` redoes it.
- `Cmd/Ctrl+S` saves.
- `Cmd/Ctrl+D` duplicates the selected layer.
- Arrow keys nudge the selected layer by 1 px; hold Shift for 10 px.
- `Delete` removes the selected layer.
- `Esc` deselects the layer.
---
## 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.
+17 -52
View File
@@ -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 -1
View File
@@ -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": {
+4
View File
@@ -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,
+5 -6
View File
@@ -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,24 +58,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",
+84
View File
@@ -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",
+13
View File
@@ -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(target_os = "linux")'.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"
+3 -2
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+626 -141
View File
@@ -5,26 +5,36 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashSet; use std::collections::HashSet;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::{Path, PathBuf};
#[cfg(not(target_os = "macos"))]
use std::process::Stdio;
use sysinfo::System; use sysinfo::System;
use tauri::AppHandle; use tauri::AppHandle;
use tauri::Emitter; use tauri::Emitter;
use tauri::Manager; use tauri::Manager;
use tauri::State; use tauri::State;
use tauri::WebviewUrl;
use tauri::WebviewWindowBuilder;
use tauri_plugin_fs::FsExt; use tauri_plugin_fs::FsExt;
use tokio::fs; use tokio::fs;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
#[cfg(not(target_os = "macos"))]
use tokio::process::Command; use tokio::process::Command;
#[cfg(not(target_os = "macos"))]
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
use crate::logging;
use crate::osudb::parse_osudb; use crate::osudb::parse_osudb;
use crate::presence; use crate::presence;
use crate::state::AppState; use crate::state::AppState;
#[cfg(all(not(windows), not(target_os = "macos")))]
use crate::utils::get_osuwinello_path;
use crate::utils::{ use crate::utils::{
check_folder_completeness, encrypt_password, get_osu_config, get_osu_user_config, encrypt_password, get_osu_config, get_osu_user_config, is_net8_installed, is_osu_process,
get_osuwinello_path, get_window_title_by_pid, is_net8_installed, is_osuwinello_available, is_osuwinello_available, is_wmctrl_available, set_osu_config_vals, set_osu_user_config_vals,
is_wmctrl_available, set_osu_config_vals, set_osu_user_config_vals,
}; };
#[cfg(not(target_os = "macos"))]
use crate::utils::{get_window_title_by_pid, is_osu_game_process, is_valid_osu_folder};
#[tauri::command] #[tauri::command]
pub fn opened_urls(app: tauri::AppHandle) -> Vec<PathBuf> { pub fn opened_urls(app: tauri::AppHandle) -> Vec<PathBuf> {
@@ -39,6 +49,34 @@ pub fn get_launcher_version(app: tauri::AppHandle) -> String {
app.package_info().version.to_string() app.package_info().version.to_string()
} }
/// Forward a webview console message to the launcher log file.
#[tauri::command]
pub fn log_frontend(level: String, message: String) {
let message: String = message.chars().take(16_000).collect();
match level.as_str() {
"error" => log::error!(target: "launcher", "[webview] {message}"),
"warn" => log::warn!(target: "launcher", "[webview] {message}"),
"debug" => log::debug!(target: "launcher", "[webview] {message}"),
"trace" => log::trace!(target: "launcher", "[webview] {message}"),
_ => log::info!(target: "launcher", "[webview] {message}"),
}
}
#[tauri::command]
pub fn set_logging_enabled(enabled: bool) {
logging::set_logging_enabled(enabled);
}
#[tauri::command]
pub fn open_logs_folder() -> Result<(), String> {
let dir = logging::logs_directory();
std::fs::create_dir_all(&dir)
.map_err(|error| format!("Failed to create the logs folder: {error}"))?;
open::that(&dir).map_err(|error| format!("Failed to open the logs folder: {error}"))
}
#[tauri::command] #[tauri::command]
pub fn get_hwid() -> String { pub fn get_hwid() -> String {
get_id() get_id()
@@ -46,43 +84,64 @@ pub fn get_hwid() -> String {
.into() .into()
} }
/// Show the native macOS picker, which can select `osu!.app` bundles as files.
/// The regular Tauri dialog opens app bundles as folders on macOS.
#[tauri::command]
pub async fn pick_osu_installation(app: AppHandle) -> Result<Option<String>, String> {
#[cfg(target_os = "macos")]
{
let (tx, rx) = std::sync::mpsc::channel();
app.run_on_main_thread(move || {
let _ = tx.send(crate::macos::pick_osu_installation());
})
.map_err(|error| format!("Could not open the osu! picker: {error}"))?;
let picked = tauri::async_runtime::spawn_blocking(move || rx.recv())
.await
.map_err(|error| format!("Could not open the osu! picker: {error}"))?;
picked
.map_err(|_| "The osu! picker closed unexpectedly".to_string())?
.map(|path| path.map(|path| path.to_string_lossy().into_owned()))
}
#[cfg(not(target_os = "macos"))]
{
let _ = app;
Ok(None)
}
}
#[tauri::command(rename_all = "snake_case")] #[tauri::command(rename_all = "snake_case")]
pub fn valid_osu_folder(folder: String) -> bool { pub fn valid_osu_folder(folder: String) -> bool {
let osu_folder_files = [ normalized_osu_folder(&folder).is_some()
"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",
];
/// Resolve a selected path (osu! folder, `.app` wrapper, Wine prefix or drive_c)
/// to the actual osu! installation folder. Returns `None` if it is not valid.
#[tauri::command(rename_all = "snake_case")]
pub fn resolve_osu_folder(folder: String) -> Option<String> {
normalized_osu_folder(&folder).map(|path| path.to_string_lossy().into_owned())
}
#[cfg(target_os = "macos")]
fn normalized_osu_folder(folder: &str) -> Option<PathBuf> {
crate::macos::resolve_osu_folder(Path::new(folder))
}
#[cfg(not(target_os = "macos"))]
fn normalized_osu_folder(folder: &str) -> Option<PathBuf> {
let path = PathBuf::from(folder); let path = PathBuf::from(folder);
if !path.join("osu!.exe").exists() { is_valid_osu_folder(&path).then_some(path)
return false;
}
check_folder_completeness(path, &osu_folder_files) >= 70.0
} }
#[tauri::command] #[tauri::command]
pub fn find_osu_installation() -> Option<String> { pub fn find_osu_installation() -> Option<String> {
#[cfg(windows)] find_osu_installation_impl()
{ }
#[cfg(windows)]
fn find_osu_installation_impl() -> Option<String> {
use winreg::RegKey; use winreg::RegKey;
use winreg::enums::*; use winreg::enums::*;
@@ -94,30 +153,6 @@ pub fn find_osu_installation() -> Option<String> {
"Software\\Classes\\osustable.File.osz\\DefaultIcon", "Software\\Classes\\osustable.File.osz\\DefaultIcon",
]; ];
let osu_folder_files = [
"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",
];
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
for reg_path in &hklm_registry_paths { for reg_path in &hklm_registry_paths {
@@ -129,10 +164,7 @@ pub fn find_osu_installation() -> Option<String> {
let stripped = trimmed.strip_suffix(",0").unwrap_or(trimmed); let stripped = trimmed.strip_suffix(",0").unwrap_or(trimmed);
let path = PathBuf::from(stripped.trim()); let path = PathBuf::from(stripped.trim());
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let match_percentage = if is_valid_osu_folder(parent) {
check_folder_completeness(parent, &osu_folder_files);
if match_percentage >= 70.0 {
return Some(parent.to_string_lossy().into()); return Some(parent.to_string_lossy().into());
} }
} }
@@ -152,10 +184,7 @@ pub fn find_osu_installation() -> Option<String> {
let stripped = trimmed.strip_suffix(",1").unwrap_or(trimmed); let stripped = trimmed.strip_suffix(",1").unwrap_or(trimmed);
let path = PathBuf::from(stripped.trim()); let path = PathBuf::from(stripped.trim());
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let match_percentage = if is_valid_osu_folder(parent) {
check_folder_completeness(parent, &osu_folder_files);
if match_percentage >= 70.0 {
return Some(parent.to_string_lossy().into()); return Some(parent.to_string_lossy().into());
} }
} }
@@ -163,7 +192,17 @@ pub fn find_osu_installation() -> Option<String> {
} }
} }
} }
}
None
}
#[cfg(target_os = "macos")]
fn find_osu_installation_impl() -> Option<String> {
crate::macos::find_osu_installation()
}
#[cfg(all(not(windows), not(target_os = "macos")))]
fn find_osu_installation_impl() -> Option<String> {
None None
} }
@@ -275,6 +314,13 @@ pub fn set_osu_config_values(
#[tauri::command] #[tauri::command]
pub async fn run_osu_updater(folder: String) -> Result<(), String> { pub async fn run_osu_updater(folder: String) -> Result<(), String> {
log::info!(target: "osu", "Starting the osu! updater for {folder}");
#[cfg(target_os = "macos")]
crate::macos::run_osu_updater(&folder).await?;
#[cfg(not(target_os = "macos"))]
{
let mut updater_process = { let mut updater_process = {
#[cfg(windows)] #[cfg(windows)]
{ {
@@ -283,9 +329,15 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> {
let osu_exe_path = PathBuf::from(&folder).join("osu!.exe"); let osu_exe_path = PathBuf::from(&folder).join("osu!.exe");
Command::new(&osu_exe_path) Command::new(&osu_exe_path)
.arg("-repair") .arg("-repair")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn updater: {}", e))? .map_err(|e| {
log::error!(target: "osu", "Failed to spawn the osu! updater: {e}");
format!("Failed to spawn updater: {}", e)
})?
} }
#[cfg(not(windows))] #[cfg(not(windows))]
@@ -293,11 +345,22 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> {
let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found"); let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found");
Command::new(&osu_wine_path) Command::new(&osu_wine_path)
.arg("-repair") .arg("-repair")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn updater: {}", e))? .map_err(|e| {
log::error!(target: "osu", "Failed to spawn the osu! updater: {e}");
format!("Failed to spawn updater: {}", e)
})?
} }
}; };
if let Some(pid) = updater_process.id() {
log::info!(target: "osu", "osu! updater started (pid {pid})");
}
crate::logging::capture_osu_output(&mut updater_process);
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
let mut sys = System::new_all(); let mut sys = System::new_all();
@@ -308,7 +371,7 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> {
let mut found = false; let mut found = false;
for (_pid, process) in sys.processes() { for (_pid, process) in sys.processes() {
if process.name() == "osu!.exe" { if is_osu_game_process(process) {
let pid = process.pid(); let pid = process.pid();
let title = get_window_title_by_pid(pid); let title = get_window_title_by_pid(pid);
@@ -327,7 +390,11 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> {
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
} }
let _ = updater_process.wait().await; match updater_process.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}"),
}
}
let force_update_files = [".require_update", "help.txt", "_pending"]; let force_update_files = [".require_update", "help.txt", "_pending"];
for update_file_name in &force_update_files { for update_file_name in &force_update_files {
@@ -342,11 +409,11 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> {
}; };
if let Err(e) = res { if let Err(e) = res {
eprintln!("Failed to remove {:?}: {}", path, e); log::warn!(target: "osu", "Failed to remove {:?}: {}", path, e);
} }
} }
Err(e) => { Err(e) => {
eprintln!("Could not stat {:?}: {}", path, e); log::warn!(target: "osu", "Could not stat {:?}: {}", path, e);
} }
} }
} }
@@ -357,6 +424,15 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> { pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
log::info!(target: "osu", "Launching osu! from {folder} (patch={patch})");
#[cfg(target_os = "macos")]
{
return crate::macos::run_osu(&folder, patch).await;
}
#[cfg(not(target_os = "macos"))]
{
#[cfg(windows)] #[cfg(windows)]
const DETACHED_PROCESS: u32 = 0x00000008; const DETACHED_PROCESS: u32 = 0x00000008;
#[cfg(windows)] #[cfg(windows)]
@@ -369,9 +445,15 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
Command::new(&osu_exe_path) Command::new(&osu_exe_path)
.arg("-devserver") .arg("-devserver")
.arg("ez-pp.farm") .arg("ez-pp.farm")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn osu: {}", e))? .map_err(|e| {
log::error!(target: "osu", "Failed to spawn osu!: {e}");
format!("Failed to spawn osu: {}", e)
})?
} }
#[cfg(not(windows))] #[cfg(not(windows))]
@@ -380,11 +462,22 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
Command::new(&osu_wine_path) Command::new(&osu_wine_path)
.arg("--devserver") .arg("--devserver")
.arg("ez-pp.farm") .arg("ez-pp.farm")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn osu: {}", e))? .map_err(|e| {
log::error!(target: "osu", "Failed to spawn osu!: {e}");
format!("Failed to spawn osu: {}", e)
})?
} }
}; };
if let Some(pid) = game_process.id() {
log::info!(target: "osu", "osu! started (pid {pid})");
}
crate::logging::capture_osu_output(&mut game_process);
if patch { if patch {
let patcher_exe_path = PathBuf::from(&folder) let patcher_exe_path = PathBuf::from(&folder)
.join("EZPPLauncher") .join("EZPPLauncher")
@@ -417,87 +510,61 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
} }
log::info!(target: "patcher", "Starting the patcher from {}", patcher_exe_path.display());
#[cfg(windows)] #[cfg(windows)]
{ let mut patcher_process = Command::new(&patcher_exe_path)
let _ = Command::new(&patcher_exe_path) .stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn() .spawn()
.map_err(|e| format!("Failed to run patcher: {e}"))?; .map_err(|e| {
} log::error!(target: "patcher", "Failed to start the patcher: {e}");
format!("Failed to run patcher: {e}")
})?;
#[cfg(not(windows))] #[cfg(not(windows))]
{ let mut patcher_process = {
use crate::utils::get_osuwinello_path; use crate::utils::get_osuwinello_path;
let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found"); let osu_wine_path = get_osuwinello_path().expect("osu-wine was not found");
let _ = Command::new(&osu_wine_path) Command::new(&osu_wine_path)
.arg("--wine") .arg("--wine")
.arg(&patcher_exe_path) .arg(&patcher_exe_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| format!("Failed to run patcher: {}", e))?; .map_err(|e| {
log::error!(target: "patcher", "Failed to start the patcher: {e}");
format!("Failed to run patcher: {}", e)
})?
};
if let Some(pid) = patcher_process.id() {
log::info!(target: "patcher", "Patcher started (pid {pid})");
} }
} crate::logging::capture_patcher_output(&mut patcher_process);
} } else {
log::warn!(
//TODO: maybe implement tosu? target: "patcher",
/* let tosu_exe_path = PathBuf::from(&folder) "Patcher was requested but {} does not exist",
.join("EZPPLauncher") patcher_exe_path.display()
.join("tosu")
.join("tosu.exe");
let mut _tosu_process: Option<_> = None;
if tosu_exe_path.exists() {
let mut sys = System::new_all();
loop {
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let mut found = false;
for (_pid, process) in sys.processes() {
if process.name() == "osu!.exe" {
let pid = process.pid();
let title = get_window_title_by_pid(pid);
if !title.is_empty() && !title.contains("updater") {
found = true;
break;
}
}
}
if found {
break;
}
sleep(Duration::from_millis(500)).await;
}
#[cfg(windows)]
{
_tosu_process = Some(
Command::new(&tosu_exe_path)
/* .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) */
.spawn()
.map_err(|e| format!("Failed to run tosu: {e}"))?,
); );
} }
/* #[cfg(not(windows))] }
{
let _ = Command::new("osu-wine")
.arg("--wine")
.arg(&tosu_exe_path)
.spawn()
.map_err(|e| format!("Failed to run tosu: {}", e))?;
} */
} */
game_process.wait().await.map_err(|e| e.to_string())?; match game_process.wait().await {
Ok(status) => log::info!(target: "osu", "osu! exited with {status}"),
/* if let Some(mut tosu_process) = _tosu_process { Err(error) => {
tosu_process.kill().await.map_err(|e| e.to_string())?; log::warn!(target: "osu", "Could not wait for osu!: {error}");
} */ return Err(error.to_string());
}
}
Ok(()) Ok(())
}
} }
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
@@ -813,7 +880,7 @@ pub fn is_osu_running() -> bool {
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
for process in sys.processes().values() { for process in sys.processes().values() {
if process.name().eq_ignore_ascii_case("osu!.exe") { if is_osu_process(process) {
return true; return true;
} }
} }
@@ -960,6 +1027,9 @@ pub async fn download_ezpp_launcher_update(app: AppHandle, url: String) -> Resul
#[tauri::command] #[tauri::command]
pub async fn install_ezpp_launcher_update(app: AppHandle) -> Result<(), String> { pub async fn install_ezpp_launcher_update(app: AppHandle) -> Result<(), String> {
#[cfg(not(windows))]
let _ = app;
#[cfg(windows)] #[cfg(windows)]
{ {
let temp_dir = app.path().temp_dir().expect("Failed to get temp directory"); let temp_dir = app.path().temp_dir().expect("Failed to get temp directory");
@@ -1049,6 +1119,9 @@ pub fn is_open_tablet_driver_running() -> bool {
#[tauri::command] #[tauri::command]
pub async fn is_open_tablet_driver_path_valid(app: AppHandle, path: String) -> bool { pub async fn is_open_tablet_driver_path_valid(app: AppHandle, path: String) -> bool {
#[cfg(not(windows))]
let _ = (&app, &path);
#[cfg(windows)] #[cfg(windows)]
{ {
app.fs_scope().allow_file(&path).ok(); app.fs_scope().allow_file(&path).ok();
@@ -1285,10 +1358,24 @@ pub async fn extract_theme(
Ok(()) Ok(())
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, Default)]
pub struct ThemeInfo { pub struct ThemeInfo {
pub name: String, pub name: String,
#[serde(default)]
pub version: String,
#[serde(rename = "apiVersion", default)]
pub api_version: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub nsfw: bool,
#[serde(default)]
pub entry: String, pub entry: String,
#[serde(default)]
pub style: String,
#[serde(default)]
pub preview: String, pub preview: String,
} }
@@ -1318,6 +1405,404 @@ pub async fn read_theme_info(app: AppHandle, file_path: String) -> Result<ThemeI
.map_err(|e| format!("Thread error: {}", e))? .map_err(|e| format!("Thread error: {}", e))?
} }
pub(crate) fn theme_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("themes");
std::fs::create_dir_all(&root)
.map_err(|e| format!("Failed to create the themes directory: {e}"))?;
std::fs::canonicalize(&root).map_err(|e| format!("Failed to resolve the themes directory: {e}"))
}
#[cfg(target_os = "linux")]
fn linux_theme_asset_base() -> String {
if crate::theme_server::base_url().is_none() {
if let Ok(root) = theme_root() {
if let Err(error) = crate::theme_server::start(root) {
log::warn!(target: "launcher", "Could not start the theme asset server: {error}");
}
}
}
crate::theme_server::base_url().unwrap_or_default()
}
#[tauri::command]
pub fn theme_asset_base() -> String {
#[cfg(target_os = "linux")]
{
return linux_theme_asset_base();
}
#[cfg(not(target_os = "linux"))]
{
String::new()
}
}
#[tauri::command]
pub fn theme_preview_url(path: String) -> Result<String, String> {
#[cfg(target_os = "linux")]
{
let _ = linux_theme_asset_base();
return crate::theme_server::preview_url(Path::new(&path));
}
#[cfg(not(target_os = "linux"))]
{
let _ = path;
Ok(String::new())
}
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThemeEditorContext {
pub folder_name: Option<String>,
pub folder_path: Option<String>,
pub theme_json: Option<String>,
}
/// Open (or focus) the advanced theme editor in its own native window.
#[tauri::command]
pub async fn open_theme_editor(app: AppHandle, theme_folder: Option<String>) -> Result<(), String> {
let folder = match theme_folder.filter(|value| !value.trim().is_empty()) {
Some(folder) => {
let checked = checked_theme_folder(&folder)?;
if !checked.join("theme.json").is_file() {
return Err("That theme does not contain a theme.json".to_string());
}
Some(checked.to_string_lossy().into_owned())
}
None => None,
};
*app.state::<AppState>().editor_theme_folder.lock().unwrap() = folder;
if let Some(window) = app.get_webview_window("theme-editor") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
app.emit_to("theme-editor", "theme-editor-context", ())
.map_err(|error| error.to_string())?;
return Ok(());
}
WebviewWindowBuilder::new(&app, "theme-editor", WebviewUrl::App("index.html".into()))
.title("EZPPLauncher Theme Editor")
.inner_size(1440.0, 900.0)
.min_inner_size(1100.0, 720.0)
.resizable(true)
.center()
.build()
.map_err(|error| format!("Could not open the theme editor: {error}"))?;
Ok(())
}
/// Theme data requested by the advanced editor window when it starts or switches themes.
#[tauri::command]
pub fn theme_editor_context(app: AppHandle) -> Result<ThemeEditorContext, String> {
let folder = app
.state::<AppState>()
.editor_theme_folder
.lock()
.unwrap()
.clone();
let Some(folder) = folder else {
return Ok(ThemeEditorContext {
folder_name: None,
folder_path: None,
theme_json: None,
});
};
let path = PathBuf::from(&folder);
let folder_name = path
.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned);
let theme_json = std::fs::read_to_string(path.join("theme.json")).ok();
Ok(ThemeEditorContext {
folder_name,
folder_path: Some(folder),
theme_json,
})
}
/// Only allow writing inside `~/.ezpplauncher/themes`.
fn checked_theme_folder(theme_folder: &str) -> Result<PathBuf, String> {
let root = theme_root()?;
let requested = PathBuf::from(theme_folder);
if !requested.is_absolute() {
return Err("The theme folder must be an absolute path".to_string());
}
let folder = if requested.exists() {
std::fs::canonicalize(&requested)
.map_err(|e| format!("Failed to resolve the theme folder: {e}"))?
} else {
let parent = requested
.parent()
.ok_or_else(|| "The theme folder has no parent directory".to_string())?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create the theme directory: {e}"))?;
let parent = std::fs::canonicalize(parent)
.map_err(|e| format!("Failed to resolve the theme directory: {e}"))?;
let name = requested
.file_name()
.ok_or_else(|| "The theme folder has no name".to_string())?;
parent.join(name)
};
if folder == root || !folder.starts_with(&root) {
return Err("The theme folder is outside the themes directory".to_string());
}
Ok(folder)
}
#[derive(Deserialize)]
pub struct ThemeAssetInput {
pub name: String,
pub source: String,
}
/// Write a theme created or edited by the in-app theme editor.
#[tauri::command]
pub async fn save_theme_project(
theme_folder: String,
config: String,
assets: Vec<ThemeAssetInput>,
) -> Result<(), String> {
let folder = checked_theme_folder(&theme_folder)?;
let parsed: Value = serde_json::from_str(&config)
.map_err(|e| format!("The theme config is not valid JSON: {e}"))?;
if parsed.get("apiVersion").and_then(Value::as_str) != Some("2.0") {
return Err("The theme editor can only save API 2.0 themes".to_string());
}
if parsed
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.is_none()
{
return Err("The theme needs a name".to_string());
}
let assets_folder = folder.join("assets");
tokio::fs::create_dir_all(&assets_folder)
.await
.map_err(|e| format!("Failed to create the assets folder: {e}"))?;
for asset in assets {
let name = asset.name.trim();
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
{
return Err(format!("Invalid theme asset name: {name}"));
}
let source = PathBuf::from(&asset.source);
if !source.is_file() {
return Err(format!("Theme asset not found: {}", source.display()));
}
let destination = assets_folder.join(name);
if source.canonicalize().ok() == destination.canonicalize().ok() {
continue;
}
tokio::fs::copy(&source, &destination)
.await
.map_err(|e| format!("Failed to copy theme asset {name}: {e}"))?;
}
tokio::fs::write(folder.join("theme.json"), config)
.await
.map_err(|e| format!("Failed to write theme.json: {e}"))?;
Ok(())
}
/// Resolve a `theme.json` asset reference to a path relative to `assets/`.
/// Remote URLs, data URLs and paths that could escape the assets folder are
/// ignored.
fn theme_asset_reference(src: &str) -> Option<String> {
let value = src.trim();
if value.is_empty()
|| value.contains("://")
|| value.starts_with('/')
|| value.starts_with("data:")
|| value.starts_with("blob:")
|| value.starts_with("asset:")
{
return None;
}
let normalized = value.replace('\\', "/");
let normalized = normalized.trim_start_matches("./");
if normalized.is_empty()
|| normalized
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return None;
}
Some(normalized.to_string())
}
fn collect_theme_asset_refs(value: &Value, assets: &mut HashSet<String>) {
match value {
Value::Object(object) => {
for (key, value) in object {
if key == "src" || key == "preview" {
if let Some(src) = value.as_str() {
if let Some(path) = theme_asset_reference(src) {
assets.insert(path);
}
}
}
collect_theme_asset_refs(value, assets);
}
}
Value::Array(values) => {
for value in values {
collect_theme_asset_refs(value, assets);
}
}
_ => {}
}
}
/// For API 2.0 packages, return the assets referenced by `theme.json` so an
/// export can omit orphaned files left behind by older edits. API 1.0 themes
/// can load assets dynamically from their bundle, so they are exported whole.
fn referenced_theme_assets(theme_json: &Path) -> Option<HashSet<String>> {
let text = std::fs::read_to_string(theme_json).ok()?;
let parsed: Value = serde_json::from_str(&text).ok()?;
let api_version = parsed.get("apiVersion")?.as_str()?;
if api_version.split('.').next() != Some("2") {
return None;
}
let mut assets = HashSet::new();
// The launcher falls back to this filename for theme thumbnails even when
// `preview` is omitted from theme.json.
assets.insert("preview.png".to_string());
collect_theme_asset_refs(&parsed, &mut assets);
Some(assets)
}
fn add_theme_folder_to_zip(
zip: &mut zip::ZipWriter<std::fs::File>,
root: &Path,
directory: &Path,
asset_filter: Option<&HashSet<String>>,
options: zip::write::SimpleFileOptions,
) -> Result<(), String> {
let entries = std::fs::read_dir(directory)
.map_err(|e| format!("Failed to read {}: {e}", directory.display()))?;
let assets_root = root.join("assets");
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?;
let file_type = entry
.file_type()
.map_err(|e| format!("Failed to inspect theme entry: {e}"))?;
if file_type.is_symlink() {
continue;
}
let entry_path = entry.path();
if file_type.is_dir() {
add_theme_folder_to_zip(zip, root, &entry_path, asset_filter, options)?;
continue;
}
if !file_type.is_file() {
continue;
}
if let Some(filter) = asset_filter {
if let Ok(asset_path) = entry_path.strip_prefix(&assets_root) {
let key = asset_path.to_string_lossy().replace('\\', "/");
if !filter.contains(&key) {
continue;
}
}
}
let relative = entry_path
.strip_prefix(root)
.map_err(|e| format!("Failed to build archive path: {e}"))?
.to_string_lossy()
.replace('\\', "/");
zip.start_file(relative, options)
.map_err(|e| format!("Failed to add a file to the theme archive: {e}"))?;
let mut file = std::fs::File::open(&entry_path)
.map_err(|e| format!("Failed to open {}: {e}", entry_path.display()))?;
std::io::copy(&mut file, zip)
.map_err(|e| format!("Failed to write the theme archive: {e}"))?;
}
Ok(())
}
/// Package a theme folder as a shareable `.ezpplauncher-theme` file.
#[tauri::command]
pub async fn export_theme(theme_folder: String, destination_path: String) -> Result<(), String> {
let folder = checked_theme_folder(&theme_folder)?;
if !folder.is_dir() {
return Err("The theme folder does not exist".to_string());
}
let destination = PathBuf::from(destination_path);
if !destination.is_absolute() {
return Err("The export path must be absolute".to_string());
}
if destination.starts_with(&folder) {
return Err("Choose a destination outside the theme folder".to_string());
}
tokio::task::spawn_blocking(move || {
let asset_filter = referenced_theme_assets(&folder.join("theme.json"));
let file = std::fs::File::create(&destination)
.map_err(|e| format!("Failed to create the theme archive: {e}"))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
add_theme_folder_to_zip(&mut zip, &folder, &folder, asset_filter.as_ref(), options)?;
zip.finish()
.map_err(|e| format!("Failed to finish the theme archive: {e}"))?;
Ok::<_, String>(())
})
.await
.map_err(|e| format!("Thread error: {e}"))??;
Ok(())
}
#[tauri::command]
pub async fn open_theme_folder(theme_folder: String) -> Result<(), String> {
let root = theme_root()?;
let requested = std::fs::canonicalize(&theme_folder).unwrap_or_else(|_| root.clone());
if requested != root && !requested.starts_with(&root) {
return Err("The theme folder is outside the themes directory".to_string());
}
open::that(&requested).map_err(|e| format!("Failed to open the themes folder: {e}"))?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn config_get(key: String, state: State<AppState>) -> Result<Option<Value>, ConfigError> { pub fn config_get(key: String, state: State<AppState>) -> Result<Option<Value>, ConfigError> {
state.config.lock().unwrap().get(&key) state.config.lock().unwrap().get(&key)
+65 -12
View File
@@ -7,36 +7,38 @@ 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(target_os = "linux")]
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, check_for_corruption, config_all, config_clear, config_delete, config_exists, config_get,
config_set, download_and_extract_theme, download_ezpp_launcher_update, config_set, download_and_extract_theme, download_ezpp_launcher_update,
download_ezpp_launcher_update_files, encrypt_string, exit, extract_theme, download_ezpp_launcher_update_files, encrypt_string, exit, export_theme, extract_theme,
find_osu_installation, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid, find_osu_installation, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid,
get_launcher_version, get_osu_release_stream, get_osu_skin, get_osu_version, get_platform, get_launcher_version, get_osu_release_stream, get_osu_skin, get_osu_version, get_platform,
get_skins_count, has_net8, has_osuwinello, has_wmctrl, install_ezpp_launcher_update, get_skins_count, has_net8, has_osuwinello, has_wmctrl, install_ezpp_launcher_update,
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, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder, open_url_in_browser,
opened_urls, pick_osu_installation, presence_connect, presence_disconnect,
presence_is_connected, presence_update_button, presence_update_status, presence_is_connected, presence_update_button, presence_update_status,
presence_update_user, read_theme_info, replace_ui_files, run_open_tablet_driver, run_osu, presence_update_user, read_theme_info, replace_ui_files, resolve_osu_folder,
run_osu_updater, set_osu_config_values, set_osu_user_config_values, run_open_tablet_driver, run_osu, run_osu_updater, save_theme_project, set_logging_enabled,
stop_open_tablet_driver, valid_osu_folder, 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 +69,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,14 +108,35 @@ 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),
}); });
// WebKitGTK cannot play theme media through the custom asset
// protocol, so Linux gets a real loopback HTTP server instead.
#[cfg(target_os = "linux")]
match commands::theme_root().and_then(theme_server::start) {
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}");
}
}
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,
@@ -139,6 +171,13 @@ pub fn run() {
read_theme_info, read_theme_info,
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,
config_get, config_get,
config_set, config_set,
config_exists, config_exists,
@@ -148,7 +187,10 @@ 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
]) ])
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -158,6 +200,17 @@ 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}");
}
}
}
presence::handle_run_event(app, &event); presence::handle_run_event(app, &event);
}); });
} }
+537
View File
@@ -0,0 +1,537 @@
//! 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, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use log::Level;
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()
}
/// 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(&current, 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();
}
}
+801
View File
@@ -0,0 +1,801 @@
//! 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(())
}
/// 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>,
}
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 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]]))
}
+25 -12
View File
@@ -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;
} }
} }
+2
View File
@@ -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>>,
} }
+436
View File
@@ -0,0 +1,436 @@
//! Loopback HTTP server for theme assets on Linux.
//!
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
//! protocol, and module scripts from that scheme are unreliable too. A real
//! loopback HTTP server gives WebKit normal status codes, range responses and
//! MIME types, so v1 and v2 theme assets load the same way they do in a
//! browser.
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,
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
}
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
/// Bind the server on a random loopback port. The root is the themes folder.
pub fn start(root: PathBuf) -> Result<u16, String> {
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 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,
previews: previews.clone(),
};
let _ = SERVER.set(server);
let root = Arc::new(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 previews = previews.clone();
tokio::spawn(async move {
let service =
service_fn(move |request| handle(request, 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))
}
/// 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>,
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();
let file = if let Some(relative) = path.strip_prefix("/themes/") {
safe_theme_path(&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_theme_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",
}
}
+129 -33
View File
@@ -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;
} }
+10 -7
View File
@@ -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/**"
] ]
} }
} }
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}
+4
View File
@@ -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,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>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,392 @@
<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="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>
{#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>
+193
View File
@@ -0,0 +1,193 @@
<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 = {},
selectedId = undefined,
interactive = false,
scale = 1,
onselect,
onpatch,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
selectedId?: string;
interactive?: boolean;
scale?: number;
onselect?: (id: string | undefined) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => 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"
onpointerdown={() => {
if (interactive) onselect?.(undefined);
}}
>
<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}
{selectedId}
{scale}
{onselect}
{onpatch}
/>
</div>
@@ -0,0 +1,251 @@
<script lang="ts">
import { custom_theme_volume } from '$lib/global';
import { themeMediaSource } 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)}
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,244 @@
<script lang="ts">
import { fontStack } from '$lib/themeFonts';
import { themeMediaSource } from '$lib/themeMedia';
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
let {
elements = [],
assets = '',
assetOverrides = {},
interactive = false,
selectedId = undefined,
scale = 1,
onselect,
onpatch,
}: {
elements?: ThemeV2Element[];
assets?: string;
assetOverrides?: Record<string, string>;
interactive?: boolean;
selectedId?: string;
scale?: number;
onselect?: (id: string | undefined) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
} = $props();
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
// 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 DragState = {
id: string;
startX: number;
startY: number;
originX: number;
originY: number;
};
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
type ResizeState = DragState & {
direction: ResizeDirection;
originWidth: number;
originHeight: number;
};
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
let drag = $state<DragState | null>(null);
let resize = $state<ResizeState | null>(null);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function beginDrag(event: PointerEvent, element: ThemeV2Element) {
if (!interactive || (event.target as HTMLElement).dataset.resize) return;
event.preventDefault();
event.stopPropagation();
onselect?.(element.id);
drag = {
id: element.id,
startX: event.clientX,
startY: event.clientY,
originX: element.x,
originY: element.y,
};
window.addEventListener('pointermove', moveDrag);
window.addEventListener('pointerup', endDrag, { once: true });
}
function moveDrag(event: PointerEvent) {
if (!drag) return;
const element = elements.find((candidate) => candidate.id === drag!.id);
if (!element) return;
const dx = (event.clientX - drag.startX) / scale;
const dy = (event.clientY - drag.startY) / scale;
onpatch?.(drag.id, {
x: Math.round(clamp(drag.originX + dx, 0, 1000 - element.width)),
y: Math.round(clamp(drag.originY + dy, 0, 650 - element.height)),
});
}
function endDrag() {
drag = null;
window.removeEventListener('pointermove', moveDrag);
}
function beginResize(
event: PointerEvent,
element: ThemeV2Element,
direction: ResizeState['direction']
) {
if (!interactive) 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);
}
</script>
<div
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
role="presentation"
>
{#each elements as element (element.id)}
{@const isSelected = interactive && element.id === selectedId}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="absolute select-none"
class:cursor-move={interactive}
style="left:{element.x}px;top:{element.y}px;width:{element.width}px;height:{element.height}px;transform:rotate({element.rotation ??
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{isSelected
? '2px solid #ff006e'
: '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)}
onkeydown={(event) => {
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onselect?.(element.id);
}
}}
>
{#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)}
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}
{#if isSelected}
{#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}
</div>
{/each}
</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>
-106
View File
@@ -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
View File
@@ -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}
/>
-7
View File
@@ -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>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { config } from '$lib/config'; import { config } from '$lib/config';
import { custom_theme_audio_playing, custom_theme_volume } from '$lib/global'; import { custom_theme_audio_playing, custom_theme_volume, platform } from '$lib/global';
import { reduceAnimations } from '$lib/userSettings'; import { reduceAnimations } from '$lib/userSettings';
import { Volume, Volume1, VolumeOff } from '@lucide/svelte'; import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
import Minimize from '@lucide/svelte/icons/minus'; import Minimize from '@lucide/svelte/icons/minus';
@@ -19,7 +19,7 @@
}); });
</script> </script>
<div data-tauri-drag-region class="titlebar z-[100]"> <div data-tauri-drag-region class="titlebar z-[100]" class:macos-window={$platform === 'macos'}>
{#if $custom_theme_audio_playing} {#if $custom_theme_audio_playing}
{#if $custom_theme_audio_playing} {#if $custom_theme_audio_playing}
<div class="relative flex items-center justify-center group"> <div class="relative flex items-center justify-center group">
@@ -115,6 +115,11 @@
pointer-events: auto; pointer-events: auto;
} }
/* Keep the window controls clear of the rounded top-right corner. */
.titlebar.macos-window {
padding-right: 12px;
}
.volume-slider { .volume-slider {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
+154
View File
@@ -0,0 +1,154 @@
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
const FONTSOURCE_API = 'https://api.fontsource.org/v1/fonts';
const FONTSOURCE_CDN = 'https://cdn.jsdelivr.net/fontsource/fonts';
export type FontsourceFont = {
id: string;
family: string;
subsets: string[];
weights: number[];
styles: string[];
defSubset: string;
variable: boolean;
category: string;
type: string;
};
export type FontsourceFontOptions = {
weight?: number;
style: 'normal' | 'italic';
subset: string;
};
let catalogPromise: Promise<FontsourceFont[]> | undefined;
const isFontsourceFont = (value: unknown): value is FontsourceFont => {
if (!value || typeof value !== 'object') return false;
const font = value as Record<string, unknown>;
return (
typeof font.id === 'string' &&
/^[a-z0-9-]+$/i.test(font.id) &&
typeof font.family === 'string' &&
font.family.trim().length > 0 &&
Array.isArray(font.subsets) &&
font.subsets.every((subset) => typeof subset === 'string' && /^[a-z0-9-]+$/i.test(subset)) &&
Array.isArray(font.weights) &&
font.weights.every((weight) => typeof weight === 'number') &&
Array.isArray(font.styles) &&
font.styles.every((style) => style === 'normal' || style === 'italic') &&
typeof font.defSubset === 'string' &&
typeof font.variable === 'boolean'
);
};
/** Load the complete Fontsource catalog once per editor session. */
export const getFontsourceCatalog = async (): Promise<FontsourceFont[]> => {
if (!catalogPromise) {
catalogPromise = fetch(FONTSOURCE_API, { headers: { Accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Fontsource returned ${response.status}`);
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
throw new Error('Fontsource returned an unexpected catalog.');
}
return payload
.filter(isFontsourceFont)
.map((font) => ({
...font,
category: typeof font.category === 'string' ? font.category : 'other',
type: typeof font.type === 'string' ? font.type : 'google',
}))
.sort((left, right) => left.family.localeCompare(right.family));
})
.catch((error) => {
catalogPromise = undefined;
throw error;
});
}
return catalogPromise;
};
const normalizeStyle = (font: FontsourceFont, style: string) =>
style === 'italic' && font.styles.includes('italic') ? 'italic' : 'normal';
const normalizeSubset = (font: FontsourceFont, subset: string) =>
font.subsets.includes(subset)
? subset
: font.subsets.includes(font.defSubset)
? font.defSubset
: (font.subsets[0] ?? 'latin');
const normalizeWeight = (font: FontsourceFont, weight: number | undefined) => {
const preferred = weight ?? 400;
return font.weights.includes(preferred) ? preferred : (font.weights[0] ?? 400);
};
/** Direct jsDelivr URL for a single Fontsource WOFF2 file. */
export const fontsourceFontUrl = (font: FontsourceFont, options: FontsourceFontOptions) => {
const subset = normalizeSubset(font, options.subset);
const style = normalizeStyle(font, options.style);
const family = font.variable ? `${font.id}:vf` : font.id;
const weight = font.variable ? 'wght' : String(normalizeWeight(font, options.weight));
return `${FONTSOURCE_CDN}/${family}@latest/${subset}-${weight}-${style}.woff2`;
};
/** Stable local filename used inside the editor cache and theme assets. */
export const fontsourceAssetName = (font: FontsourceFont, options: FontsourceFontOptions) => {
const subset = normalizeSubset(font, options.subset);
const style = normalizeStyle(font, options.style);
const weight = font.variable ? 'variable' : String(normalizeWeight(font, options.weight));
return `font_${font.id.replace(/[^a-z0-9]+/gi, '_')}_${subset}_${weight}_${style}.woff2`;
};
/** Key used to avoid duplicate family/weight/style entries in a theme. */
export const themeFontKey = (family: string, weight: string, style: string) =>
`${family.trim().toLowerCase()}|${weight.trim()}|${style}`;
export const fontsourceFontKey = (font: FontsourceFont, options: FontsourceFontOptions) => {
const weight = font.variable ? '100 900' : String(normalizeWeight(font, options.weight));
return themeFontKey(font.family, weight, normalizeStyle(font, options.style));
};
/** Sensible face to use when previewing a catalog family as a whole. */
export const defaultFontsourceOptions = (font: FontsourceFont): FontsourceFontOptions => ({
weight: normalizeWeight(font, 400),
style: normalizeStyle(font, 'normal'),
subset: normalizeSubset(font, font.defSubset),
});
export const fontCacheDirectory = async () =>
await path.join(await path.homeDir(), '.ezpplauncher', 'font-cache');
/**
* Download a selected Fontsource face into the editor cache. The cached file is
* later copied into the theme's assets folder when the theme is saved.
*/
export const downloadFontsourceFont = async (
font: FontsourceFont,
options: FontsourceFontOptions
): Promise<{ name: string; source: string; url: string }> => {
const url = fontsourceFontUrl(font, options);
const name = fontsourceAssetName(font, options);
const directory = await fontCacheDirectory();
const target = await path.join(directory, name);
await fs.mkdir(directory, { recursive: true });
if (!(await fs.exists(target))) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Could not download ${font.family} (${response.status}).`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength === 0) {
throw new Error(`Fontsource returned an empty file for ${font.family}.`);
}
await fs.writeFile(target, bytes);
}
return { name, source: target, url };
};
+98
View File
@@ -0,0 +1,98 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Forward webview console output and uncaught errors to the launcher log file.
*
* Tauri's log plugin only captures Rust logs, so the frontend mirrors console
* calls through a small command. The original console methods are preserved so
* devtools output and HMR are unaffected.
*/
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const MAX_MESSAGE_LENGTH = 16_000;
const INITIALIZED_KEY = '__ezppLauncherLoggingInitialized';
const originalConsole = {
debug: console.debug.bind(console),
info: console.info.bind(console),
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
function stringify(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Error) {
return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ''}`;
}
if (typeof value === 'object' && value !== null) {
try {
const seen = new WeakSet<object>();
return JSON.stringify(value, (_key, candidate: unknown) => {
if (typeof candidate === 'bigint') return candidate.toString();
if (typeof candidate === 'object' && candidate !== null) {
if (seen.has(candidate)) return '[Circular]';
seen.add(candidate);
}
return candidate;
});
} catch {
return String(value);
}
}
return String(value);
}
function send(level: LogLevel, values: unknown[]) {
const message = values.map(stringify).join(' ').slice(0, MAX_MESSAGE_LENGTH);
// Never let a failed IPC call produce another console error and loop back.
invoke('log_frontend', { level, message }).catch((error) => {
originalConsole.error('Could not forward a log message:', error);
});
}
export function initLogging() {
if (typeof window === 'undefined') return;
const marker = window as unknown as Record<string, boolean>;
if (marker[INITIALIZED_KEY]) return;
marker[INITIALIZED_KEY] = true;
console.debug = (...values: unknown[]) => {
originalConsole.debug(...values);
send('debug', values);
};
console.info = (...values: unknown[]) => {
originalConsole.info(...values);
send('info', values);
};
console.log = (...values: unknown[]) => {
originalConsole.log(...values);
send('info', values);
};
console.warn = (...values: unknown[]) => {
originalConsole.warn(...values);
send('warn', values);
};
console.error = (...values: unknown[]) => {
originalConsole.error(...values);
send('error', values);
};
window.addEventListener('error', (event) => {
send('error', [
`[uncaught] ${event.message}`,
event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '',
event.error instanceof Error ? event.error.stack : '',
]);
});
window.addEventListener('unhandledrejection', (event) => {
send('error', ['[unhandled rejection]', event.reason]);
});
send('info', [`Webview started: ${window.location.href}`]);
}
+27
View File
@@ -2,6 +2,7 @@ import type { UpdateFile, UpdateStatus } from './types';
import { betterFetch } from '@better-fetch/fetch'; import { betterFetch } from '@better-fetch/fetch';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
const updateUrl = 'https://ez-pp.farm/api/ezpplauncher'; const updateUrl = 'https://ez-pp.farm/api/ezpplauncher';
@@ -23,9 +24,32 @@ export const getHWID = async (): Promise<string | undefined> =>
export const isValidOsuFolder = async (folder: string): Promise<boolean | undefined> => export const isValidOsuFolder = async (folder: string): Promise<boolean | undefined> =>
await invoke<boolean | undefined>('valid_osu_folder', { folder }); await invoke<boolean | undefined>('valid_osu_folder', { folder });
export const resolveOsuFolder = async (folder: string): Promise<string | undefined> =>
(await invoke<string | null>('resolve_osu_folder', { folder })) ?? undefined;
export const autoDetectOsuInstallFolder = async () => export const autoDetectOsuInstallFolder = async () =>
await invoke<string | undefined>('find_osu_installation'); await invoke<string | undefined>('find_osu_installation');
/**
* Pick an osu! installation. On macOS the app runs inside a Wineskin `.app`
* bundle, and the standard dialog would descend into the bundle instead of
* letting it be selected, so a native panel is used there.
*/
export const pickOsuInstallation = async (platform: string): Promise<string | undefined> => {
if (platform === 'macos') {
return (await invoke<string | null>('pick_osu_installation')) ?? undefined;
}
const selected = await open({
directory: true,
multiple: false,
title: 'Select osu! Installation Folder',
canCreateDirectories: false,
});
return typeof selected === 'string' ? selected : undefined;
};
export const setUserConfigValues = async ( export const setUserConfigValues = async (
osuFolderPath: string, osuFolderPath: string,
entries: { key: string; value: string }[] entries: { key: string; value: string }[]
@@ -120,6 +144,9 @@ export const isOsuRunning = async (): Promise<boolean> => await invoke<boolean>(
export const getLauncherVersion = async () => await invoke<string>('get_launcher_version'); export const getLauncherVersion = async () => await invoke<string>('get_launcher_version');
export const exit = async () => await invoke('exit'); export const exit = async () => await invoke('exit');
export const getPlatform = async (): Promise<string> => await invoke<string>('get_platform'); export const getPlatform = async (): Promise<string> => await invoke<string>('get_platform');
export const setLoggingEnabled = async (enabled: boolean) =>
await invoke('set_logging_enabled', { enabled });
export const openLogsFolder = async () => await invoke('open_logs_folder');
export const isOsuCorrupted = async (folder: string): Promise<boolean> => export const isOsuCorrupted = async (folder: string): Promise<boolean> =>
await invoke<boolean>('check_for_corruption', { folder }); await invoke<boolean>('check_for_corruption', { folder });
export const hasWMCTRL = async (): Promise<boolean> => await invoke<boolean>('has_wmctrl'); export const hasWMCTRL = async (): Promise<boolean> => await invoke<boolean>('has_wmctrl');
-24
View File
@@ -1,24 +0,0 @@
const specialOrder = "'-!#$%&(),;@[]^_`{}~+=";
const priority = Object.fromEntries([...specialOrder].map((c, i) => [c, i]));
const osuSkinsCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export const sortSkins = (
skins: { name: string; author: string | undefined; modified: number }[]
) =>
skins.sort((a, b) => {
const pa = priority[a.name[0]];
const pb = priority[b.name[0]];
if (pa !== undefined || pb !== undefined) {
if (pa === undefined) return 1;
if (pb === undefined) return -1;
return pa - pb;
}
return osuSkinsCollator.compare(a.name, b.name);
});
+18
View File
@@ -0,0 +1,18 @@
export type ThemeFontChoice = {
label: string;
family: string;
category: 'Sans' | 'Serif' | 'Display' | 'Mono' | 'Custom';
};
/** Fonts bundled with the launcher through @fontsource. */
export const BUNDLED_THEME_FONTS: ThemeFontChoice[] = [
{ label: 'Sora', family: 'Sora Variable', category: 'Sans' },
{ label: 'Montserrat', family: 'Montserrat Variable', category: 'Sans' },
{ label: 'Bebas Neue', family: 'Bebas Neue', category: 'Display' },
{ label: 'Playfair Display', family: 'Playfair Display Variable', category: 'Serif' },
{ label: 'Space Mono', family: 'Space Mono', category: 'Mono' },
{ label: 'JetBrains Mono', family: 'JetBrains Mono Variable', category: 'Mono' },
];
export const fontStack = (family: string | undefined, fallback = 'Sora Variable') =>
`"${(family || fallback).replace(/["\\]/g, '\\$&')}", ui-sans-serif, system-ui, sans-serif`;
+208
View File
@@ -0,0 +1,208 @@
/**
* Theme media playback helper.
*
* On macOS, WKWebView's media pipeline asks Tauri's custom `asset:` protocol
* for thousands of one-byte ranges when it plays a WebM file. That request
* storm starves the audio decoder, which makes the audio stutter or drop out.
* Fetching the file once and handing the media element a `blob:` URL avoids
* the custom protocol entirely and lets WebKit decode from a local buffer.
*/
type MediaCleanup = () => void;
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
// The request-storm problem is specific to WKWebView. Keep the native
// streaming path everywhere else, where the asset protocol behaves correctly
// (and where blob-backed media can fail to decode in WebKitGTK).
const useBlobMedia =
typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
const blobUrlPromises = new Map<string, Promise<string>>();
const objectUrls = new Set<string>();
const mediaTokens = new WeakMap<HTMLMediaElement, number>();
const mediaCleanups = new WeakMap<HTMLMediaElement, MediaCleanup>();
let tokenCounter = 0;
let cacheGeneration = 0;
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
!!url && assetPattern.test(url);
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
export const themeMediaUrl = async (source: string): Promise<string> => {
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
const cached = blobUrlPromises.get(source);
if (cached) return cached;
const generation = cacheGeneration;
const promise = (async () => {
try {
const response = await fetch(source);
if (!response.ok) throw new Error(`Theme media request failed (${response.status})`);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
// The theme was switched while the file was loading; don't keep a URL
// that nothing can use anymore.
if (generation !== cacheGeneration) {
URL.revokeObjectURL(objectUrl);
return source;
}
objectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
console.warn('Could not prepare theme media for smooth playback', error);
return source;
}
})();
blobUrlPromises.set(source, promise);
return promise;
};
/** Revoke URLs from the previous theme. */
export const clearThemeMediaCache = () => {
cacheGeneration += 1;
blobUrlPromises.clear();
for (const url of objectUrls) URL.revokeObjectURL(url);
objectUrls.clear();
};
/**
* Swap a media element over to a blob URL while keeping its playback position
* and state. `source` must be the original asset URL, not the blob URL.
*/
const loadMediaSource = (node: HTMLMediaElement, source: string) => {
const token = ++tokenCounter;
mediaTokens.set(node, token);
mediaCleanups.get(node)?.();
mediaCleanups.delete(node);
void themeMediaUrl(source).then((url) => {
if (mediaTokens.get(node) !== token || !node.isConnected) return;
if (node.src === url) return;
const wasPlaying = !node.paused;
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
const cleanup = () => {
node.removeEventListener('loadedmetadata', onLoaded);
node.removeEventListener('error', onError);
};
const onLoaded = () => {
cleanup();
mediaCleanups.delete(node);
if (previousTime > 0.05) {
try {
node.currentTime = previousTime;
} catch {
// Some media formats cannot seek until more data is buffered.
}
}
if (wasPlaying || node.autoplay) {
void node.play().catch(() => {
// Autoplay can be rejected until the user interacts with the window.
});
}
};
const onError = () => {
cleanup();
mediaCleanups.delete(node);
// Keep the theme usable if this WebView cannot decode the blob.
if (isThemeAssetUrl(source) && node.src !== source) {
node.src = source;
node.load();
if (wasPlaying || node.autoplay) void node.play().catch(() => {});
}
};
node.addEventListener('loadedmetadata', onLoaded);
node.addEventListener('error', onError);
mediaCleanups.set(node, cleanup);
node.src = url;
node.load();
});
};
/**
* Svelte action for v2 theme videos/audio. Use this instead of `src=...`.
*/
export function themeMediaSource(node: HTMLMediaElement, source: string) {
let currentSource = source;
loadMediaSource(node, currentSource);
return {
update(nextSource: string) {
if (nextSource === currentSource) return;
currentSource = nextSource;
loadMediaSource(node, currentSource);
},
destroy() {
mediaTokens.delete(node);
mediaCleanups.get(node)?.();
mediaCleanups.delete(node);
},
};
}
/**
* Svelte action for the v1 theme container. Themes mount their own markup, so
* watch for media elements (and src changes) and route them through blobs too.
*/
export function smoothThemeMedia(node: HTMLElement) {
const lastSource = new WeakMap<HTMLMediaElement, string>();
const prepare = (media: HTMLMediaElement) => {
const source =
media.getAttribute('src') ??
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
undefined;
if (!isThemeAssetUrl(source)) return;
if (lastSource.get(media) === source) return;
lastSource.set(media, source);
loadMediaSource(media, source);
};
const scan = (root: ParentNode) => {
if (root instanceof HTMLMediaElement) prepare(root);
root.querySelectorAll?.<HTMLMediaElement>('video, audio').forEach(prepare);
};
scan(node);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.target instanceof HTMLMediaElement) {
prepare(mutation.target);
continue;
}
for (const added of mutation.addedNodes) {
if (added instanceof HTMLElement) scan(added);
}
}
});
observer.observe(node, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src'],
});
return {
destroy() {
observer.disconnect();
},
};
}
File diff suppressed because it is too large. Load diff
+567
View File
@@ -0,0 +1,567 @@
/**
* Declarative theme API 2.0.
*
* Version 1 themes are JavaScript bundles that mount their own UI. Version 2
* themes describe their look with data instead, which lets the launcher render
* them consistently and lets the built-in editor create them without shipping
* any code. A v2 theme package is a zip containing `theme.json` and an
* `assets/` folder.
*/
export type ThemeV2BackgroundType = 'color' | 'gradient' | 'image' | 'video';
export type ThemeV2Background =
| {
type: 'color';
color: string;
opacity?: number;
}
| {
type: 'gradient';
from: string;
to: string;
angle?: number;
opacity?: number;
}
| {
type: 'image';
src: string;
fit?: 'cover' | 'contain';
opacity?: number;
blur?: number;
}
| {
type: 'video';
src: string;
fit?: 'cover' | 'contain';
opacity?: number;
blur?: number;
/** Defaults to true so autoplay works without user interaction. */
muted?: boolean;
};
export type ThemeV2EffectType = 'none' | 'snow' | 'stars' | 'petals' | 'bubbles';
export type ThemeV2Effects = {
type: ThemeV2EffectType;
density?: number;
speed?: number;
color?: string;
};
export type ThemeV2Overlay = {
color?: string;
opacity?: number;
vignette?: number;
};
export type ThemeV2Audio = {
src: string;
loop?: boolean;
};
export type ThemeV2Font = {
/** The family name used by elements and the typography settings. */
family: string;
/** Font file relative to the theme's assets folder. */
src: string;
weight?: string;
style?: 'normal' | 'italic';
};
export type ThemeV2Typography = {
/** Font used by the launcher's normal UI text. */
body?: string;
/** Font used by headings such as the launcher title. */
heading?: string;
};
export type ThemeV2ElementAlign = 'left' | 'center' | 'right';
export type ThemeV2ElementVerticalAlign = 'top' | 'center' | 'bottom';
export type ThemeV2ElementBase = {
id: string;
/** Stage coordinates: the launcher canvas is 1000 x 650. */
x: number;
y: number;
width: number;
height: number;
rotation?: number;
opacity?: number;
};
export type ThemeV2TextElement = ThemeV2ElementBase & {
type: 'text';
text: string;
fontFamily?: string;
fontSize?: number;
fontWeight?: number;
color?: string;
align?: ThemeV2ElementAlign;
verticalAlign?: ThemeV2ElementVerticalAlign;
lineHeight?: number;
letterSpacing?: number;
};
export type ThemeV2ImageElement = ThemeV2ElementBase & {
type: 'image';
src: string;
fit?: 'cover' | 'contain';
radius?: number;
};
export type ThemeV2VideoElement = ThemeV2ElementBase & {
type: 'video';
src: string;
fit?: 'cover' | 'contain';
radius?: number;
/** Defaults to true so autoplay works without user interaction. */
muted?: boolean;
};
export type ThemeV2ShapeElement = ThemeV2ElementBase & {
type: 'shape';
shape: 'rectangle' | 'ellipse' | 'line';
fill?: string;
radius?: number;
borderWidth?: number;
borderColor?: string;
};
export type ThemeV2Element =
| ThemeV2TextElement
| ThemeV2ImageElement
| ThemeV2VideoElement
| ThemeV2ShapeElement;
export type ThemeV2Definition = {
name: string;
version: string;
apiVersion: '2.0';
author: string;
description?: string;
/** Marks the theme as adult content in the theme manager. */
nsfw?: boolean;
/** Optional preview image, relative to the theme's assets folder. */
preview?: string;
background: ThemeV2Background;
overlay: ThemeV2Overlay;
effects: ThemeV2Effects;
audio?: ThemeV2Audio;
/** Theme-wide fonts and freeform layers rendered over the launcher UI. */
typography: ThemeV2Typography;
fonts: ThemeV2Font[];
elements: ThemeV2Element[];
/** Used for focus rings, active states and effect accents. */
accentColor?: string;
/** Optional CSS appended after the launcher's own styles. */
css?: string;
};
export type ThemeV2Asset = {
name: string;
source: string;
};
const clamp = (value: unknown, fallback: number, min: number, max: number) => {
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
};
const text = (value: unknown, fallback = '') =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback;
const color = (value: unknown, fallback: string) => {
const result = text(value, fallback);
return /^#(?:[0-9a-f]{3,8})$/i.test(result) ? result : fallback;
};
const oneOf = <T extends string>(value: unknown, options: readonly T[], fallback: T): T =>
options.includes(value as T) ? (value as T) : fallback;
const clampInteger = (value: unknown, fallback: number, min: number, max: number) =>
Math.round(clamp(value, fallback, min, max));
const fontFamilyCss = (family: string) => `"${family.replace(/["\\]/g, '\\$&')}"`;
const fontFormat = (src: string) => {
const extension = src.split('.').pop()?.toLowerCase();
if (extension === 'woff2') return 'woff2';
if (extension === 'woff') return 'woff';
if (extension === 'otf') return 'opentype';
if (extension === 'ttf') return 'truetype';
return 'woff2';
};
const normalizeFonts = (raw: unknown): ThemeV2Font[] => {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const fonts: ThemeV2Font[] = [];
for (const candidate of raw.slice(0, 48)) {
if (!candidate || typeof candidate !== 'object') continue;
const source = candidate as Record<string, unknown>;
const family = text(source.family).slice(0, 80);
const src = text(source.src).slice(0, 260);
const weight =
text(typeof source.weight === 'number' ? String(source.weight) : source.weight, '400')
.replace(/[^0-9,\s]/g, '')
.slice(0, 40) || '400';
const style = source.style === 'italic' ? 'italic' : 'normal';
const key = `${family}|${weight}|${style}|${src}`;
if (!family || !src || seen.has(key)) continue;
seen.add(key);
fonts.push({ family, src, weight, style });
}
return fonts;
};
const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefined => {
if (!raw || typeof raw !== 'object') return undefined;
const source = raw as Record<string, unknown>;
const type = oneOf(source.type, ['text', 'image', 'video', 'shape'] as const, 'text');
const base = {
id: text(source.id, `element_${index + 1}`).slice(0, 64),
x: clamp(source.x, 0, 0, 1000),
y: clamp(source.y, 0, 0, 650),
width: clamp(source.width, 320, 24, 1000),
height: clamp(source.height, 120, 24, 650),
rotation: clamp(source.rotation, 0, -360, 360),
opacity: clamp(source.opacity, 1, 0, 1),
};
if (type === 'text') {
return {
...base,
type,
text: typeof source.text === 'string' ? source.text.slice(0, 2000) : 'Your text',
fontFamily: text(source.fontFamily, 'Sora Variable'),
fontSize: clamp(source.fontSize, 28, 6, 240),
fontWeight: clampInteger(source.fontWeight, 600, 100, 900),
color: color(source.color, '#ffffff'),
align: oneOf(source.align, ['left', 'center', 'right'] as const, 'left'),
verticalAlign: oneOf(source.verticalAlign, ['top', 'center', 'bottom'] as const, 'top'),
lineHeight: clamp(source.lineHeight, 1.2, 0.7, 3),
letterSpacing: clamp(source.letterSpacing, 0, -20, 40),
};
}
if (type === 'shape') {
return {
...base,
type,
shape: oneOf(source.shape, ['rectangle', 'ellipse', 'line'] as const, 'rectangle'),
fill: color(source.fill, '#ff006e'),
radius: clamp(source.radius, 16, 0, 999),
borderWidth: clamp(source.borderWidth, 0, 0, 40),
borderColor: color(source.borderColor, '#ffffff'),
};
}
const src = text(source.src).slice(0, 500);
if (!src) return undefined;
return {
...base,
type,
src,
fit: source.fit === 'contain' ? 'contain' : 'cover',
radius: clamp(source.radius, 0, 0, 999),
...(type === 'video' ? { muted: source.muted !== false } : {}),
};
};
const normalizeElements = (raw: unknown): ThemeV2Element[] => {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const elements: ThemeV2Element[] = [];
raw.slice(0, 100).forEach((candidate, index) => {
const element = normalizeElement(candidate, index);
if (!element) return;
let id = element.id;
let suffix = 2;
while (seen.has(id)) id = `${element.id}_${suffix++}`;
seen.add(id);
elements.push({ ...element, id });
});
return elements;
};
/** Create a sensible element for the advanced editor's add buttons. */
export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): ThemeV2Element => {
const id = `element_${Date.now().toString(36)}_${index}`;
const base = { id, x: 340, y: 250, width: 320, height: 120 };
if (type === 'image') {
return normalizeElement(
{ ...base, type, src: 'placeholder.png' },
index
)! as ThemeV2ImageElement;
}
if (type === 'video') {
return normalizeElement(
{ ...base, type, src: 'placeholder.webm' },
index
)! as ThemeV2VideoElement;
}
if (type === 'shape') {
return normalizeElement(
{ ...base, type, shape: 'rectangle', width: 220, height: 120 },
index
)! as ThemeV2ShapeElement;
}
return normalizeElement(
{ ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 },
index
)! as ThemeV2TextElement;
};
export const isThemeV2 = (apiVersion: unknown) =>
typeof apiVersion === 'string' && apiVersion.split('.')[0] === '2';
export const createDefaultThemeV2 = (): ThemeV2Definition => ({
name: 'Untitled theme',
version: '1.0.0',
apiVersion: '2.0',
author: '',
description: '',
nsfw: false,
background: {
type: 'gradient',
from: '#181825',
to: '#3b1d4f',
angle: 135,
opacity: 1,
},
overlay: {
color: '#000000',
opacity: 0.2,
vignette: 0.35,
},
effects: {
type: 'none',
density: 30,
speed: 1,
color: '#ffffff',
},
typography: {
body: 'Sora Variable',
heading: 'Sora Variable',
},
fonts: [],
elements: [],
accentColor: '#ff006e',
css: '',
});
/** Coerce a theme.json payload into the supported v2 shape. */
export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Definition => {
const defaults = createDefaultThemeV2();
const sourceBackground = (raw.background ?? {}) as Record<string, unknown>;
const backgroundType = ['color', 'gradient', 'image', 'video'].includes(
String(sourceBackground.type)
)
? (String(sourceBackground.type) as ThemeV2BackgroundType)
: 'gradient';
let background: ThemeV2Background;
switch (backgroundType) {
case 'color':
background = {
type: 'color',
color: color(sourceBackground.color, '#181825'),
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
};
break;
case 'image':
background = {
type: 'image',
src: text(sourceBackground.src),
fit: sourceBackground.fit === 'contain' ? 'contain' : 'cover',
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
blur: clamp(sourceBackground.blur, 0, 0, 40),
};
break;
case 'video':
background = {
type: 'video',
src: text(sourceBackground.src),
fit: sourceBackground.fit === 'contain' ? 'contain' : 'cover',
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
blur: clamp(sourceBackground.blur, 0, 0, 40),
muted: sourceBackground.muted !== false,
};
break;
default:
background = {
type: 'gradient',
from: color(
sourceBackground.from,
defaults.background.type === 'gradient' ? defaults.background.from : '#181825'
),
to: color(sourceBackground.to, '#3b1d4f'),
angle: clamp(sourceBackground.angle, 135, 0, 360),
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
};
}
const sourceEffects = (raw.effects ?? {}) as Record<string, unknown>;
const effectType = ['none', 'snow', 'stars', 'petals', 'bubbles'].includes(
String(sourceEffects.type)
)
? (String(sourceEffects.type) as ThemeV2EffectType)
: 'none';
const sourceOverlay = (raw.overlay ?? {}) as Record<string, unknown>;
const sourceAudio = raw.audio as Record<string, unknown> | undefined;
const sourceTypography = (raw.typography ?? {}) as Record<string, unknown>;
const bodyFont = text(sourceTypography.body).slice(0, 80);
const headingFont = text(sourceTypography.heading).slice(0, 80);
return {
name: text(raw.name, 'Untitled theme'),
version: text(raw.version, '1.0.0'),
apiVersion: '2.0',
author: text(raw.author, 'Unknown'),
description: text(raw.description),
nsfw: raw.nsfw === true,
preview: text(raw.preview),
background,
overlay: {
color: color(sourceOverlay.color, '#000000'),
opacity: clamp(sourceOverlay.opacity, 0.2, 0, 1),
vignette: clamp(sourceOverlay.vignette, 0.35, 0, 1),
},
effects: {
type: effectType,
density: Math.round(clamp(sourceEffects.density, 30, 0, 120)),
speed: clamp(sourceEffects.speed, 1, 0.2, 3),
color: color(sourceEffects.color, '#ffffff'),
},
audio:
sourceAudio && text(sourceAudio.src)
? {
src: text(sourceAudio.src),
loop: sourceAudio.loop !== false,
}
: undefined,
typography: {
body: bodyFont || defaults.typography?.body || 'Sora Variable',
heading: headingFont || bodyFont || defaults.typography?.heading || 'Sora Variable',
},
fonts: normalizeFonts(raw.fonts),
elements: normalizeElements(raw.elements),
accentColor: color(raw.accentColor, '#ff006e'),
css: typeof raw.css === 'string' ? raw.css : '',
};
};
/** Resolve a path inside a theme package to an asset URL. */
export const themeAssetUrl = (base: string, path: string | undefined) => {
if (!path) return '';
if (/^(?:[a-z]+:)?\/\//i.test(path) || /^(?:data|blob|asset):/i.test(path)) return path;
const relative = path
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
return base ? `${base.replace(/\/+$/, '')}/${relative}` : relative;
};
/** A CSS background value used for theme cards in the manager. */
export const themeV2ThumbnailStyle = (definition: ThemeV2Definition, assets = '') => {
const background = definition.background;
switch (background.type) {
case 'color':
return `background-color:${background.color};`;
case 'image':
return background.src
? `background-image:url("${themeAssetUrl(assets, background.src)}");background-size:cover;background-position:center;`
: `background-color:#181825;`;
case 'video':
return `background:linear-gradient(135deg, ${definition.accentColor ?? '#ff006e'}55, #181825);`;
default:
return `background:linear-gradient(${background.angle ?? 135}deg, ${background.from}, ${background.to});`;
}
};
const channel = (value: string) => parseInt(value, 16) / 255;
const relativeLuminance = (hex: string) => {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
if (!match) return 0;
let value = match[1];
if (value.length === 3)
value = value
.split('')
.map((part) => part + part)
.join('');
const [r, g, b] = [value.slice(0, 2), value.slice(2, 4), value.slice(4, 6)].map(channel);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
export const readableTextColor = (hex: string) =>
relativeLuminance(hex) > 0.55 ? '#181825' : '#ffffff';
/** @font-face declarations for fonts bundled in a theme package. */
export const themeFontFaceCss = (
definition: ThemeV2Definition,
assets = '',
assetOverrides: Record<string, string> = {}
) =>
(definition.fonts ?? [])
.map(
(font) =>
`@font-face{font-family:${fontFamilyCss(font.family)};src:url("${
assetOverrides[font.src] ?? themeAssetUrl(assets, font.src)
}") format("${fontFormat(font.src)}");font-weight:${font.weight ?? '400'};font-style:${
font.style ?? 'normal'
};font-display:swap;}`
)
.join('\n');
/** CSS injected while a v2 theme is active. */
export const themeV2Css = (
definition: ThemeV2Definition,
assets = '',
assetOverrides: Record<string, string> = {}
) => {
const accent = definition.accentColor;
// `.dark` also defines the primary variables, so the override needs a
// selector that is at least as specific as `html.dark`.
const accentCss = accent
? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${readableTextColor(accent)};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${readableTextColor(accent)};}`
: '';
const body = definition.typography?.body?.trim();
const heading = definition.typography?.heading?.trim();
const fontFaces = themeFontFaceCss(definition, assets, assetOverrides);
const typographyCss = body
? `:root,:root.dark{--font-sans:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}body{font-family:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}`
: '';
const headingCss = heading
? `:root,:root.dark{--font-heading:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}h1,h2,h3,h4,h5,h6,.theme-heading{font-family:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}`
: '';
return [accentCss, fontFaces, typographyCss, headingCss, definition.css?.trim()]
.filter(Boolean)
.join('\n');
};
/** A safe, predictable asset filename for the theme editor. */
export const themeAssetName = (sourcePath: string, prefix: string) => {
const base = sourcePath.split(/[\\/]/).pop() ?? 'asset';
const dot = base.lastIndexOf('.');
const extension =
dot > 0
? base
.slice(dot)
.toLowerCase()
.replace(/[^a-z0-9.]/g, '')
: '';
const stem =
(dot > 0 ? base.slice(0, dot) : base)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 48) || 'asset';
return `${prefix}_${stem}${extension}`;
};
+291 -164
View File
@@ -1,8 +1,9 @@
import { active_custom_theme, custom_themes } from './global'; import { active_custom_theme, custom_theme_audio_playing, custom_themes } from './global';
import { clearThemeMediaCache } from './themeMedia';
import { isThemeV2, normalizeThemeV2, type ThemeV2Asset, type ThemeV2Definition } from './themeV2';
import { disableMediaControls, setGlobalVolume } from './utils'; import { disableMediaControls, setGlobalVolume } from './utils';
import { betterFetch } from '@better-fetch/fetch'; import { betterFetch } from '@better-fetch/fetch';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc, invoke } from '@tauri-apps/api/core';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path'; import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs'; import * as fs from '@tauri-apps/plugin-fs';
@@ -12,7 +13,10 @@ export type Theme = {
folder_name: string; folder_name: string;
name: string; name: string;
version: string; version: string;
apiVersion: string;
author: string; author: string;
description: string;
nsfw: boolean;
scriptUrl: string; scriptUrl: string;
cssStyle: string; cssStyle: string;
assets: string; assets: string;
@@ -20,16 +24,19 @@ export type Theme = {
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed'; status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
progress: number; progress: number;
updateAvailable: boolean; updateAvailable: boolean;
definition?: ThemeV2Definition;
}; };
export type ThemeInfo = { export type ThemeInfo = {
name: string; name: string;
version: string; version?: string;
apiVersion: string; apiVersion?: string;
author: string; author?: string;
entry: string; description?: string;
style: string; nsfw?: boolean;
preview: string; entry?: string;
style?: string;
preview?: string;
}; };
type DownloadProgress = { type DownloadProgress = {
@@ -47,6 +54,44 @@ type ExtractProgress = {
current_file: string; current_file: string;
}; };
const THEME_REPOSITORY = 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes';
let themeAssetBasePromise: Promise<string> | undefined;
/**
* Linux cannot load theme media from the custom asset protocol, so the native
* side exposes a loopback HTTP base for theme files. Everywhere else this is
* empty and the normal asset protocol is used.
*/
export const themeAssetBase = (): Promise<string> => {
themeAssetBasePromise ??= invoke<string>('theme_asset_base').catch(() => '');
return themeAssetBasePromise;
};
const encodeThemePath = (value: string) =>
value
.replace(/\\/g, '/')
.replace(/^\.?\//, '')
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
export const themesDirectory = async () =>
await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
/**
* Replace a theme in the store instead of mutating it. The theme list is
* rendered from a filtered derived list, and Svelte treats each item as
* immutable there, so in-place mutation would not update the cards (e.g. the
* download status and progress would stay at their initial values).
*/
const patchTheme = (name: string, patch: (theme: Theme) => Theme) => {
custom_themes.update((themes) =>
themes.map((theme) => (theme.name === name ? patch(theme) : theme))
);
};
export const getDownloadableThemes = async () => { export const getDownloadableThemes = async () => {
const themes: Theme[] = []; const themes: Theme[] = [];
@@ -55,8 +100,11 @@ export const getDownloadableThemes = async () => {
name: string; name: string;
version: string; version: string;
author: string; author: string;
apiVersion?: string;
description?: string;
nsfw?: boolean;
}[] }[]
>('https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes.json', { >(`${THEME_REPOSITORY}/raw/branch/main/themes.json`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -71,10 +119,13 @@ export const getDownloadableThemes = async () => {
folder_name: `${toSafeName(theme.name)}-${generateRandomString(16)}`, folder_name: `${toSafeName(theme.name)}-${generateRandomString(16)}`,
name: theme.name, name: theme.name,
version: theme.version, version: theme.version,
apiVersion: theme.apiVersion ?? '1.0',
author: theme.author, author: theme.author,
description: theme.description ?? '',
nsfw: theme.nsfw === true,
scriptUrl: '', scriptUrl: '',
assets: '', assets: '',
preview: `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(theme.name)}.png`, preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(theme.name)}.png`,
status: 'not-installed', status: 'not-installed',
progress: 0, progress: 0,
updateAvailable: false, updateAvailable: false,
@@ -88,6 +139,83 @@ export const getDownloadableThemes = async () => {
return themes; return themes;
}; };
const themeFromFolder = async (
folderName: string,
folderPath: string
): Promise<Theme | undefined> => {
const themeConfigFilePath = await path.join(folderPath, 'theme.json');
if (!(await fs.exists(themeConfigFilePath))) return undefined;
try {
const themeConfig = await fs.readTextFile(themeConfigFilePath);
const parsed = JSON.parse(themeConfig) as ThemeInfo & Partial<ThemeV2Definition>;
if (!parsed.name) return undefined;
const apiVersion = parsed.apiVersion ?? '1.0';
const isV2 = isThemeV2(apiVersion);
const definition = isV2
? normalizeThemeV2(parsed as unknown as Partial<ThemeV2Definition>)
: undefined;
const httpBase = await themeAssetBase();
const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : '';
const entry = parsed.entry ?? '';
let scriptUrl = '';
if (!isV2 && entry) {
const scriptFile = await path.join(folderPath, entry);
if (!(await fs.exists(scriptFile))) return undefined;
scriptUrl = httpFolder
? `${httpFolder}/${encodeThemePath(entry)}`
: convertFileSrc(await path.normalize(scriptFile));
}
const style = parsed.style ?? '';
let cssStyle = '';
if (style) {
const cssStylePath = await path.join(folderPath, style);
if (await fs.exists(cssStylePath)) cssStyle = await fs.readTextFile(cssStylePath);
}
const assets = httpFolder
? `${httpFolder}/assets`
: convertFileSrc(await path.normalize(await path.join(folderPath, 'assets')));
const previewName = parsed.preview || (isV2 ? 'preview.png' : '');
let preview = '';
if (previewName) {
const previewPath = await path.join(folderPath, 'assets', previewName);
if (await fs.exists(previewPath)) {
// The editor rewrites preview.png on every save. A cache-busting query
// makes the WebView pick up the new file instead of its cached asset.
preview = httpFolder
? `${httpFolder}/assets/${encodeThemePath(previewName)}?v=${Date.now()}`
: `${convertFileSrc(await path.normalize(previewPath))}?v=${Date.now()}`;
}
}
return {
folder_name: folderName,
name: parsed.name,
version: parsed.version ?? '1.0.0',
apiVersion,
author: parsed.author ?? 'Unknown',
description: parsed.description ?? '',
nsfw: parsed.nsfw === true,
scriptUrl,
assets,
preview,
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle,
definition,
};
} catch (error) {
console.error(`Failed to read theme ${folderName}`, error);
return undefined;
}
};
export const getThemes = async (): Promise<Theme[]> => { export const getThemes = async (): Promise<Theme[]> => {
const themes: Theme[] = []; const themes: Theme[] = [];
themes.push({ themes.push({
@@ -95,6 +223,9 @@ export const getThemes = async (): Promise<Theme[]> => {
author: 'EZPPFarm', author: 'EZPPFarm',
name: 'Default', name: 'Default',
version: '1.0.0', version: '1.0.0',
apiVersion: '1.0',
description: 'The default EZPPLauncher look.',
nsfw: false,
scriptUrl: '', scriptUrl: '',
assets: '', assets: '',
preview: '', preview: '',
@@ -103,36 +234,16 @@ export const getThemes = async (): Promise<Theme[]> => {
updateAvailable: false, updateAvailable: false,
cssStyle: '', cssStyle: '',
}); });
const folderPath = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const folderPath = await themesDirectory();
if (await fs.exists(folderPath)) { if (await fs.exists(folderPath)) {
const themeFolders = await fs.readDir(folderPath); const themeFolders = await fs.readDir(folderPath);
for (const themeFolder of themeFolders) { for (const themeFolder of themeFolders) {
const themeFolderPath = await path.join(folderPath, themeFolder.name); const theme = await themeFromFolder(
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json'); themeFolder.name,
if (await fs.exists(themeConfigFilePath)) { await path.join(folderPath, themeFolder.name)
const themeConfig = await fs.readTextFile(themeConfigFilePath); );
const theme = JSON.parse(themeConfig) as ThemeInfo; if (theme) themes.push(theme);
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
const cssStylePath = await path.join(folderPath, themeFolder.name, theme.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
if (await fs.exists(scriptFile)) {
themes.push({
folder_name: themeFolder.name,
name: theme.name,
version: theme.version,
author: theme.author,
scriptUrl: convertFileSrc(await path.normalize(`${themeFolderPath}/${theme.entry}`)),
assets: convertFileSrc(await path.normalize(`${themeFolderPath}/assets`)),
preview: convertFileSrc(
await path.normalize(`${themeFolderPath}/assets/${theme.preview}`)
),
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle,
});
}
}
} }
} }
return themes; return themes;
@@ -144,11 +255,7 @@ export const downloadTheme = async (
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
if (theme.status !== 'not-installed' && !force) return { success: false }; if (theme.status !== 'not-installed' && !force) return { success: false };
custom_themes.update((themes) => { patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'downloading';
return themes;
});
const themeFileName = `${toSafeName(theme.name)}.ezpplauncher-theme`; const themeFileName = `${toSafeName(theme.name)}.ezpplauncher-theme`;
const themeContents = await betterFetch<{ const themeContents = await betterFetch<{
@@ -158,55 +265,56 @@ export const downloadTheme = async (
); );
if (themeContents.error) { if (themeContents.error) {
custom_themes.update((themes) => { patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
return { return {
success: false, success: false,
error: themeContents.error.message ?? 'An unknown error occurred', error: themeContents.error.message ?? 'An unknown error occurred',
}; };
} }
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes'); const baseThemeFolder = await themesDirectory();
const themeFolder = await path.join(baseThemeFolder, toSafeName(theme.name)); const folderName = toSafeName(theme.name);
const themeFolder = await path.join(baseThemeFolder, folderName);
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true }); if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true }); if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const downloadUrl = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${themeFileName}`; const downloadUrl = `${THEME_REPOSITORY}/raw/branch/main/themes/${themeFileName}`;
custom_themes.update((themes) => { patchTheme(theme.name, (current) => ({
const matchedTheme = themes.find((t) => t.name === theme.name); ...current,
if (matchedTheme) { status: 'downloading',
matchedTheme.status = 'downloading'; progress: 0,
matchedTheme.progress = 0; }));
}
return themes; let extractionStartedAt = 0;
}); const beginExtraction = (progress: number) => {
if (extractionStartedAt === 0) extractionStartedAt = Date.now();
patchTheme(theme.name, (current) => ({
...current,
status: 'extracting',
progress,
}));
};
const unlistenDownload = await listen<DownloadProgress>('download_progress', (event) => { const unlistenDownload = await listen<DownloadProgress>('download_progress', (event) => {
if (event.payload.theme_name !== theme.name) return; if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name); if (event.payload.total > 0 && event.payload.received >= event.payload.total) {
if (matchedTheme) { beginExtraction(0);
matchedTheme.status = 'downloading'; return;
matchedTheme.progress = event.payload.progress;
} }
return themes;
}); patchTheme(theme.name, (current) => ({
...current,
status: 'downloading',
progress: event.payload.progress,
}));
}); });
const unlistenExtract = await listen<ExtractProgress>('extract_progress', (event) => { const unlistenExtract = await listen<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== theme.name) return; if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => { beginExtraction(event.payload.progress);
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'extracting';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
}); });
try { try {
@@ -219,50 +327,55 @@ export const downloadTheme = async (
} catch (err) { } catch (err) {
console.log(err); console.log(err);
const erro = err as Error; const erro = err as Error;
custom_themes.update((themes) => { patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
return { success: false, error: erro.message || 'Failed to download or extract theme.' }; return { success: false, error: erro.message || 'Failed to download or extract theme.' };
} finally { } finally {
unlistenDownload(); unlistenDownload();
unlistenExtract(); unlistenExtract();
} }
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json')); // Small themes can extract in a few milliseconds, before the webview paints
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo; // the extracting state. Keep it visible long enough to be readable, while
const themeScriptUrl = convertFileSrc( // still letting large themes finish naturally.
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`) if (extractionStartedAt === 0) {
); beginExtraction(0);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
matchedTheme.folder_name = toSafeName(theme.name);
} }
return themes;
}); const minimumExtractionMs = 700;
const elapsed = Date.now() - extractionStartedAt;
if (elapsed < minimumExtractionMs) {
await new Promise((resolve) => setTimeout(resolve, minimumExtractionMs - elapsed));
}
const installedTheme = await themeFromFolder(folderName, themeFolder);
if (!installedTheme) {
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
return { success: false, error: 'The downloaded theme package is invalid.' };
}
patchTheme(theme.name, (current) => ({
...current,
...installedTheme,
status: 'installed',
progress: 1,
}));
await reloadThemes(); await reloadThemes();
return { success: true }; return { success: true };
}; };
export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => { export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => {
// Theme media is buffered as blob URLs; the previous theme's URLs are no
// longer needed once it is replaced.
clearThemeMediaCache();
active_custom_theme.set(theme); active_custom_theme.set(theme);
custom_theme_audio_playing.set(false);
themeContainer.innerHTML = ''; themeContainer.innerHTML = '';
if (theme.definition) {
setGlobalVolume(volume);
disableMediaControls();
return;
}
if (theme.scriptUrl.length > 0) { if (theme.scriptUrl.length > 0) {
const themeScript = await import(/* @vite-ignore */ theme.scriptUrl); const themeScript = await import(/* @vite-ignore */ theme.scriptUrl);
themeScript.mountTheme(themeContainer, { assets: theme.assets }); themeScript.mountTheme(themeContainer, { assets: theme.assets });
@@ -275,7 +388,7 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
if (themeToUninstall.status !== 'installed') { if (themeToUninstall.status !== 'installed') {
return false; return false;
} }
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes'); const baseThemeFolder = await themesDirectory();
if (!(await fs.exists(baseThemeFolder))) { if (!(await fs.exists(baseThemeFolder))) {
return false; return false;
} }
@@ -283,27 +396,19 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
if (!(await fs.exists(themeFolder))) { if (!(await fs.exists(themeFolder))) {
return false; return false;
} }
custom_themes.update((themes) => { patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'deleting' }));
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.status = 'deleting';
}
return themes;
});
await fs.remove(themeFolder, { recursive: true }); await fs.remove(themeFolder, { recursive: true });
custom_themes.update((themes) => { patchTheme(themeToUninstall.name, (current) => ({
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name); ...current,
if (matchedTheme) { scriptUrl: '',
matchedTheme.scriptUrl = ''; cssStyle: '',
matchedTheme.cssStyle = ''; assets: '',
matchedTheme.assets = ''; definition: undefined,
matchedTheme.preview = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`; preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`,
matchedTheme.status = 'not-installed'; status: 'not-installed',
matchedTheme.updateAvailable = false; updateAvailable: false,
} }));
return themes;
});
await reloadThemes(); await reloadThemes();
return true; return true;
@@ -327,18 +432,14 @@ export const checkThemeFromFile = async (filePath: string) => {
}; };
export const importThemeFromFile = async (themeName: string, filePath: string) => { export const importThemeFromFile = async (themeName: string, filePath: string) => {
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes'); const baseThemeFolder = await themesDirectory();
const themeFolder = await path.join(baseThemeFolder, toSafeName(themeName)); const themeFolder = await path.join(baseThemeFolder, toSafeName(themeName));
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true }); if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true }); if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const unlisten = await listen<ExtractProgress>('extract_progress', (event) => { const unlisten = await listen<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== themeName) return; if (event.payload.theme_name !== themeName) return;
custom_themes.update((themes) => { patchTheme(themeName, (current) => ({ ...current, progress: event.payload.progress }));
const matchedTheme = themes.find((t) => t.name === themeName);
if (matchedTheme) matchedTheme.progress = event.payload.progress;
return themes;
});
}); });
try { try {
@@ -354,30 +455,9 @@ export const importThemeFromFile = async (themeName: string, filePath: string) =
unlisten(); unlisten();
} }
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json')); if (!(await themeFromFolder(toSafeName(themeName), themeFolder))) {
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo; return { success: false, error: 'The theme package is missing its theme.json or entry file.' };
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeInfoObj.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
} }
return themes;
});
await reloadThemes(); await reloadThemes();
return { success: true }; return { success: true };
@@ -396,9 +476,13 @@ export const reloadThemes = async () => {
const installed = installedMap.get(downloadable.name); const installed = installedMap.get(downloadable.name);
if (installed) { if (installed) {
try {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) { if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true; combinedMap.get(downloadable.name)!.updateAvailable = true;
} }
} catch {
// Ignore malformed versions instead of breaking the whole theme list.
}
} else { } else {
combinedMap.set(downloadable.name, downloadable); combinedMap.set(downloadable.name, downloadable);
} }
@@ -410,21 +494,31 @@ export const reloadThemes = async () => {
const storeMap = new Map(storeThemes.map((t) => [t.name, t])); const storeMap = new Map(storeThemes.map((t) => [t.name, t]));
const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']); const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']);
for (const theme of combinedThemes) { const updatedThemes = storeThemes.map((storeTheme) => {
const storeTheme = storeMap.get(theme.name); if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme;
if (!storeTheme || ACTIVE_STATUSES.has(storeTheme.status)) continue; const theme = combinedMap.get(storeTheme.name);
if (!theme) return storeTheme;
storeTheme.scriptUrl = theme.scriptUrl; // Replace the object rather than mutating it so the theme cards see the
storeTheme.assets = theme.assets; // new values when the list is rendered from a filtered derived array.
storeTheme.preview = theme.preview; return {
storeTheme.version = theme.version; ...storeTheme,
storeTheme.author = theme.author; scriptUrl: theme.scriptUrl,
storeTheme.updateAvailable = theme.updateAvailable; assets: theme.assets,
storeTheme.cssStyle = theme.cssStyle; preview: theme.preview,
} version: theme.version,
author: theme.author,
apiVersion: theme.apiVersion,
description: theme.description,
nsfw: theme.nsfw,
definition: theme.definition,
updateAvailable: theme.updateAvailable,
cssStyle: theme.cssStyle,
};
});
const newThemes = combinedThemes.filter((t) => !storeMap.has(t.name)); const newThemes = combinedThemes.filter((t) => !storeMap.has(t.name));
const merged = [...storeThemes, ...newThemes]; const merged = [...updatedThemes, ...newThemes];
return merged.sort((a, b) => { return merged.sort((a, b) => {
if (a.name === 'Default') return -1; if (a.name === 'Default') return -1;
@@ -434,6 +528,39 @@ export const reloadThemes = async () => {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
}); });
active_custom_theme.update((active) => {
if (!active) return active;
const updated = combinedMap.get(active.name);
return updated ? { ...active, ...updated } : active;
});
};
export const saveThemeProject = async (
folderName: string,
definition: ThemeV2Definition,
assets: ThemeV2Asset[] = []
) => {
const themeFolder = await path.join(await themesDirectory(), folderName);
await invoke('save_theme_project', {
themeFolder,
config: JSON.stringify(definition, null, 2),
assets,
});
return themeFolder;
};
export const exportThemeFolder = async (folderName: string, destinationPath: string) => {
const themeFolder = await path.join(await themesDirectory(), folderName);
await invoke('export_theme', { themeFolder, destinationPath });
};
export const exportTheme = async (theme: Theme, destinationPath: string) =>
exportThemeFolder(theme.folder_name, destinationPath);
export const openThemesFolder = async () => {
const folder = await themesDirectory();
await invoke('open_theme_folder', { themeFolder: folder });
}; };
export const toSafeName = (name: string) => { export const toSafeName = (name: string) => {
+1
View File
@@ -5,6 +5,7 @@ export const cursorSmoothening = writable<boolean>(false);
export const cursorSmoothness = writable<number>(180); export const cursorSmoothness = writable<number>(180);
export const reduceAnimations = writable<boolean>(false); export const reduceAnimations = writable<boolean>(false);
export const patch = writable<boolean>(true); export const patch = writable<boolean>(true);
export const loggingEnabled = writable<boolean>(true);
export const osuInstallationPath = writable<string>(''); export const osuInstallationPath = writable<string>('');
+3 -1
View File
@@ -163,7 +163,9 @@ export function trackMediaContainer(node: HTMLElement) {
const media = new Set<HTMLMediaElement>(); const media = new Set<HTMLMediaElement>();
function update() { function update() {
custom_theme_audio_playing.set(Array.from(media).some((el) => !el.paused)); custom_theme_audio_playing.set(
Array.from(media).some((el) => !el.paused && !(el instanceof HTMLVideoElement && el.muted))
);
} }
function register(el: HTMLMediaElement) { function register(el: HTMLMediaElement) {
+70 -15
View File
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment';
import Logo from '$assets/logo.png'; import Logo from '$assets/logo.png';
import ThemeEditorWindow from '$lib/components/theme/ThemeEditorWindow.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte'; import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte';
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
@@ -24,12 +27,22 @@
setupValues, setupValues,
trackingEnabled, trackingEnabled,
} from '$lib/global'; } from '$lib/global';
import { exit, getLauncherVersion, getPlatform } from '$lib/osuUtil'; import { initLogging } from '$lib/logging';
import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil';
import * as presence from '$lib/presence'; import * as presence from '$lib/presence';
import { smoothThemeMedia } from '$lib/themeMedia';
import { themeV2Css } from '$lib/themeV2';
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes'; import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
import { cursorSmoothening, customCursor, patch, reduceAnimations } from '$lib/userSettings'; import {
cursorSmoothening,
customCursor,
loggingEnabled,
patch,
reduceAnimations,
} from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils'; import { trackMediaContainer } from '$lib/utils';
import '../app.css'; import '../app.css';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { sileo } from 'sileo'; import { sileo } from 'sileo';
@@ -38,6 +51,17 @@
let { children } = $props(); let { children } = $props();
if (browser) initLogging();
const editorWindow = (() => {
if (!browser) return false;
try {
return getCurrentWindow().label === 'theme-editor';
} catch {
return false;
}
})();
let unsupported_platform = $state<boolean>(false); let unsupported_platform = $state<boolean>(false);
function disableReload() { function disableReload() {
@@ -83,12 +107,18 @@
} }
onMount(async () => { onMount(async () => {
if (editorWindow) return;
window.Buffer = Buffer; window.Buffer = Buffer;
disableReload(); disableReload();
setupValues(); setupValues();
launcherVersion.set(await getLauncherVersion()); launcherVersion.set(await getLauncherVersion());
const currentPlatform = await getPlatform();
platform.set(currentPlatform);
if (!['windows', 'linux', 'macos'].includes(currentPlatform)) unsupported_platform = true;
const oldUserConfig = new Config('user_settings', false); const oldUserConfig = new Config('user_settings', false);
const oldUserConfigExists = await oldUserConfig.init(); const oldUserConfigExists = await oldUserConfig.init();
if (oldUserConfigExists) { if (oldUserConfigExists) {
@@ -127,6 +157,7 @@
const config_custom_cursor = config.value<boolean>('custom_cursor'); const config_custom_cursor = config.value<boolean>('custom_cursor');
const config_cursor_smoothening = config.value<boolean>('cursor_smoothening'); const config_cursor_smoothening = config.value<boolean>('cursor_smoothening');
const config_reduce_animations = config.value<boolean>('reduce_animations'); const config_reduce_animations = config.value<boolean>('reduce_animations');
const config_logging_enabled = config.value<boolean>('logging_enabled');
const config_discord_presence = config.value<boolean>('discord_presence'); const config_discord_presence = config.value<boolean>('discord_presence');
const config_tracking_enabled = config.value<boolean>('tracking_consent'); const config_tracking_enabled = config.value<boolean>('tracking_consent');
const config_otd_enabled = config.value<boolean>('otd_enabled'); const config_otd_enabled = config.value<boolean>('otd_enabled');
@@ -135,7 +166,7 @@
const localThemes = await getThemes(); const localThemes = await getThemes();
const lastThemeName = await config_theme.get('Default'); const lastThemeName = await config_theme.get('Default');
const last_theme = localThemes.find((t) => t.name === lastThemeName); const last_theme = localThemes.find((t) => t.name === lastThemeName);
if (!last_theme || $platform === 'linux') { if (!last_theme) {
await config.value<string>('theme').set('Default'); await config.value<string>('theme').set('Default');
loadTheme(localThemes[0], $custom_theme_container!, await config_theme_volume.get(0.15)); loadTheme(localThemes[0], $custom_theme_container!, await config_theme_volume.get(0.15));
} else { } else {
@@ -147,6 +178,7 @@
customCursor.set(await config_custom_cursor.get(true)); customCursor.set(await config_custom_cursor.get(true));
cursorSmoothening.set(await config_cursor_smoothening.get(true)); cursorSmoothening.set(await config_cursor_smoothening.get(true));
reduceAnimations.set(await config_reduce_animations.get(false)); reduceAnimations.set(await config_reduce_animations.get(false));
loggingEnabled.set(await config_logging_enabled.get(true));
discordPresence.set(await config_discord_presence.get(true)); discordPresence.set(await config_discord_presence.get(true));
openTabletDriverEnabled.set(await config_otd_enabled.get(false)); openTabletDriverEnabled.set(await config_otd_enabled.get(false));
openTabletDriverPath.set(await config_otd_path.get('')); openTabletDriverPath.set(await config_otd_path.get(''));
@@ -158,6 +190,10 @@
customCursor.subscribe(async (val) => await config_custom_cursor.set(val)); customCursor.subscribe(async (val) => await config_custom_cursor.set(val));
cursorSmoothening.subscribe(async (val) => await config_cursor_smoothening.set(val)); cursorSmoothening.subscribe(async (val) => await config_cursor_smoothening.set(val));
reduceAnimations.subscribe(async (val) => await config_reduce_animations.set(val)); reduceAnimations.subscribe(async (val) => await config_reduce_animations.set(val));
loggingEnabled.subscribe(async (val) => {
await config_logging_enabled.set(val);
await setLoggingEnabled(val);
});
openTabletDriverEnabled.subscribe(async (val) => config_otd_enabled.set(val)); openTabletDriverEnabled.subscribe(async (val) => config_otd_enabled.set(val));
discordPresence.subscribe(async (val) => { discordPresence.subscribe(async (val) => {
@@ -221,21 +257,21 @@
description: 'Failed to load themes', description: 'Failed to load themes',
}); });
} }
platform.set(await getPlatform());
if ($platform !== 'windows' && $platform !== 'linux') unsupported_platform = true;
}); });
</script> </script>
<svelte:head> <svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${$active_custom_theme?.cssStyle || ''}</style>`} {@html `<style>${$active_custom_theme?.cssStyle || ''}</style>`}
{#if $active_custom_theme?.definition}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${themeV2Css(
$active_custom_theme.definition,
$active_custom_theme.assets
)}</style>`}
{/if}
</svelte:head> </svelte:head>
{#if $customCursor}
<OsuCursor smoothCursor={$cursorSmoothening} />
{/if}
<SileoToast <SileoToast
position="top-center" position="top-center"
options={{ options={{
@@ -246,9 +282,16 @@
}} }}
/> />
<Titlebar /> {#if editorWindow}
<ThemeEditorWindow />
{:else}
{#if $customCursor}
<OsuCursor smoothCursor={$cursorSmoothening} />
{/if}
<AlertDialog.Root open={unsupported_platform}> <Titlebar />
<AlertDialog.Root open={unsupported_platform}>
<AlertDialog.Content class="bg-theme-950 border-theme-800 p-0"> <AlertDialog.Content class="bg-theme-950 border-theme-800 p-0">
<div <div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3" class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
@@ -269,9 +312,10 @@
> >
</div> </div>
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
<main> <main>
<div class:hidden={!!$active_custom_theme?.definition}>
<div class="opacity-30"> <div class="opacity-30">
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}> <div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
<div <div
@@ -280,11 +324,22 @@
: 'absolute z-0 top-0 left-0 w-full h-full object-cover object-center aspect-video'} : 'absolute z-0 top-0 left-0 w-full h-full object-cover object-center aspect-video'}
bind:this={$custom_theme_container} bind:this={$custom_theme_container}
use:trackMediaContainer use:trackMediaContainer
use:smoothThemeMedia
></div> ></div>
{#if ($active_custom_theme && $active_custom_theme.name === 'Default') || !$active_custom_theme} {#if ($active_custom_theme && $active_custom_theme.name === 'Default') || !$active_custom_theme}
<AnimatedBg /> <AnimatedBg />
{/if} {/if}
</div> </div>
</div> </div>
</div>
{#if $active_custom_theme?.definition}
<div class="absolute inset-0 z-0">
<ThemeV2Background
definition={$active_custom_theme.definition}
assets={$active_custom_theme.assets}
/>
</div>
{/if}
{@render children()} {@render children()}
</main> </main>
{/if}
+624 -232
View File
File diff suppressed because it is too large. Load diff
+567 -244
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import Logo from '$assets/logo.png'; import Logo from '$assets/logo.png';
import ConfettiCannon from '$lib/components/confetti/ConfettiCannon.svelte';
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte'; import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/config'; import { config } from '$lib/config';
import { import {
@@ -13,6 +13,7 @@
discordPresence, discordPresence,
osuBuild, osuBuild,
osuStream, osuStream,
platform,
presenceLoading, presenceLoading,
skinsCount, skinsCount,
} from '$lib/global'; } from '$lib/global';
@@ -23,7 +24,8 @@
getSkin, getSkin,
getSkinsCount, getSkinsCount,
getVersion, getVersion,
isValidOsuFolder, pickOsuInstallation,
resolveOsuFolder,
} from '$lib/osuUtil'; } from '$lib/osuUtil';
import { import {
cursorSmoothening, cursorSmoothening,
@@ -33,345 +35,666 @@
reduceAnimations, reduceAnimations,
} from '$lib/userSettings'; } from '$lib/userSettings';
import Launch from './Launch.svelte'; import Launch from './Launch.svelte';
import { Check, CircleCheckBig, CircleOff, LoaderCircle } from '@lucide/svelte'; import {
import { open } from '@tauri-apps/plugin-dialog'; ArrowLeft,
import { animate } from 'animejs'; ArrowRight,
Check,
CircleAlert,
CircleCheckBig,
FolderOpen,
Info,
LoaderCircle,
Palette,
RefreshCw,
Rocket,
Search,
} from '@lucide/svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Confetti from 'svelte-confetti'; import { fly } from 'svelte/transition';
import { fade } from 'svelte/transition';
let selectedStep = $state(1); const steps = ['Welcome', 'osu! installation', 'Personalize'];
const steps = ['Welcome', 'Locate your osu! Installation', 'Appearance Settings'];
type DetectionStatus = 'detecting' | 'valid' | 'invalid' | 'missing';
let stepIndex = $state(0);
let osuInstallPath = $state(''); let osuInstallPath = $state('');
let manualSelect = $state(false); let manualSelection = $state(false);
let manualSelectValid = $state(false); let selectionError = $state(false);
let autoDetectedOsuPath = $state(false); let detecting = $state(true);
let wizardFinished = $state(false); let detectionFinished = $state(false);
let selecting = $state(false);
let saving = $state(false);
let saveError = $state<string | undefined>(undefined);
let finished = $state(false);
let detectionRun = 0;
let ezppLogo: HTMLImageElement | undefined = $state(undefined); const stepNumber = $derived(stepIndex + 1);
const logo_mouseenter = () => { const status: DetectionStatus = $derived(
if (ezppLogo) { detecting
animate(ezppLogo, { ? 'detecting'
duration: 700, : selectionError
scale: 1.2, ? 'invalid'
ease: (t: number) => Math.pow(2, -5 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t, : osuInstallPath
}); ? 'valid'
: detectionFinished
? 'missing'
: 'detecting'
);
const statusTitle = $derived(
status === 'detecting'
? 'Looking for osu!'
: status === 'valid'
? manualSelection
? 'osu! installation selected'
: 'osu! found'
: status === 'invalid'
? 'No osu! in that folder'
: 'osu! not found yet'
);
const statusDescription = $derived(
status === 'detecting'
? 'Checking your applications and Wine bottles…'
: status === 'valid'
? manualSelection
? 'That folder contains a valid osu! installation.'
: 'This installation was found automatically.'
: status === 'invalid'
? $platform === 'macos'
? 'Pick the osu!.app wrapper, or the folder that contains osu!.exe.'
: 'Pick the folder that contains osu!.exe.'
: 'Browse to your installation to continue.'
);
const statusBorderClass = $derived(
status === 'valid'
? 'border-emerald-500/25 bg-emerald-500/5'
: status === 'invalid'
? 'border-red-500/25 bg-red-500/5'
: 'border-theme-800/70 bg-black/15'
);
const statusIconClass = $derived(
status === 'valid'
? 'bg-emerald-500/10 text-emerald-400'
: status === 'invalid'
? 'bg-red-500/10 text-red-400'
: status === 'missing'
? 'bg-amber-500/10 text-amber-400'
: 'bg-primary/10 text-primary'
);
const canContinue = $derived(stepIndex !== 1 || osuInstallPath.length > 0 || dev);
const detectOsu = async () => {
const run = ++detectionRun;
detecting = true;
selectionError = false;
try {
const detectedPath = await autoDetectOsuInstallFolder();
if (run !== detectionRun) return;
if (detectedPath) {
osuInstallPath = detectedPath;
manualSelection = false;
}
} catch (error) {
console.warn('Could not auto-detect the osu! installation.', error);
} finally {
if (run === detectionRun) {
detecting = false;
detectionFinished = true;
}
} }
}; };
const logo_mouseleave = () => { const browseOsu = async () => {
if (ezppLogo) { if (selecting) return;
animate(ezppLogo, {
duration: 700,
scale: 1,
ease: (t: number) => (t - 1) ** 7 + 1,
});
}
};
const browse_osu_installation = async () => { detectionRun += 1;
const selectedPath = await open({ detecting = false;
directory: true, detectionFinished = true;
multiple: false, selectionError = false;
title: 'Select osu! Installation Folder', selecting = true;
});
if (typeof selectedPath === 'string') { try {
const validFolder = await isValidOsuFolder(selectedPath); const selectedPath = await pickOsuInstallation($platform);
manualSelect = true; if (typeof selectedPath !== 'string') return;
if (!validFolder) {
manualSelectValid = false; const resolvedPath = await resolveOsuFolder(selectedPath);
if (!resolvedPath) {
osuInstallPath = ''; osuInstallPath = '';
manualSelection = false;
selectionError = true;
return; return;
} }
osuInstallPath = selectedPath;
autoDetectedOsuPath = false; osuInstallPath = resolvedPath;
manualSelectValid = true; manualSelection = true;
} catch (error) {
console.error('Could not select the osu! installation.', error);
osuInstallPath = '';
manualSelection = false;
selectionError = true;
} finally {
selecting = false;
} }
}; };
const nextStep = () => {
if (stepIndex < steps.length - 1) {
stepIndex += 1;
return;
}
finished = true;
};
const previousStep = () => {
if (finished) {
finished = false;
return;
}
stepIndex = Math.max(stepIndex - 1, 0);
};
const saveConfig = async () => { const saveConfig = async () => {
if (saving) return;
saving = true;
saveError = undefined;
try {
await config.value<string>('osu_installation_path').set(osuInstallPath); await config.value<string>('osu_installation_path').set(osuInstallPath);
osuInstallationPath.set(osuInstallPath); osuInstallationPath.set(osuInstallPath);
await config.value<boolean>('setup_complete').set(true); await config.value<boolean>('setup_complete').set(true);
const beatmapSetCount = await getBeatmapSetsCount(osuInstallPath); if (osuInstallPath.length > 0) {
if (beatmapSetCount) { try {
beatmapSets.set(beatmapSetCount); const [beatmapSetCount, skinCount, skin, stream, version] = await Promise.all([
getBeatmapSetsCount(osuInstallPath),
getSkinsCount(osuInstallPath),
getSkin(osuInstallPath),
getReleaseStream(osuInstallPath),
getVersion(osuInstallPath),
]);
beatmapSets.set(beatmapSetCount ?? 0);
skinsCount.set(skinCount ?? 0);
currentSkin.set(skin ?? '');
osuStream.set(stream);
osuBuild.set(version);
} catch (error) {
console.warn('Could not load the osu! installation details.', error);
} }
const skins_count = await getSkinsCount(osuInstallPath);
if (skins_count) {
/* skins.set(skins_count); */
skinsCount.set(skins_count);
} else {
skinsCount.set(0);
} }
const skin: string = await getSkin(osuInstallPath);
currentSkin.set(skin);
const osuReleaseStream = await getReleaseStream(osuInstallPath);
osuStream.set(osuReleaseStream);
const osuVersion = await getVersion(osuInstallPath);
osuBuild.set(osuVersion);
currentView.set(Launch); currentView.set(Launch);
} catch (error) {
console.error('Could not finish the setup.', error);
saveError = error instanceof Error ? error.message : String(error);
saving = false;
}
}; };
onMount(async () => { onMount(() => {
const osuPath = await autoDetectOsuInstallFolder(); void detectOsu();
if (osuPath) {
osuInstallPath = osuPath;
autoDetectedOsuPath = true;
await config.value<string>('osu_installation_path').set(osuInstallPath);
}
}); });
</script> </script>
{#if wizardFinished} {#if finished}
<div class="relative flex flex-col items-center justify-center h-screen z-50"> <div
<div class="absolute h-fit w-fit top-1/2 left-1/2"> class="relative flex h-screen w-full flex-col items-center justify-center overflow-hidden px-6"
<Confetti amount={200} y={[-1, 1.5]} x={[-2.3, 2.3]} colorArray={['#C6A0F3']} /> >
<div
class="pointer-events-none absolute -top-32 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-primary/15 blur-[110px]"
></div>
{#if !$reduceAnimations}
<ConfettiCannon count={820} colors={['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24']} />
{/if}
<div
class="relative z-10 w-full max-w-md overflow-hidden rounded-2xl border border-theme-800/80 bg-theme-950/75 shadow-2xl shadow-black/40 backdrop-blur-xl"
>
<div class="flex flex-col items-center px-7 py-8 text-center">
<div
class="rounded-3xl bg-primary/20 p-3 ring-1 ring-inset ring-primary/25 shadow-xl shadow-primary/10"
>
<img src={Logo} alt="EZPPLauncher logo" class="size-20 object-contain" />
</div> </div>
<h1 class="text-3xl font-semibold">EZPPLauncher Setup completed!</h1> <h1 class="mt-5 text-2xl font-bold tracking-tight">You're all set!</h1>
<p class="text-muted-foreground mt-2">You are now ready to farm some maps!</p> <p class="mt-1.5 text-sm text-muted-foreground">
<Button class="mt-4" onclick={saveConfig}>Finish</Button> Everything is configured. Time to farm some pp.
</p>
{#if osuInstallPath}
<div
class="mt-5 w-full rounded-xl border border-theme-800/70 bg-black/20 px-3 py-2.5 text-left"
>
<div
class="flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase"
>
<FolderOpen class="size-3" />
osu! installation
</div>
<p class="mt-1 truncate font-mono text-[11px] text-theme-200" title={osuInstallPath}>
{osuInstallPath}
</p>
</div>
{/if}
{#if saveError}
<div
class="mt-4 flex w-full items-start gap-2 rounded-lg border border-red-500/20 bg-red-500/5 p-2.5 text-left text-xs text-red-300"
>
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<span>{saveError}</span>
</div>
{/if}
</div>
<div
class="flex items-center justify-between gap-3 border-t border-theme-800/70 bg-black/20 px-5 py-3.5"
>
<Button
variant="ghost"
class="text-muted-foreground hover:text-foreground"
onclick={previousStep}
disabled={saving}
>
<ArrowLeft />
Back
</Button>
<Button onclick={saveConfig} disabled={saving || (osuInstallPath.length === 0 && !dev)}>
{#if saving}
<LoaderCircle class="animate-spin" />
Saving…
{:else}
<Rocket />
Start farming
{/if}
</Button>
</div>
</div>
<p class="relative z-10 mt-4 text-[11px] text-muted-foreground">See you on the leaderboards.</p>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-[0.41fr_1fr] h-screen z-50"> <div class="relative h-screen w-full overflow-x-hidden overflow-y-auto">
<div <div
class="w-full h-full border-r border-black/40 bg-black/40 backdrop-blur-md flex flex-col gap-6 p-3 z-50" class="pointer-events-none absolute -top-32 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-primary/15 blur-[110px]"
> ></div>
{#each steps as step, i (step)}
<div <div
class="flex flex-row items-center gap-2 border {selectedStep === i + 1 class="relative z-10 mx-auto flex min-h-full w-full max-w-2xl flex-col justify-center px-6 pt-10 pb-3"
? 'border-primary-600/30 bg-primary-700/30'
: selectedStep > i
? 'border-green-800/30 bg-green-900/30'
: 'border-black/30 bg-black/30'} rounded-lg p-2 transition-all"
> >
<header class="mb-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div <div
class="flex flex-col items-center justify-center h-8 w-8 border-2 {selectedStep > i + 1 class="rounded-2xl bg-primary/20 p-1 ring-1 ring-inset ring-primary/25 shadow-lg shadow-primary/10"
? 'border-green-600'
: 'border-theme-600'} rounded-full"
> >
{#if selectedStep > i + 1} <img src={Logo} alt="EZPPLauncher logo" class="size-10 object-contain" />
<Check class="mt-0.5 text-green-400" /> </div>
{:else} <div>
<span class="text-lg font-semibold text-theme-100">{i + 1}</span> <p class="text-[10px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">
{/if} EZPPLauncher setup
</p>
<p class="text-sm font-semibold tracking-tight">Welcome aboard</p>
</div>
</div> </div>
<span <span
class="{selectedStep === i + 1 class="rounded-full border border-theme-800/80 bg-theme-950/60 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
? 'text-white'
: selectedStep > i
? 'text-green-500'
: "'text-muted-foreground'"} transition-all text-sm font-bold">{step}</span
> >
</div> Step {stepNumber} of {steps.length}
</span>
</header>
<ol class="mb-3 grid grid-cols-3 gap-1.5" aria-label="Setup progress">
{#each steps as step, index (step)}
<li
class="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors {index ===
stepIndex
? 'border-primary/30 bg-primary/10'
: index < stepIndex
? 'border-theme-800/70 bg-theme-900/50'
: 'border-theme-800/40 bg-black/10'}"
aria-current={index === stepIndex ? 'step' : undefined}
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold {index <
stepIndex
? 'bg-primary text-primary-foreground'
: index === stepIndex
? 'bg-primary/20 text-primary ring-1 ring-primary/30 ring-inset'
: 'bg-theme-800 text-muted-foreground'}"
>
{#if index < stepIndex}
<Check class="size-3" />
{:else}
{index + 1}
{/if}
</span>
<span
class="truncate text-[11px] font-semibold {index === stepIndex
? 'text-foreground'
: index < stepIndex
? 'text-theme-200'
: 'text-muted-foreground'}">{step}</span
>
</li>
{/each} {/each}
</div> </ol>
<div class="flex flex-col gap-6 w-full h-full backdrop-blur-md p-6 z-50">
{#if selectedStep === 1}
<div <div
class="my-auto h-full w-full bg-black/40 rounded-lg border border-black/40 p-6 mt-5 flex flex-col items-center justify-center" class="overflow-hidden rounded-2xl border border-theme-800/80 bg-theme-950/75 shadow-2xl shadow-black/40 backdrop-blur-xl"
in:fade={{ duration: $reduceAnimations ? 0 : 200 }}
> >
<img <div class="p-5">
src={Logo} {#key stepIndex}
alt="EZPPLauncher Logo" <div in:fly={{ y: 6, duration: $reduceAnimations ? 0 : 200, opacity: 0 }}>
class="w-52 h-52 mb-2" {#if stepIndex === 0}
bind:this={ezppLogo} <div class="flex min-h-[332px] flex-col">
onmouseenter={logo_mouseenter} <div>
onmouseleave={logo_mouseleave} <p class="text-[10px] font-semibold tracking-[0.2em] text-primary uppercase">
/> Welcome
<h1 class="text-3xl font-semibold">Welcome to EZPPLauncher!</h1>
<p class="text-muted-foreground mt-2">
This setup wizard will guide you through the initial setup of EZPPLauncher.
</p> </p>
<div class="bg-red-800 border border-red-900 text-red-200 p-4 rounded-lg mt-4"> <h2 class="mt-1.5 text-2xl font-bold tracking-tight">Let's get you farming.</h2>
Please make sure you have osu! installed on your system before proceeding. <p class="mt-1.5 max-w-lg text-sm leading-relaxed text-muted-foreground">
</div> EZPPLauncher keeps osu! updated, patched and looking the way you want. This
</div> takes less than a minute.
{:else if selectedStep === 2}
<div
class="my-auto h-full w-full bg-black/40 rounded-lg border border-black/40 p-6 mt-5 flex flex-col items-center justify-center"
in:fade={{ duration: $reduceAnimations ? 0 : 200 }}
>
<h1 class="text-3xl font-semibold">Locate your osu! Installation</h1>
<p class="text-muted-foreground mt-2">
Please select the folder where your osu! installation is located.
</p> </p>
<div class="flex flex-row w-full">
<Input
class="mt-4 w-full bg-theme-950 border-theme-800 border-r-0 rounded-r-none"
type="text"
placeholder="Path to osu! installation"
value={osuInstallPath}
/>
<Button
class="mt-4 bg-theme-950 border-theme-800 rounded-l-none"
variant="outline"
onclick={browse_osu_installation}>Browse</Button
>
</div> </div>
{#if !manualSelect}
{#if autoDetectedOsuPath} <div class="mt-4 grid gap-1.5">
<div <div
class="flex flex-row gap-3 bg-green-800 border border-green-900 text-green-500 p-4 rounded-lg mt-4" class="flex items-start gap-3 rounded-xl border border-theme-800/60 bg-black/15 p-2.5"
> >
<CircleCheckBig /> <div
<span>Auto-detected osu! installation path! Please check if its correct!</span> class="rounded-lg bg-primary/10 p-2 text-primary ring-1 ring-primary/15 ring-inset"
>
<Rocket class="size-4" />
</div> </div>
<div class="min-w-0">
<p class="text-[13px] leading-tight font-semibold">One-click launch</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Checks for updates, applies the patch and starts osu!.
</p>
</div>
</div>
<div
class="flex items-start gap-3 rounded-xl border border-theme-800/60 bg-black/15 p-2.5"
>
<div
class="rounded-lg bg-primary/10 p-2 text-primary ring-1 ring-primary/15 ring-inset"
>
<Palette class="size-4" />
</div>
<div class="min-w-0">
<p class="text-[13px] leading-tight font-semibold">Make it yours</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Themes with images, videos and custom fonts.
</p>
</div>
</div>
<div
class="flex items-start gap-3 rounded-xl border border-theme-800/60 bg-black/15 p-2.5"
>
<div
class="rounded-lg bg-primary/10 p-2 text-primary ring-1 ring-primary/15 ring-inset"
>
<RefreshCw class="size-4" />
</div>
<div class="min-w-0">
<p class="text-[13px] leading-tight font-semibold">Always up to date</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Keeps launcher files in sync before every launch.
</p>
</div>
</div>
</div>
<div class="mt-auto flex items-start gap-2.5 pt-3">
<Info class="mt-0.5 size-3.5 shrink-0 text-primary" />
<p class="text-xs leading-relaxed text-muted-foreground">
Make sure osu! is installed and signed in before continuing.
</p>
</div>
</div>
{:else if stepIndex === 1}
<div class="flex min-h-[332px] flex-col">
<div>
<p class="text-[10px] font-semibold tracking-[0.2em] text-primary uppercase">
osu! installation
</p>
<h2 class="mt-1.5 text-2xl font-bold tracking-tight">Where's osu!?</h2>
<p class="mt-1.5 max-w-lg text-sm leading-relaxed text-muted-foreground">
{#if $platform === 'macos'}
Point us at your osu!.app wrapper, or the folder that contains osu!.exe.
{:else} {:else}
<div Point us at the folder that contains osu!.exe.
class="flex flex-row gap-3 bg-red-800 border border-red-900 text-red-500 p-4 rounded-lg mt-4"
>
<CircleOff />
<span>Could not auto-detect osu! installation path. Please select it manually.</span
>
</div>
{/if} {/if}
{:else if manualSelectValid} </p>
<div
class="flex flex-row gap-3 bg-green-800 border border-green-900 text-green-500 p-4 rounded-lg mt-4"
>
<CircleCheckBig />
<span>Selected osu! installation path is valid!</span>
</div> </div>
<div class="mt-4 rounded-xl border p-4 transition-colors {statusBorderClass}">
<div class="flex items-start gap-3">
<div class="rounded-full p-2 {statusIconClass}">
{#if status === 'detecting'}
<LoaderCircle class="size-4 animate-spin" />
{:else if status === 'valid'}
<CircleCheckBig class="size-4" />
{:else if status === 'invalid'}
<CircleAlert class="size-4" />
{:else} {:else}
<Search class="size-4" />
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-semibold">{statusTitle}</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{statusDescription}
</p>
{#if osuInstallPath}
<div <div
class="flex flex-row gap-3 bg-red-800 border border-red-900 text-red-500 p-4 rounded-lg mt-4" class="mt-2.5 flex items-center gap-2 rounded-lg border border-theme-800/80 bg-theme-950/70 px-2.5 py-1.5"
> >
<CircleOff /> <FolderOpen class="size-3.5 shrink-0 text-muted-foreground" />
<span <span
>Selected osu! installation path is invalid! Please select a valid osu! class="truncate font-mono text-[11px] text-theme-200"
installation.</span title={osuInstallPath}>{osuInstallPath}</span
> >
</div> </div>
{/if} {/if}
</div> </div>
{:else if selectedStep === 3} </div>
<div class="h-full w-full flex items-center justify-center">
<div class="mt-3.5 flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="border-theme-700 bg-theme-900/80 hover:bg-theme-800"
onclick={browseOsu}
disabled={selecting}
>
<FolderOpen />
Browse…
</Button>
<Button
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-foreground"
onclick={detectOsu}
disabled={detecting || selecting}
>
<RefreshCw class={detecting ? 'animate-spin' : ''} />
Scan again
</Button>
</div>
</div>
{#if $platform === 'macos'}
<div <div
class="bg-black/40 backdrop-blur-sm py-8 px-8 rounded-lg" class="mt-3 flex items-start gap-2.5 rounded-xl border border-primary/15 bg-primary/5 p-3"
in:fade={{ duration: $reduceAnimations ? 0 : 200 }}
> >
<div class="grid grid-cols-[1fr_auto] gap-y-5 items-center px-6"> <Info class="mt-0.5 size-3.5 shrink-0 text-primary" />
<div class="flex flex-col"> <p class="text-xs leading-relaxed text-theme-200">
<Label class="text-sm" for="setting-patch">Patching</Label> Pick <code class="rounded bg-black/25 px-1 py-0.5 font-mono text-[11px]"
<div class="text-muted-foreground text-xs"> >osu!.app</code
Shows misses in Relax and Autopilot <!-- {#if $platform !== 'windows'}<span > directly — you don't need to open its package contents. The launcher finds osu!
class="text-red-500 bg-red-800/20 border border-red-600/20 p-0.5 mx-1 px-2 rounded-lg text-[0.55rem]!" inside the Wineskin wrapper.
>currently only on windows!</span </p>
>
{/if} -->
</div> </div>
{/if}
</div>
{:else}
<div class="flex min-h-[332px] flex-col">
<div>
<p class="text-[10px] font-semibold tracking-[0.2em] text-primary uppercase">
Personalize
</p>
<h2 class="mt-1.5 text-2xl font-bold tracking-tight">Make it yours.</h2>
<p class="mt-1.5 max-w-lg text-sm leading-relaxed text-muted-foreground">
Choose how the launcher should look and behave.
</p>
</div>
<div
class="mt-3 divide-y divide-theme-800/50 rounded-xl border border-theme-800/60 bg-black/15"
>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Requires
.NET in your Wine prefix.{/if}
</p>
</div> </div>
<Checkbox <Checkbox
id="setting-patch" id="wizard-patch"
class="mt-0.5"
checked={$patch} checked={$patch}
onCheckedChange={async (e) => { onCheckedChange={(checked) => patch.set(checked)}
patch.set(e); />
}}
class="flex items-center justify-center w-5 h-5"
></Checkbox>
<div class="flex flex-col">
<Label class="text-sm" for="setting-custom-cursor">Lazer-Style Cursor</Label>
<div class="text-muted-foreground text-xs">
Enable a custom cursor in the Launcher like in the lazer build of osu!
</div> </div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-custom-cursor"
>Lazer-style cursor</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Use a custom cursor in the launcher, like in osu!lazer.
</p>
</div> </div>
<Checkbox <Checkbox
id="setting-custom-cursor" id="wizard-custom-cursor"
class="mt-0.5"
checked={$customCursor} checked={$customCursor}
onCheckedChange={async (e) => { onCheckedChange={(checked) => {
if (!e) { if (!checked) cursorSmoothening.set(false);
cursorSmoothening.set(false); customCursor.set(checked);
}
customCursor.set(e);
}} }}
class="flex items-center justify-center w-5 h-5" />
></Checkbox>
<div class="flex flex-col">
<Label class="text-sm" for="setting-cursor-smoothening">Cursor Smoothening</Label>
<div class="text-muted-foreground text-xs">
Makes the custom cursor movement smoother.
</div> </div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-cursor-smoothing"
>Cursor smoothening</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Makes the custom cursor movement smoother.
</p>
</div> </div>
<Checkbox <Checkbox
id="setting-cursor-smoothening" id="wizard-cursor-smoothing"
class="mt-0.5"
checked={$cursorSmoothening} checked={$cursorSmoothening}
onCheckedChange={async (e) => { onCheckedChange={(checked) => {
if (!$customCursor) return; if (!$customCursor) return;
cursorSmoothening.set(e); cursorSmoothening.set(checked);
}} }}
disabled={!$customCursor} disabled={!$customCursor}
class="flex items-center justify-center w-5 h-5" />
></Checkbox>
<div class="flex flex-col">
<Label class="text-sm" for="setting-reduce-animations">Reduce Animations</Label>
<div class="text-muted-foreground text-xs">
Disables some animations in the Launcher to improve performance on low-end
devices.
</div> </div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-reduce-animations"
>Reduce animations</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Disables some launcher animations on low-end devices.
</p>
</div> </div>
<Checkbox <Checkbox
id="setting-reduce-animations" id="wizard-reduce-animations"
class="mt-0.5"
checked={$reduceAnimations} checked={$reduceAnimations}
onCheckedChange={async (e) => { onCheckedChange={(checked) => reduceAnimations.set(checked)}
reduceAnimations.set(e); />
}} </div>
class="flex items-center justify-center w-5 h-5"
></Checkbox>
<div class="flex flex-col"> <div class="flex items-start justify-between gap-6 px-4 py-1">
<Label class="text-sm" for="setting-rich-presence">Discord Rich Presence</Label> <div class="min-w-0">
<div class="text-muted-foreground text-xs"> <Label class="text-[13px]" for="wizard-presence"
Let other discord users show what you are doing right now 👀 >Discord Rich Presence</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Let other Discord users see what you are doing right now 👀
</p>
</div> </div>
</div> <div class="flex items-center gap-2.5">
<div class="relative">
{#if $presenceLoading} {#if $presenceLoading}
<div class="-left-8 absolute" transition:fade> <LoaderCircle class="size-3.5 animate-spin text-muted-foreground" />
<LoaderCircle class="animate-spin" />
</div>
{/if} {/if}
<Checkbox <Checkbox
id="setting-rich-presence" id="wizard-presence"
class="mt-0.5"
bind:checked={$discordPresence} bind:checked={$discordPresence}
disabled={$presenceLoading} disabled={$presenceLoading}
class="flex items-center justify-center w-5 h-5" />
></Checkbox>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<div class="mt-auto flex flex-row items-center justify-between">
<Button
class="bg-theme-950 hover:bg-theme-800"
variant="outline"
onclick={() => (selectedStep = Math.max(selectedStep - 1, 1))}
disabled={selectedStep <= 1}>Previous</Button
>
<Button
onclick={() => {
if (selectedStep >= steps.length) wizardFinished = true;
else selectedStep = Math.min(selectedStep + 1, steps.length);
}}
disabled={selectedStep > steps.length ||
(selectedStep === 2 && !dev && osuInstallPath.length <= 0)}
>{selectedStep >= steps.length ? 'Finish' : 'Next'}</Button
>
</div> </div>
{/key}
</div>
<div
class="flex items-center justify-between gap-3 border-t border-theme-800/70 bg-black/20 px-5 py-3"
>
<Button
variant="ghost"
class={stepIndex === 0
? 'invisible text-muted-foreground'
: 'text-muted-foreground hover:text-foreground'}
onclick={previousStep}
disabled={stepIndex === 0}
>
<ArrowLeft />
Back
</Button>
<div class="flex items-center gap-3">
{#if stepIndex === 1 && osuInstallPath.length === 0 && !dev}
<span class="hidden text-[11px] text-muted-foreground sm:inline">
Select an installation to continue
</span>
{/if}
<Button class="group" onclick={nextStep} disabled={!canContinue}>
{stepIndex === 0 ? 'Get started' : 'Continue'}
<ArrowRight class="transition-transform group-hover:translate-x-0.5" />
</Button>
</div>
</div>
</div>
<p class="mt-3 text-center text-[11px] text-muted-foreground">
You can change all of this later in Settings.
</p>
</div> </div>
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -10,7 +10,7 @@
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"types": ["@types/node"] "types": ["@types/node", "bun"]
} }
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files