Compare commits

..
12 Commits
Author SHA1 Message Date
HorizonCode e3abea8d83 fix: bind inspector props to resolve ownership_invalid_mutation warnings 2026-10-31 12:36:27 +01:00
HorizonCode 4dc6cb1721 feat: add date/time picker and fix launcherVersion initialization error 2026-10-31 11:00:00 +01:00
HorizonCode 54f7f81160 chore: add svelte component layer with monaco editor 2026-10-31 10:28:33 +01:00
HorizonCode 8071085e94 fix: allow window destroy 2026-10-29 16:23:27 +01:00
HorizonCode fbfa3aa54c fix: resize object opacity, context menu visibility 2026-10-29 16:18:20 +01:00
HorizonCode c02b26e8ae feat: polish theme tooling, diagnostics and launcher workflows
Expand the theme editor with multi-select, snapping, context menus, draft
recovery and export validation; add theme backups and trust prompts. Add
in-app diagnostics and rotating log bundle viewing, hardware-accelerated
theme video playback on macOS, and refresh the settings, setup wizard and
theme gallery layouts.
2026-10-29 15:41:33 +01:00
HorizonCode 87202de656 chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard 2026-10-29 10:53:12 +01:00
HorizonCode 417f54a94a fix(linux): osu-winello detection 2026-09-07 23:46:23 +02:00
HorizonCode 01628e8514 chore: update version to 3.2.7 and refactor skin handling functions 2026-08-09 23:24:50 +02:00
HorizonCode 5a06700748 chore: bump version 2026-07-20 22:21:02 +02:00
HorizonCode 74020e3817 fix: actually respect the patch setting smh 2026-07-20 22:20:03 +02:00
HorizonCode 6dc1f4f703 fix: mode select height 2026-06-26 10:58:35 +02:00
179 changed files with 35201 additions and 1335 deletions

No files matched your search

+89 -7
View File
@@ -14,28 +14,76 @@ It enhances the osu! experience with quality-of-life features and integration sp
## 🚀 Features
- [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] 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] Shows the number of imported beatmapsets
- [x] Shows the number of imported skins
- [x] Displays ping to the EZPPFarm server
- [x] Discord Rich Presence
- [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, custom Svelte 5 component layers with Monaco editing and inline compiler diagnostics, gradient/outline/shadow text effects, multi-select and snapping, a drag-and-drop layer tree with moves into and out of nested groups, layer/group transforms with independent X/Y scale and skew, hover group breadcrumbs, on-canvas layer/group rotate handles plus group move and scale handles, layer/group-linked WHEN/IF/THEN logic with quick navigation, safe advanced rule scripts with expressions, branches and loops, draft recovery, validation and export
- [x] Theme update backups with one-click rollback, plus a trust prompt before running executable API 1.0 themes or API 2.0 themes with custom Svelte layers
- [x] In-app diagnostics for osu!, Wine, the patcher, and the experimental stream's .NET 8 runtime, plus a log-bundle viewer
---
## 💻 Supported Platforms
| Platform | Status |
| -------- | ---------------------------------------- |
| -------- | ------------------------------------------------------ |
| Windows | ✅ Supported |
| macOS | ❌ Not supported |
| macOS | 🕧 Partially supported (via Wineskin/Whisky/CrossOver) |
| Linux | 🕧 Partially supported (via osu-winello) |
> Currently, only **Windows** is fully supported. Support for other platforms may be considered in the future.
> **Windows** remains the fully supported platform. OpenTabletDriver is Windows-only. On macOS,
> in-game patching runs the Windows patcher through the same Wine prefix as osu!. The stable
> patcher stream needs no extra runtime; the experimental stream needs the .NET 8 Desktop Runtime
> installed in that prefix.
### macOS
osu! stable is a Windows build, so on macOS it runs inside a Wine wrapper. EZPPLauncher
supports Wineskin wrappers (the usual `osu!.app` wine skin), Whisky bottles and CrossOver
bottles.
The setup wizard automatically looks for Wineskins in `/Applications` and `~/Applications`.
If detection fails, choose **Browse** and pick the `osu!.app` wrapper itself (or a Wine prefix
folder); the launcher resolves the osu! installation inside it, so there is no need to open the
bundle's package contents.
Large VP8/VP9/AV1 theme videos are transcoded once to a cached 720p H.264/AAC file so WebKit
can use the Mac's hardware decoder. The first large video triggers a one-time (~63 MB) download
of a signed static FFmpeg 9.0.2 build from `ffmpeg.martin-riedl.de` into `~/.ezpplauncher/bin`;
the launcher verifies its pinned SHA-256 and makes it executable. The managed build requires
macOS 12 or newer. If the download fails, a system FFmpeg (Homebrew or MacPorts) is used; without
either, themes still load but large WebM videos remain on WebKit's software decoder and may
stutter. Theme authors can also ship an H.264/MP4 version alongside the WebM.
---
## 🪵 Logs
Launcher, osu! and patcher output is written to `.ezpplauncher/logs` in your home folder:
| File | Contents |
| -------------- | -------------------------------------------------------------- |
| `launcher.log` | Launcher lifecycle, webview console output and uncaught errors |
| `osu.log` | osu! and osu! updater output, including stdout/stderr |
| `patcher.log` | Patcher output, including stdout/stderr |
A file is only created when that stream actually writes a line. On each new session, or when
any file reaches 5 MiB, all three active files are bundled into a single
`logs_<date>_<time>.zip`; the five most recent bundles are kept. You can turn file logging off,
open the folder, or browse and filter a bundle in the built-in viewer from **Settings → Logging**.
On Windows the folder is `%USERPROFILE%\.ezpplauncher\logs`.
**Settings → Diagnostics** checks the selected osu! install and write access, the macOS Wine
prefix/runtime/version and (for the experimental patcher stream) its .NET 8 runtime, the FFmpeg
theme-video transcoder, osu-winello/wmctrl on Linux, and whether the patcher is present. It can
copy a plain-text support report or reopen the setup wizard for a guided repair.
---
@@ -47,6 +95,7 @@ It enhances the osu! experience with quality-of-life features and integration sp
- **Cargo** (comes with rustup)
- **[CMake](https://cmake.org/download/)**
- **[Bun](https://bun.sh/)** (JavaScript runtime)
- On macOS: **Xcode Command Line Tools** (`xcode-select --install`)
### Steps
@@ -61,7 +110,12 @@ bun run build
After building, the launcher executable will be located at:
```
# Windows
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 +125,9 @@ src-tauri/target/release/ezpplauncher.exe
```
├── src/ # Frontend (Svelte)
├── src-tauri/ # Backend (Rust)
│ ├── src/macos.rs # Wine/Wineskin support for macOS
│ ├── tauri.conf.json # Tauri configuration
│ └── tauri.macos.conf.json # macOS bundle targets (app + dmg)
├── package.json # Bun project config
└── README.md
```
@@ -80,4 +136,30 @@ src-tauri/target/release/ezpplauncher.exe
## 🎨 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, custom Svelte 5 components with a Monaco editor, rich text effects
(gradient fills, outlines, shadows, decoration and case), WebM support,
`@fontsource` fonts and custom font imports. Themes can switch the launcher
chrome between the bundled Lucide, Phosphor and Tabler icon families. Hover a
layer and click a crumb in its group path (or double-click it), then use the
on-canvas frame or the inspector to move, rotate, scale or skew the group.
Logic rules can be linked to layers or groups and edited from their inspectors.
Any rule can switch from the visual builder to a sandboxed JavaScript script
with variables, expressions, `if/else`, `for`/`while`, visibility actions and
navigation; scripts open in a Monaco editor with syntax highlighting,
completion, hover docs and inline errors, and can expand to fullscreen. Text
layers can display live variables with `{name}` placeholders and theme-defined
starting values, plus read-only clock values such as `{time}` and `{hour}` that
refresh every second. Scripts are compiled into a safe instruction set and run by
the launcher without `eval`.
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.
+1413
View File
File diff suppressed because it is too large. Load diff
+47 -53
View File
@@ -6,33 +6,43 @@
"name": "ezpplauncher",
"dependencies": {
"@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/bebas-neue": "^5.3.0",
"@fontsource/space-mono": "^5.2.9",
"@number-flow/svelte": "^0.4.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"acorn": "^8.19.0",
"animejs": "^4.4.1",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"jszip": "^3.10.1",
"lottie-web": "^5.13.0",
"monaco-editor": "^0.57.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-to-webcomponent": "^2.0.1",
"semver": "^7.8.1",
"sileo": "^0.1.5",
"svelte-confetti": "^2.3.2",
"torph": "^0.1.3",
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iarna/toml": "2.2.5",
"@lucide/svelte": "^1.16.0",
"@iconify-json/lucide": "^1.2.149",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/tabler": "^1.2.43",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -42,24 +52,20 @@
"@types/crypto-js": "^4.2.2",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"autoprefixer": "10.5.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.0.1",
"sass-embedded": "^1.100.0",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
@@ -121,8 +127,16 @@
"@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/bebas-neue": ["@fontsource/[email protected]", "", {}, "sha512-nCxMEb9Bg9sjNWm9TIOsvHKDMLRUfEZPWpLgYu6m9KavZZVSA/WoHzI5wROwqA+eNwEi/PNBAcB5Co7HA3S0Cw=="],
"@fontsource/space-mono": ["@fontsource/[email protected]", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -135,7 +149,15 @@
"@iarna/toml": ["@iarna/[email protected]", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="],
"@internationalized/date": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA=="],
"@iconify-json/lucide": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-5NG9YdbtRS03hG1A+EIE6nLhhdAYsf0mKu1ZRi6kLMlCG8DxmDsRM6DAhwlNMLiSiQKFbHpKTJ2VddZdwUkUyw=="],
"@iconify-json/ph": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw=="],
"@iconify-json/tabler": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-EKhyOj6nASi25ZjuRpEhPL1fwiKVtg/e/VX6v6X3FPKpjG9og26ypW8ItnWYSTn6fdNuQe3VPeyuThWlP6QuWw=="],
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@internationalized/date": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -149,7 +171,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/svelte": ["@lucide/svelte@1.16.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg=="],
"@lucide/svelte": ["@lucide/svelte@0.561.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A=="],
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
@@ -187,8 +209,6 @@
"@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-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="],
@@ -261,8 +281,6 @@
"@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=="],
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
@@ -343,7 +361,7 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
"acorn": ["[email protected]6.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["[email protected]9.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -353,30 +371,22 @@
"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=="],
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"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=="],
"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=="],
"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=="],
"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=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -407,12 +417,10 @@
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="],
"dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
"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=="],
"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 +465,6 @@
"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=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -499,7 +505,7 @@
"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=="],
@@ -555,15 +561,19 @@
"lodash-es": ["[email protected]", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lottie-web": ["[email protected]", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
"lz-string": ["[email protected]", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"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=="],
"monaco-editor": ["[email protected]", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
"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=="],
@@ -583,8 +593,6 @@
"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=="],
"obug": ["[email protected]", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -617,9 +625,7 @@
"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-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"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=="],
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -635,8 +641,6 @@
"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=="],
"readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -723,8 +727,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-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=="],
"sync-child-process": ["[email protected]", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
@@ -739,14 +741,14 @@
"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=="],
"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=="],
"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=="],
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
@@ -763,8 +765,6 @@
"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=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -801,7 +801,7 @@
"@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=="],
"@sveltejs/kit/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@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=="],
@@ -835,24 +835,20 @@
"eslint-plugin-svelte/globals": ["[email protected]", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
"espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"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=="],
"svelte/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"svelte-eslint-parser/eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"svelte-eslint-parser/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"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=="],
"@jridgewell/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
@@ -865,8 +861,6 @@
"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=="],
"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",
"tailwind": {
"css": "src\\app.css",
"css": "src/app.css",
"baseColor": "gray"
},
"aliases": {
+4
View File
@@ -11,6 +11,10 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
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,
...ts.configs.recommended,
...svelte.configs.recommended,
+15 -8
View File
@@ -1,6 +1,6 @@
{
"name": "ezpplauncher",
"version": "3.2.5",
"version": "3.2.7",
"description": "",
"type": "module",
"scripts": {
@@ -11,6 +11,7 @@
"tauri:build": "tauri build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"icons:build": "bun ./scripts/generate-theme-icons.ts",
"tauri": "tauri",
"tauri:update-logo": "bun run tauri icon ./static/logo.png",
"format": "prettier --write .",
@@ -19,33 +20,43 @@
"license": "GPL-3.0-only",
"dependencies": {
"@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/bebas-neue": "^5.3.0",
"@fontsource/space-mono": "^5.2.9",
"@number-flow/svelte": "^0.4.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"acorn": "^8.19.0",
"animejs": "^4.4.1",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"jszip": "^3.10.1",
"lottie-web": "^5.13.0",
"monaco-editor": "^0.57.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-to-webcomponent": "^2.0.1",
"semver": "^7.8.1",
"sileo": "^0.1.5",
"svelte-confetti": "^2.3.2",
"torph": "^0.1.3",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iarna/toml": "2.2.5",
"@lucide/svelte": "^1.16.0",
"@iconify-json/lucide": "^1.2.149",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/tabler": "^1.2.43",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -55,24 +66,20 @@
"@types/crypto-js": "^4.2.2",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"autoprefixer": "10.5.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.0.1",
"sass-embedded": "^1.100.0",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
+142
View File
@@ -0,0 +1,142 @@
import { THEME_ICON_NAMES, type ThemeIconName, type ThemeIconSet } from '../src/lib/themeIcons';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
type IconifyCollection = {
width?: number;
height?: number;
icons: Record<string, { body: string; width?: number; height?: number }>;
aliases?: Record<string, { parent: string }>;
};
type IconBody = {
width: number;
height: number;
body: string;
};
/**
* Semantic name -> Iconify icon id per family. Keeping this explicit means a
* family swap can never pick a random similarly named icon from another set.
*/
const ICON_IDS: Record<ThemeIconName, Record<ThemeIconSet, string>> = {
home: { lucide: 'house', phosphor: 'house', tabler: 'home' },
themes: { lucide: 'palette', phosphor: 'palette', tabler: 'palette' },
settings: {
lucide: 'sliders-horizontal',
phosphor: 'sliders-horizontal',
tabler: 'adjustments-horizontal',
},
launch: { lucide: 'play', phosphor: 'play', tabler: 'player-play' },
user: { lucide: 'user', phosphor: 'user', tabler: 'user' },
music: { lucide: 'music', phosphor: 'music-notes', tabler: 'music' },
brush: { lucide: 'brush', phosphor: 'paint-brush', tabler: 'brush' },
wifi: { lucide: 'wifi', phosphor: 'wifi-high', tabler: 'wifi' },
'wifi-off': { lucide: 'wifi-off', phosphor: 'wifi-slash', tabler: 'wifi-off' },
gameplay: { lucide: 'gamepad-2', phosphor: 'game-controller', tabler: 'device-gamepad-2' },
integrations: { lucide: 'plug', phosphor: 'plugs', tabler: 'plug' },
installation: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
installed: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
diagnostics: { lucide: 'shield-check', phosphor: 'shield-check', tabler: 'shield-check' },
logs: { lucide: 'scroll-text', phosphor: 'scroll', tabler: 'file-text' },
'all-themes': { lucide: 'layout-grid', phosphor: 'squares-four', tabler: 'layout-grid' },
available: {
lucide: 'cloud-download',
phosphor: 'cloud-arrow-down',
tabler: 'cloud-download',
},
updates: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
search: { lucide: 'search', phosphor: 'magnifying-glass', tabler: 'search' },
folder: { lucide: 'folder-open', phosphor: 'folder-open', tabler: 'folder-open' },
plus: { lucide: 'plus', phosphor: 'plus', tabler: 'plus' },
'osu-standard': { lucide: 'circle', phosphor: 'circle', tabler: 'circle' },
'osu-taiko': { lucide: 'drum', phosphor: 'music-notes', tabler: 'music' },
'osu-catch': { lucide: 'cherry', phosphor: 'orange-slice', tabler: 'cherry' },
'osu-mania': { lucide: 'piano', phosphor: 'piano-keys', tabler: 'piano' },
login: { lucide: 'log-in', phosphor: 'sign-in', tabler: 'login' },
logout: { lucide: 'log-out', phosphor: 'sign-out', tabler: 'logout' },
warning: { lucide: 'triangle-alert', phosphor: 'warning', tabler: 'alert-triangle' },
info: { lucide: 'info', phosphor: 'info', tabler: 'info-circle' },
loader: { lucide: 'loader-circle', phosphor: 'circle-notch', tabler: 'loader' },
ellipsis: { lucide: 'ellipsis', phosphor: 'dots-three', tabler: 'dots' },
check: { lucide: 'check', phosphor: 'check', tabler: 'check' },
trash: { lucide: 'trash', phosphor: 'trash', tabler: 'trash' },
edit: { lucide: 'pencil', phosphor: 'pencil-simple', tabler: 'pencil' },
history: { lucide: 'history', phosphor: 'clock-counter-clockwise', tabler: 'history' },
refresh: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
download: { lucide: 'cloud-download', phosphor: 'cloud-arrow-down', tabler: 'cloud-download' },
import: { lucide: 'import', phosphor: 'upload-simple', tabler: 'upload' },
'arrow-right': { lucide: 'arrow-right', phosphor: 'arrow-right', tabler: 'arrow-right' },
eye: { lucide: 'eye', phosphor: 'eye', tabler: 'eye' },
'eye-off': { lucide: 'eye-off', phosphor: 'eye-slash', tabler: 'eye-off' },
wrench: { lucide: 'wrench', phosphor: 'wrench', tabler: 'tool' },
rotate: { lucide: 'rotate-cw', phosphor: 'arrows-clockwise', tabler: 'rotate-clockwise' },
volume: { lucide: 'volume-2', phosphor: 'speaker-high', tabler: 'volume' },
'volume-low': { lucide: 'volume-1', phosphor: 'speaker-low', tabler: 'volume-2' },
'volume-off': { lucide: 'volume-x', phosphor: 'speaker-slash', tabler: 'volume-off' },
};
const collectionPaths: Record<ThemeIconSet, string> = {
lucide: '../node_modules/@iconify-json/lucide/icons.json',
phosphor: '../node_modules/@iconify-json/ph/icons.json',
tabler: '../node_modules/@iconify-json/tabler/icons.json',
};
const loadCollection = (set: ThemeIconSet): IconifyCollection =>
JSON.parse(readFileSync(fileURLToPath(new URL(collectionPaths[set], import.meta.url)), 'utf8'));
const resolveIcon = (collection: IconifyCollection, id: string): IconBody | undefined => {
const direct = collection.icons[id];
if (direct) {
return {
width: direct.width ?? collection.width ?? 24,
height: direct.height ?? collection.height ?? 24,
body: direct.body,
};
}
const alias = collection.aliases?.[id];
if (!alias) return undefined;
const parent = collection.icons[alias.parent];
if (!parent) return undefined;
return {
width: parent.width ?? collection.width ?? 24,
height: parent.height ?? collection.height ?? 24,
body: parent.body,
};
};
const collections = Object.fromEntries(
(Object.keys(collectionPaths) as ThemeIconSet[]).map((set) => [set, loadCollection(set)])
) as Record<ThemeIconSet, IconifyCollection>;
const data = {} as Record<ThemeIconSet, Record<ThemeIconName, IconBody>>;
for (const set of Object.keys(collectionPaths) as ThemeIconSet[]) {
data[set] = {} as Record<ThemeIconName, IconBody>;
for (const name of THEME_ICON_NAMES) {
const id = ICON_IDS[name]?.[set];
if (!id) throw new Error(`No ${set} icon mapped for semantic name "${name}".`);
const icon = resolveIcon(collections[set], id);
if (!icon) throw new Error(`Icon "${id}" from ${set} was not found for "${name}".`);
data[set][name] = icon;
}
}
const output = `// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
export type ThemeIconBody = { width: number; height: number; body: string };
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = ${JSON.stringify(
data,
null,
2
)};
`;
const outputPath = fileURLToPath(new URL('../src/lib/themeIconData.ts', import.meta.url));
writeFileSync(outputPath, output);
console.log(
`Generated ${THEME_ICON_NAMES.length} semantic icons for ${Object.keys(collectionPaths).length} sets.`
);
+85 -1
View File
@@ -78,6 +78,23 @@ dependencies = [
"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]]
name = "android_system_properties"
version = "0.1.5"
@@ -1189,6 +1206,16 @@ dependencies = [
"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]]
name = "equivalent"
version = "1.0.2"
@@ -1239,7 +1266,7 @@ dependencies = [
[[package]]
name = "ezpplauncher"
version = "3.2.5"
version = "3.2.7"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1248,9 +1275,16 @@ dependencies = [
"futures-util",
"hardware-id",
"hex",
"http-body-util",
"http-range",
"hyper",
"hyper-util",
"log",
"md5",
"objc2 0.6.4",
"once_cell",
"open",
"percent-encoding",
"presenceforge",
"reqwest",
"rusqlite",
@@ -1265,9 +1299,11 @@ dependencies = [
"tauri-plugin-cors-fetch",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"widestring",
@@ -1304,6 +1340,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1993,6 +2038,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.12"
@@ -2016,6 +2067,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2766,6 +2818,15 @@ dependencies = [
"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]]
name = "objc-sys"
version = "0.3.5"
@@ -4995,6 +5056,27 @@ dependencies = [
"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]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -5216,7 +5298,9 @@ dependencies = [
"deranged",
"itoa",
"js-sys",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
+14 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ezpplauncher"
version = "3.2.5"
version = "3.2.7"
description = "EZPPLauncher redefined."
authors = ["HorizonCode"]
edition = "2024"
@@ -56,6 +56,19 @@ aes-gcm = "0.10.3"
base64 = "0.22.1"
sha2 = "0.11.0"
thiserror = "2.0.18"
tauri-plugin-log = "2"
log = "0.4"
time = { version = "0.3", features = ["local-offset"] }
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
http-body-util = "0.1.3"
http-range = "0.1.5"
hyper = { version = "1.8.1", features = ["server", "http1"] }
hyper-util = { version = "0.1.19", features = ["tokio"] }
percent-encoding = "2.3.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3"
[target.'cfg(windows)'.dependencies]
winreg = "0.56.0"
+4 -2
View File
@@ -1,9 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"description": "Capabilities for the main launcher and theme editor windows",
"windows": [
"main"
"main",
"theme-editor"
],
"permissions": [
"core:default",
@@ -14,6 +15,7 @@
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-destroy",
"cors-fetch:default",
"core:window:allow-hide",
"core:window:allow-show",
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

+1572 -160
View File
File diff suppressed because it is too large. Load diff
+90 -18
View File
@@ -7,36 +7,40 @@ use tauri_plugin_fs::FsExt;
pub mod commands;
pub mod config;
pub mod logging;
#[cfg(target_os = "macos")]
pub mod macos;
pub mod osudb;
pub mod presence;
pub mod state;
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub mod theme_server;
pub mod utils;
use crate::{
commands::{
check_for_corruption, config_all, config_clear, config_delete, config_exists, config_get,
config_set, download_and_extract_theme, download_ezpp_launcher_update,
download_ezpp_launcher_update_files, encrypt_string, exit, extract_theme,
find_osu_installation, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid,
get_launcher_version, get_osu_release_stream, get_osu_skin, get_osu_version, get_platform,
get_skins, 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, open_url_in_browser, opened_urls,
backup_theme_folder, check_for_corruption, config_all, config_clear, config_delete,
config_exists, config_get, config_set, download_and_extract_theme,
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
export_theme, extract_theme, file_size, find_osu_installation, get_beatmapsets_count,
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
has_wmctrl, install_ezpp_launcher_update, installation_diagnostics,
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
list_theme_backups, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder,
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
presence_connect, presence_disconnect, presence_is_connected, presence_update_button,
presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
run_open_tablet_driver, run_osu, run_osu_updater, set_osu_config_values,
set_osu_user_config_values, stop_open_tablet_driver, valid_osu_folder,
resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater,
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
valid_osu_folder,
},
state::AppState,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
unsafe {
// std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
let mut builder = tauri::Builder::default();
let mut builder = tauri::Builder::default().plugin(logging::plugin());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
@@ -67,6 +71,15 @@ pub fn run() {
let app = builder
.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 ────────────────────────────────────────────────────────
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
@@ -97,20 +110,48 @@ pub fn run() {
app.manage(AppState {
config: Mutex::new(cfg),
opened_files: Mutex::new(opened_files),
editor_theme_folder: Mutex::new(None),
});
// WebKit's custom-protocol media pipeline is unreliable (and
// loads large WebMs into memory), so Linux and macOS use a real
// loopback HTTP server with byte-range support instead.
#[cfg(any(target_os = "linux", target_os = "macos"))]
match commands::theme_root().and_then(|root| {
let media = theme_server::media_root()?;
theme_server::start(root, media)
}) {
Ok(_) => {}
Err(error) => log::warn!(
target: "launcher",
"Could not start the theme asset server: {error}"
),
}
#[cfg(target_os = "macos")]
if let Some(window) = app.get_webview_window("main") {
if let Err(error) = macos::round_window(&window, 12.0) {
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
}
if let Err(error) = macos::allow_media_autoplay(&window) {
log::warn!(target: "launcher", "{error}");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
opened_urls,
get_hwid,
find_osu_installation,
pick_osu_installation,
resolve_osu_folder,
valid_osu_folder,
get_beatmapsets_count,
get_skins,
get_skins_count,
get_osu_version,
get_osu_release_stream,
installation_diagnostics,
set_osu_config_values,
set_osu_user_config_values,
run_osu_updater,
@@ -124,6 +165,7 @@ pub fn run() {
get_launcher_version,
exit,
get_platform,
file_size,
check_for_corruption,
presence_connect,
presence_disconnect,
@@ -138,8 +180,19 @@ pub fn run() {
download_ezpp_launcher_update,
install_ezpp_launcher_update,
read_theme_info,
backup_theme_folder,
list_theme_backups,
restore_theme_backup,
extract_theme,
download_and_extract_theme,
save_theme_project,
export_theme,
open_theme_folder,
open_theme_editor,
theme_editor_context,
theme_asset_base,
theme_preview_url,
optimize_theme_media,
config_get,
config_set,
config_exists,
@@ -149,7 +202,12 @@ pub fn run() {
run_open_tablet_driver,
stop_open_tablet_driver,
is_open_tablet_driver_running,
is_open_tablet_driver_path_valid
is_open_tablet_driver_path_valid,
log_frontend,
set_logging_enabled,
open_logs_folder,
logging::list_log_bundles,
logging::read_log_bundle
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
@@ -159,6 +217,20 @@ pub fn run() {
.expect("error while building tauri application");
app.run(|app, event| {
// The content view can be replaced again while the webview is being
// built, so re-apply the corner mask once the app is ready.
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Ready = &event {
if let Some(window) = app.get_webview_window("main") {
if let Err(error) = macos::round_window(&window, 12.0) {
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
}
if let Err(error) = macos::allow_media_autoplay(&window) {
log::warn!(target: "launcher", "{error}");
}
}
}
presence::handle_run_event(app, &event);
});
}
+637
View File
@@ -0,0 +1,637 @@
//! Rotating file logs for the launcher, osu! and the patcher.
//!
//! Each stream writes to `~/.ezpplauncher/logs`:
//!
//! - `launcher.log` receives everything, including frontend console output.
//! - `osu.log` receives osu! and its updater, including stdout/stderr.
//! - `patcher.log` receives patcher output.
//!
//! A stream's file is only created when it actually has a line to write, so a
//! launch without osu!/patcher output never leaves empty files behind. When a
//! new session starts, or any stream reaches 5 MiB, all three active files are
//! bundled into one `logs_<date>_<time>.zip` and fresh files are started. The
//! five most recent bundles are kept, so a support report only needs one file.
//! `launcher.log` intentionally includes the osu!/patcher entries too. File
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use log::Level;
use serde::Serialize;
use tauri::Runtime;
use tauri::plugin::TauriPlugin;
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Child;
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
const KEPT_LOG_BUNDLES: usize = 5;
const MAX_LINE_CHARS: usize = 16_000;
static ENABLED: AtomicBool = AtomicBool::new(true);
pub fn set_logging_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
fn logging_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Read the persisted setting before the config database is initialized, so a
/// disabled logger does not write a startup file before the frontend loads.
fn initial_logging_enabled() -> bool {
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
return true;
};
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
if !db_path.exists() {
return true;
}
let Ok(connection) =
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
else {
return true;
};
let Ok(raw) = connection.query_row(
"SELECT value FROM config WHERE key = 'logging_enabled'",
[],
|row| row.get::<_, String>(0),
) else {
return true;
};
serde_json::from_str::<bool>(&raw).unwrap_or(true)
}
pub fn logs_directory() -> PathBuf {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".ezpplauncher").join("logs")
}
#[derive(Clone, Copy)]
enum Stream {
Launcher,
Osu,
Patcher,
}
impl Stream {
const ALL: [Stream; 3] = [Stream::Launcher, Stream::Osu, Stream::Patcher];
fn index(self) -> usize {
match self {
Stream::Launcher => 0,
Stream::Osu => 1,
Stream::Patcher => 2,
}
}
fn file_name(self) -> &'static str {
match self {
Stream::Launcher => "launcher.log",
Stream::Osu => "osu.log",
Stream::Patcher => "patcher.log",
}
}
}
/// Shared state for the three log files. Rotating any stream closes and
/// bundles all of them, so every bundle always holds the same session.
struct LogSet {
dir: PathBuf,
state: Mutex<LogState>,
}
struct LogState {
started: bool,
files: [Option<File>; 3],
sizes: [u64; 3],
}
impl LogSet {
fn new(dir: PathBuf) -> Self {
Self {
dir,
state: Mutex::new(LogState {
started: false,
files: [None, None, None],
sizes: [0; 3],
}),
}
}
fn write(&self, stream: Stream, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !state.started {
state.started = true;
bundle_active_logs(&self.dir)?;
prune_bundles(&self.dir)?;
}
let index = stream.index();
if state.files[index].is_none() {
self.open_stream(&mut state, stream)?;
}
if state.sizes[index] > 0
&& state.sizes[index].saturating_add(buf.len() as u64) > MAX_LOG_SIZE
{
self.rotate(&mut state, stream)?;
}
let file = state.files[index].as_mut().expect("stream log is open");
file.write_all(buf)?;
state.sizes[index] += buf.len() as u64;
Ok(buf.len())
}
fn flush(&self, stream: Stream) -> io::Result<()> {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
match state.files[stream.index()].as_mut() {
Some(file) => file.flush(),
None => Ok(()),
}
}
fn open_stream(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
fs::create_dir_all(&self.dir)?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(self.dir.join(stream.file_name()))?;
let index = stream.index();
state.sizes[index] = file.metadata()?.len();
state.files[index] = Some(file);
Ok(())
}
fn rotate(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
for file in state.files.iter_mut() {
if let Some(mut file) = file.take() {
let _ = file.flush();
}
}
bundle_active_logs(&self.dir)?;
prune_bundles(&self.dir)?;
let index = stream.index();
state.sizes[index] = 0;
self.open_stream(state, stream)
}
}
struct SessionWriter {
logs: Arc<LogSet>,
stream: Stream,
}
impl Write for SessionWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if !logging_enabled() {
return Ok(buf.len());
}
self.logs.write(self.stream, buf)
}
fn flush(&mut self) -> io::Result<()> {
if !logging_enabled() {
return Ok(());
}
self.logs.flush(self.stream)
}
}
/// Zip the active logs that have content and remove them afterwards.
fn bundle_active_logs(dir: &Path) -> io::Result<()> {
let mut entries = Vec::new();
for stream in Stream::ALL {
let path = dir.join(stream.file_name());
match path.metadata() {
Ok(metadata) if metadata.len() > 0 => {
entries.push((path, stream.file_name().to_string()));
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
if entries.is_empty() {
return Ok(());
}
fs::create_dir_all(dir)?;
let bundle_path = unique_bundle_path(dir);
let result = (|| -> io::Result<()> {
let file = File::create(&bundle_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, name) in &entries {
zip.start_file(name.clone(), options).map_err(|error| {
io::Error::other(format!("Could not add {name} to the log bundle: {error}"))
})?;
let mut source = File::open(path)?;
io::copy(&mut source, &mut zip)?;
}
zip.finish().map_err(|error| {
io::Error::other(format!("Could not finish the log bundle: {error}"))
})?;
Ok(())
})();
if let Err(error) = result {
let _ = fs::remove_file(&bundle_path);
return Err(error);
}
for (path, _) in entries {
fs::remove_file(path)?;
}
Ok(())
}
fn unique_bundle_path(dir: &Path) -> PathBuf {
let stamp = archive_timestamp();
let mut path = dir.join(format!("logs_{stamp}.zip"));
let mut suffix = 1;
while path.exists() {
path = dir.join(format!("logs_{stamp}-{suffix}.zip"));
suffix += 1;
}
path
}
fn archive_timestamp() -> String {
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
format!(
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second()
)
}
fn prune_bundles(dir: &Path) -> io::Result<()> {
let mut bundles = fs::read_dir(dir)?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
let file_name = path.file_name()?.to_str()?;
if !file_name.starts_with("logs_") || !file_name.ends_with(".zip") {
return None;
}
let modified = entry.metadata().ok()?.modified().ok()?;
Some((path, modified))
})
.collect::<Vec<_>>();
bundles.sort_by_key(|(_, modified)| *modified);
if bundles.len() > KEPT_LOG_BUNDLES {
for (path, _) in bundles.iter().take(bundles.len() - KEPT_LOG_BUNDLES) {
fs::remove_file(path)?;
}
}
Ok(())
}
fn file_target(set: &Arc<LogSet>, stream: Stream) -> Target {
let writer = SessionWriter {
logs: Arc::clone(set),
stream,
};
let dispatch = tauri_plugin_log::fern::Dispatch::new().chain(
tauri_plugin_log::fern::Output::writer(Box::new(writer), "\n"),
);
Target::new(TargetKind::Dispatch(dispatch))
}
/// Build the log plugin. File targets are filtered by logging target so osu!
/// and patcher output stay separate while still being copied to the main log.
pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
set_logging_enabled(initial_logging_enabled());
let logs_dir = logs_directory();
let set = Arc::new(LogSet::new(logs_dir));
let launcher = file_target(&set, Stream::Launcher);
let osu =
file_target(&set, Stream::Osu).filter(|metadata| metadata.target().starts_with("osu"));
let patcher = file_target(&set, Stream::Patcher)
.filter(|metadata| metadata.target().starts_with("patcher"));
tauri_plugin_log::Builder::new()
.targets([Target::new(TargetKind::Stdout), launcher, osu, patcher])
.level(if cfg!(debug_assertions) {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
})
.timezone_strategy(TimezoneStrategy::UseLocal)
.build()
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogBundleInfo {
pub path: String,
pub name: String,
pub size: u64,
pub modified: u64,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogBundleEntry {
pub name: String,
pub content: String,
}
/// List the rotating ZIP bundles, newest first, for the in-app log viewer.
#[tauri::command]
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
let dir = logs_directory();
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut bundles = fs::read_dir(&dir)
.map_err(|error| format!("Could not read the logs folder: {error}"))?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?.to_string();
if !name.starts_with("logs_") || !name.ends_with(".zip") {
return None;
}
let metadata = entry.metadata().ok()?;
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
Some(LogBundleInfo {
path: path.to_string_lossy().into_owned(),
name,
size: metadata.len(),
modified,
})
})
.collect::<Vec<_>>();
bundles.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(bundles)
}
/// Read the launcher/osu/patcher logs out of one bundle.
#[tauri::command]
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
let dir = logs_directory();
let requested = fs::canonicalize(&path)
.map_err(|error| format!("Could not open the log bundle: {error}"))?;
let root = fs::canonicalize(&dir)
.map_err(|error| format!("Could not resolve the logs folder: {error}"))?;
if !requested.starts_with(&root) {
return Err("The log bundle is outside the logs folder".to_string());
}
let file = File::open(&requested)
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|error| format!("Invalid log bundle: {error}"))?;
let mut entries = Vec::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
if !entry.name().ends_with(".log") {
continue;
}
let mut bytes = Vec::new();
entry
.by_ref()
.take(2 * 1024 * 1024)
.read_to_end(&mut bytes)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
let mut content = String::from_utf8_lossy(&bytes).into_owned();
if entry.size() > bytes.len() as u64 {
content.push_str("\n… log truncated in the viewer …\n");
}
entries.push(LogBundleEntry {
name: entry.name().to_string(),
content,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
/// Start reading a child's stdout/stderr and write every line to `target`.
pub fn capture_osu_output(child: &mut Child) {
capture_output(child, "osu");
}
/// Start reading a child's stdout/stderr and write every line to `target`.
pub fn capture_patcher_output(child: &mut Child) {
capture_output(child, "patcher");
}
fn capture_output(child: &mut Child, target: &'static str) {
if let Some(stdout) = child.stdout.take() {
stream_lines(stdout, "stdout", target);
}
if let Some(stderr) = child.stderr.take() {
stream_lines(stderr, "stderr", target);
}
}
fn stream_lines<R>(reader: R, stream: &'static str, target: &'static str)
where
R: AsyncRead + Unpin + Send + 'static,
{
tauri::async_runtime::spawn(async move {
let mut lines = BufReader::new(reader).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
let line = truncate(line.trim_end_matches(['\r', '\n']));
if !line.is_empty() {
log::log!(target: target, Level::Info, "[{stream}] {line}");
}
}
Ok(None) => break,
Err(error) => {
log::log!(
target: target,
Level::Warn,
"Could not read {stream} output: {error}"
);
break;
}
}
}
});
}
fn truncate(mut value: &str) -> String {
if value.chars().count() <= MAX_LINE_CHARS {
return value.to_string();
}
value = &value[..value
.char_indices()
.nth(MAX_LINE_CHARS)
.map(|(index, _)| index)
.unwrap_or(value.len())];
format!("{value}…")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn temp_dir(name: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ezpp-logging-{}-{name}-{id}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_line(logs: &Arc<LogSet>, stream: Stream, line: &str) {
let mut writer = SessionWriter {
logs: Arc::clone(logs),
stream,
};
writer.write_all(line.as_bytes()).unwrap();
writer.flush().unwrap();
}
#[test]
fn creates_file_only_after_first_line() {
let dir = temp_dir("lazy");
let logs = Arc::new(LogSet::new(dir.clone()));
assert!(!dir.join("osu.log").exists());
write_line(&logs, Stream::Osu, "[stdout] first\n");
assert_eq!(
fs::read_to_string(dir.join("osu.log")).unwrap(),
"[stdout] first\n"
);
assert!(!dir.join("launcher.log").exists());
assert!(!dir.join("patcher.log").exists());
drop(logs);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bundles_all_streams_when_a_new_session_starts() {
let dir = temp_dir("bundle");
let previous = Arc::new(LogSet::new(dir.clone()));
write_line(&previous, Stream::Launcher, "launcher previous\n");
write_line(&previous, Stream::Osu, "osu previous\n");
write_line(&previous, Stream::Patcher, "patcher previous\n");
drop(previous);
let current = Arc::new(LogSet::new(dir.clone()));
write_line(&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();
}
}
+907
View File
@@ -0,0 +1,907 @@
//! macOS support for osu! stable running inside a Wine wrapper.
//!
//! osu! stable is a Windows application, so on macOS it has to run through a
//! Wine-based wrapper. The launcher supports Wineskin wrappers (the common
//! `osu!.app` wine skin), Whisky bottles and CrossOver bottles.
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration as StdDuration, Instant};
use tokio::process::{Child, Command};
use tokio::time::{Duration, sleep};
use crate::utils::is_valid_osu_folder;
/// Round the borderless launcher window and make its corners transparent.
///
/// macOS does not round undecorated windows on its own, so the webview has to
/// be clipped with the content view's layer. The window itself is made
/// transparent so the area outside that clip shows the desktop instead of a
/// square background.
pub fn round_window(window: &tauri::WebviewWindow, radius: f64) -> Result<(), String> {
use objc2::msg_send;
use objc2::runtime::AnyObject;
let ns_window = window.ns_window().map_err(|error| error.to_string())? as *mut AnyObject;
if ns_window.is_null() {
return Err("The native window is not available".into());
}
unsafe {
let _: () = msg_send![ns_window, setOpaque: false];
let clear: *mut AnyObject = msg_send![objc2::class!(NSColor), clearColor];
let _: () = msg_send![ns_window, setBackgroundColor: clear];
let _: () = msg_send![ns_window, setHasShadow: true];
let content_view: *mut AnyObject = msg_send![ns_window, contentView];
if content_view.is_null() {
return Err("The native content view is not available".into());
}
let _: () = msg_send![content_view, setWantsLayer: true];
let layer: *mut AnyObject = msg_send![content_view, layer];
if layer.is_null() {
return Err("The native content view has no layer".into());
}
let _: () = msg_send![layer, setCornerRadius: radius];
let _: () = msg_send![layer, setMasksToBounds: true];
let _: () = msg_send![ns_window, invalidateShadow];
}
Ok(())
}
/// Allow theme media to start without a click. WKWebView otherwise blocks
/// autoplay for anything with an audio track, which makes a theme's background
/// video sit frozen until the user interacts with the launcher.
pub fn allow_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> {
use objc2::msg_send;
use objc2::runtime::AnyObject;
window
.with_webview(|webview| unsafe {
let webview = webview.inner() as *mut AnyObject;
if webview.is_null() {
return;
}
let configuration: *mut AnyObject = msg_send![webview, configuration];
if configuration.is_null() {
return;
} // WKAudiovisualMediaTypesNone. This is a public WKWebView setting.
let _: () =
msg_send![configuration, setMediaTypesRequiringUserActionForPlayback: 0usize];
let current: usize = msg_send![configuration, mediaTypesRequiringUserActionForPlayback];
log::debug!(
target: "launcher",
"Theme media autoplay policy: {current}"
);
})
.map_err(|error| format!("Could not enable theme media autoplay: {error}"))
}
/// Show a native open panel that accepts `.app` bundles as well as folders.
///
/// AppKit treats application bundles as packages (directories) by default, so a
/// regular folder picker descends into `osu!.app` instead of selecting it. This
/// panel turns that behaviour off, letting the user pick the wrapper itself,
/// while still allowing a plain Wine prefix folder to be selected.
pub fn pick_osu_installation() -> Result<Option<PathBuf>, String> {
use objc2::msg_send;
use objc2::rc::autoreleasepool;
use objc2::runtime::AnyObject;
use std::ffi::{CStr, CString};
unsafe fn ns_string(value: &str) -> Result<*mut AnyObject, String> {
let value = CString::new(value).map_err(|_| "The path contains a NUL byte".to_string())?;
let string: *mut AnyObject =
unsafe { msg_send![objc2::class!(NSString), stringWithUTF8String: value.as_ptr()] };
if string.is_null() {
return Err("Could not create the native dialog text".to_string());
}
Ok(string)
}
autoreleasepool(|_| unsafe {
let panel: *mut AnyObject = msg_send![objc2::class!(NSOpenPanel), openPanel];
if panel.is_null() {
return Err("Could not create the native file picker".to_string());
}
let _: () = msg_send![panel, setCanChooseFiles: true];
let _: () = msg_send![panel, setCanChooseDirectories: true];
// Keep `.app` bundles selectable instead of navigating into them.
let _: () = msg_send![panel, setTreatsFilePackagesAsDirectories: false];
let _: () = msg_send![panel, setAllowsMultipleSelection: false];
let _: () = msg_send![panel, setCanCreateDirectories: false];
let title = ns_string("Select osu!.app")?;
let _: () = msg_send![panel, setTitle: title];
let message = ns_string("Pick your osu!.app wrapper, or a Wine prefix folder.")?;
let _: () = msg_send![panel, setMessage: message];
let prompt = ns_string("Select")?;
let _: () = msg_send![panel, setPrompt: prompt];
let applications = ns_string("/Applications")?;
let directory_url: *mut AnyObject =
msg_send![objc2::class!(NSURL), fileURLWithPath: applications];
if !directory_url.is_null() {
let _: () = msg_send![panel, setDirectoryURL: directory_url];
}
// NSModalResponseOK
let response: i64 = msg_send![panel, runModal];
if response != 1 {
return Ok(None);
}
let url: *mut AnyObject = msg_send![panel, URL];
if url.is_null() {
return Ok(None);
}
let path: *mut AnyObject = msg_send![url, path];
if path.is_null() {
return Ok(None);
}
let utf8: *const std::os::raw::c_char = msg_send![path, UTF8String];
if utf8.is_null() {
return Ok(None);
}
let path = CStr::from_ptr(utf8).to_string_lossy().into_owned();
Ok(Some(PathBuf::from(path)))
})
}
pub struct WineRuntime {
prefix: PathBuf,
wine_bin: PathBuf,
wine_root: Option<PathBuf>,
}
#[derive(Clone)]
pub struct WineDiagnostics {
pub prefix: PathBuf,
pub wine_bin: PathBuf,
pub app_bundle: Option<PathBuf>,
pub net8_installed: bool,
pub wine_version: Option<String>,
pub prefix_arch: Option<String>,
}
impl WineDiagnostics {
/// Inspect the wrapper without starting it, for the launcher's diagnostics panel.
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
let runtime = WineRuntime::for_osu_folder(osu_folder)?;
let net8_installed = [
runtime
.prefix
.join("drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"),
runtime
.prefix
.join("drive_c/Program Files (x86)/dotnet/shared/Microsoft.WindowsDesktop.App"),
]
.iter()
.any(|path| {
fs::read_dir(path)
.ok()
.map(|entries| {
entries.filter_map(|entry| entry.ok()).any(|entry| {
entry
.file_name()
.to_str()
.map(|name| name.starts_with("8."))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
Ok(Self {
prefix: runtime.prefix.clone(),
wine_bin: runtime.wine_bin.clone(),
app_bundle: app_bundle_for(&runtime.prefix),
net8_installed,
wine_version: wine_version_for(&runtime.wine_bin),
prefix_arch: prefix_architecture(&runtime.prefix),
})
}
}
impl WineRuntime {
/// Build a runtime description for an osu! folder inside a Wine prefix.
pub fn for_osu_folder(osu_folder: &Path) -> Result<Self, String> {
if !osu_folder.join("osu!.exe").is_file() {
return Err(format!(
"osu!.exe was not found in {}",
osu_folder.display()
));
}
let prefix = prefix_for(osu_folder).ok_or_else(|| {
"This osu! installation is not inside a Wine prefix. On macOS, choose the osu! folder inside your Wineskin, Whisky or CrossOver wrapper.".to_string()
})?;
let app = app_bundle_for(&prefix);
let (wine_bin, wine_root) = find_wine_bin(&prefix, app.as_deref()).ok_or_else(|| {
"Could not find a Wine runtime for this osu! installation. Make sure the Wineskin/Whisky/CrossOver wrapper is still installed.".to_string()
})?;
Ok(Self {
prefix,
wine_bin,
wine_root,
})
}
/// Spawn a Windows executable through the wrapper's Wine runtime.
pub fn spawn(&self, exe: &Path, args: &[&str]) -> Result<Child, String> {
let loader = self.loader_for(exe);
if !loader.is_file() {
return Err(format!(
"Wine loader not found at {}. The wrapper may be incomplete.",
loader.display()
));
}
let mut command = Command::new(&loader);
command.arg(exe).args(args);
command.current_dir(exe.parent().unwrap_or(&self.prefix));
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command.env("WINEPREFIX", &self.prefix);
command.env("WINEDEBUG", "-all");
command.env("WINEDLLOVERRIDES", "mscoree,mshtml=");
command.env("FREETYPE_PROPERTIES", "truetype:interpreter-version=35");
if let Some(wine_root) = &self.wine_root {
let mut library_paths = vec![
wine_root.join("lib"),
wine_root.join("lib64"),
wine_root.join("lib32on64"),
wine_root.clone(),
PathBuf::from("/opt/local/lib"),
PathBuf::from("/usr/lib"),
PathBuf::from("/usr/libexec"),
PathBuf::from("/usr/lib/system"),
PathBuf::from("/opt/X11/lib"),
];
if let Some(existing) = std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH") {
library_paths.extend(std::env::split_paths(&existing));
}
if let Ok(library_path) = std::env::join_paths(library_paths) {
command.env("DYLD_FALLBACK_LIBRARY_PATH", &library_path);
command.env("WINESKIN_LIB_PATH_FOR_FALLBACK", &library_path);
}
}
let mut paths = vec![self.wine_bin.clone()];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
if let Ok(path) = std::env::join_paths(paths) {
command.env("PATH", path);
}
let executable_name = exe
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "the Windows application".to_string());
command.spawn().map_err(|error| {
format!(
"Failed to start {executable_name} through Wine ({}): {}",
loader.display(),
error
)
})
}
/// Stop every process that belongs to this Wine prefix.
pub async fn kill_prefix(&self) {
let wineserver = self.wine_bin.join("wineserver");
if !wineserver.is_file() {
return;
}
let _ = Command::new(wineserver)
.arg("-k")
.env("WINEPREFIX", &self.prefix)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
fn loader_for(&self, exe: &Path) -> PathBuf {
let is_64_bit = pe_machine(exe) == Some(0x8664);
let candidates: &[&str] = if is_64_bit {
&["wine64", "wine"]
} else {
&["wine32on64", "wine", "wine64"]
};
candidates
.iter()
.map(|candidate| self.wine_bin.join(candidate))
.find(|candidate| candidate.is_file())
.unwrap_or_else(|| self.wine_bin.join(candidates[0]))
}
}
/// Run osu! (`-devserver ez-pp.farm`) and wait until the game exits.
pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> {
let folder = Path::new(osu_folder);
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
error
})?;
let mut game = runtime
.spawn(&folder.join("osu!.exe"), &["-devserver", "ez-pp.farm"])
.map_err(|error| {
log::error!(target: "osu", "Could not start osu!: {error}");
error
})?;
if let Some(pid) = game.id() {
log::info!(target: "osu", "osu! started through Wine (pid {pid})");
}
crate::logging::capture_osu_output(&mut game);
if patch {
if let Err(error) = start_patcher(&runtime, folder).await {
log::warn!(target: "patcher", "Could not start the patcher: {error}");
}
}
match game.wait().await {
Ok(status) => log::info!(target: "osu", "osu! exited with {status}"),
Err(error) => {
log::warn!(target: "osu", "Could not wait for osu!: {error}");
return Err(format!("Failed to wait for osu!: {}", error));
}
}
Ok(())
}
/// Start the Windows patcher through the same Wine prefix as osu!.
async fn start_patcher(runtime: &WineRuntime, osu_folder: &Path) -> Result<(), String> {
let patcher_exe = osu_folder
.join("EZPPLauncher")
.join("patcher")
.join("osu!.patcher.exe");
if !patcher_exe.is_file() {
return Err(format!(
"Patcher was requested but {} does not exist",
patcher_exe.display()
));
}
wait_for_osu_game().await;
log::info!(
target: "patcher",
"Starting the patcher from {}",
patcher_exe.display()
);
let mut patcher = runtime.spawn(&patcher_exe, &[])?;
if let Some(pid) = patcher.id() {
log::info!(target: "patcher", "Patcher started through Wine (pid {pid})");
}
crate::logging::capture_patcher_output(&mut patcher);
tauri::async_runtime::spawn(async move {
match patcher.wait().await {
Ok(status) => log::info!(target: "patcher", "Patcher exited with {status}"),
Err(error) => log::warn!(target: "patcher", "Could not wait for the patcher: {error}"),
}
});
Ok(())
}
/// Wait until the Wine-hosted osu! process exists, then give it time to create
/// its game window. Wine does not expose the window title to the launcher here,
/// so the process is the best signal available.
async fn wait_for_osu_game() {
let mut system = sysinfo::System::new_all();
let deadline = Instant::now() + StdDuration::from_secs(30);
let mut detected = false;
while Instant::now() < deadline {
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
if system
.processes()
.values()
.any(crate::utils::is_osu_game_process)
{
detected = true;
break;
}
sleep(Duration::from_millis(500)).await;
}
if !detected {
log::warn!(
target: "patcher",
"Could not detect the osu! process; starting the patcher anyway"
);
}
// Wine gives no reliable window-ready signal, so leave the game a moment
// to finish initializing before the patcher attaches to it.
sleep(Duration::from_secs(8)).await;
}
/// Run `osu!.exe -repair`, wait for the updater and close the game it starts.
pub async fn run_osu_updater(osu_folder: &str) -> Result<(), String> {
let folder = Path::new(osu_folder);
let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| {
log::error!(target: "osu", "Could not prepare the Wine runtime: {error}");
error
})?;
let mut updater = runtime
.spawn(&folder.join("osu!.exe"), &["-repair"])
.map_err(|error| {
log::error!(target: "osu", "Could not start the osu! updater: {error}");
error
})?;
if let Some(pid) = updater.id() {
log::info!(target: "osu", "osu! updater started through Wine (pid {pid})");
}
crate::logging::capture_osu_output(&mut updater);
let mut system = sysinfo::System::new_all();
let deadline = Instant::now() + StdDuration::from_secs(300);
loop {
if updater
.try_wait()
.map_err(|error| format!("Failed to check the osu! updater: {}", error))?
.is_some()
{
log::info!(target: "osu", "The osu! updater exited");
break;
}
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
if system
.processes()
.values()
.any(crate::utils::is_osu_game_process)
{
// The updater relaunched osu! to finish the update. Close it so the
// launcher can start the game itself with the correct server.
log::info!(target: "osu", "The updater relaunched osu!; closing the Wine prefix");
runtime.kill_prefix().await;
break;
}
if Instant::now() >= deadline {
log::warn!(target: "osu", "Timed out waiting for the osu! updater; closing the Wine prefix");
runtime.kill_prefix().await;
break;
}
sleep(Duration::from_millis(500)).await;
}
match updater.wait().await {
Ok(status) => log::info!(target: "osu", "osu! updater exited with {status}"),
Err(error) => log::warn!(target: "osu", "Could not wait for the osu! updater: {error}"),
}
Ok(())
}
/// Resolve a user-selected path (osu! folder, `.app` bundle, prefix or drive_c)
/// to a valid osu! installation folder.
pub fn resolve_osu_folder(folder: &Path) -> Option<PathBuf> {
if is_valid_osu_folder(folder) {
return Some(folder.to_path_buf());
}
let mut prefixes = Vec::new();
if folder.file_name() == Some(OsStr::new("drive_c")) {
if let Some(parent) = canonical_prefix(folder) {
prefixes.push(parent);
}
}
if folder.join("drive_c").is_dir() {
if let Some(parent) = canonical_prefix(&folder.join("drive_c")) {
prefixes.push(parent);
}
}
if folder.extension() == Some(OsStr::new("app")) || folder.join("Contents").is_dir() {
for drive_c in [
folder.join("Contents/Resources/drive_c"),
folder.join("Contents/SharedSupport/drive_c"),
folder.join("Contents/SharedSupport/prefix/drive_c"),
] {
if drive_c.is_dir() {
if let Some(parent) = canonical_prefix(&drive_c) {
prefixes.push(parent);
}
}
}
let mut drive_dirs = Vec::new();
find_drive_c_dirs(folder, 5, &mut drive_dirs);
prefixes.extend(
drive_dirs
.iter()
.filter_map(|drive_c| drive_c.parent().map(Path::to_path_buf)),
);
}
if let Some(prefix) = prefix_for(folder) {
prefixes.push(prefix);
}
prefixes.sort();
prefixes.dedup();
prefixes
.into_iter()
.find_map(|prefix| find_osu_folder_in_prefix(&prefix))
}
/// Auto-detect an osu! installation in the usual macOS wrapper locations.
pub fn find_osu_installation() -> Option<String> {
for app in app_bundles() {
if let Some(folder) = resolve_osu_folder(&app) {
return Some(folder.to_string_lossy().into_owned());
}
}
for prefix in bottle_prefixes() {
if let Some(folder) = find_osu_folder_in_prefix(&prefix) {
return Some(folder.to_string_lossy().into_owned());
}
}
None
}
fn find_osu_folder_in_prefix(prefix: &Path) -> Option<PathBuf> {
let drive_c = prefix.join("drive_c");
if !drive_c.is_dir() {
return None;
}
if let Some(folder) = common_osu_folders(&drive_c)
.into_iter()
.find(|folder| is_valid_osu_folder(folder))
{
return Some(folder);
}
let mut candidates = Vec::new();
collect_osu_folders(&drive_c, 0, 6, &mut candidates);
candidates
.into_iter()
.find(|folder| is_valid_osu_folder(folder))
}
fn common_osu_folders(drive_c: &Path) -> Vec<PathBuf> {
let mut folders = vec![
drive_c.join("osu!"),
drive_c.join("Program Files").join("osu!"),
drive_c.join("Program Files (x86)").join("osu!"),
];
if let Ok(users) = fs::read_dir(drive_c.join("users")) {
for user in users.flatten() {
let user_path = user.path();
if user_path.is_dir() {
folders.push(user_path.join("AppData").join("Local").join("osu!"));
folders.push(user_path.join("AppData").join("Roaming").join("osu!"));
}
}
}
folders
}
fn collect_osu_folders(dir: &Path, depth: usize, max_depth: usize, out: &mut Vec<PathBuf>) {
if depth > max_depth {
return;
}
if dir.join("osu!.exe").is_file() {
out.push(dir.to_path_buf());
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
continue;
}
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
if matches!(
name.as_str(),
"windows" | "programdata" | "$recycle.bin" | "system volume information"
) {
continue;
}
collect_osu_folders(&path, depth + 1, max_depth, out);
}
}
fn find_drive_c_dirs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() {
// Wineskin wrappers often add a convenience drive_c symlink at
// the app root. Resolve it to the real prefix so WINEPREFIX points
// at Contents/Resources instead of the bundle itself.
if path.file_name() == Some(OsStr::new("drive_c")) {
if let Ok(canonical) = fs::canonicalize(&path) {
out.push(canonical);
}
}
continue;
}
if !metadata.is_dir() {
continue;
}
if path.file_name() == Some(OsStr::new("drive_c")) {
out.push(path);
continue;
}
// Do not descend into nested apps (for example Wineskin.app).
if path.extension() == Some(OsStr::new("app")) {
continue;
}
find_drive_c_dirs(&path, depth - 1, out);
}
}
fn prefix_for(osu_folder: &Path) -> Option<PathBuf> {
let drive_c = osu_folder
.ancestors()
.find(|ancestor| ancestor.file_name() == Some(OsStr::new("drive_c")))?;
canonical_prefix(drive_c)
}
fn canonical_prefix(drive_c: &Path) -> Option<PathBuf> {
let canonical = fs::canonicalize(drive_c).ok()?;
canonical.parent().map(Path::to_path_buf)
}
fn app_bundle_for(prefix: &Path) -> Option<PathBuf> {
prefix
.ancestors()
.find(|ancestor| ancestor.extension() == Some(OsStr::new("app")))
.map(Path::to_path_buf)
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn app_bundles() -> Vec<PathBuf> {
let mut roots = vec![PathBuf::from("/Applications")];
if let Some(home) = home_dir() {
roots.push(home.join("Applications"));
}
let mut apps = Vec::new();
for root in roots {
let Ok(entries) = fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension() == Some(OsStr::new("app")) {
apps.push(path);
}
}
}
apps.sort();
apps
}
fn bottle_prefixes() -> Vec<PathBuf> {
let Some(home) = home_dir() else {
return Vec::new();
};
let bottle_roots = [
home.join("Library/Containers/com.isaacmarovitz.Whisky/Bottles"),
home.join("Library/Application Support/CrossOver/Bottles"),
home.join("Library/Application Support/Whisky/Bottles"),
];
let mut prefixes = Vec::new();
for root in bottle_roots {
let Ok(entries) = fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.join("drive_c").is_dir() {
prefixes.push(path);
}
}
}
prefixes.sort();
prefixes
}
fn find_wine_bin(prefix: &Path, app: Option<&Path>) -> Option<(PathBuf, Option<PathBuf>)> {
let mut candidates: Vec<(PathBuf, Option<PathBuf>)> = Vec::new();
if let Some(app) = app {
for root in [
app.join("Contents/SharedSupport/wine"),
app.join("Contents/Resources/WineskinEngine.bundle/Wine"),
app.join("Contents/Frameworks/wswine.bundle"),
] {
push_wine_root(&mut candidates, root);
}
}
for root in [
prefix.join("WineskinEngine.bundle/Wine"),
prefix.join("Wine"),
] {
push_wine_root(&mut candidates, root);
}
if let Some(home) = home_dir() {
for root in [
home.join("Applications/Whisky.app/Contents/Resources/wine"),
home.join("Applications/Whisky.app/Contents/Libraries/Wine"),
] {
push_wine_root(&mut candidates, root);
}
}
for root in [
PathBuf::from("/Applications/Whisky.app/Contents/Resources/wine"),
PathBuf::from("/Applications/Whisky.app/Contents/Libraries/Wine"),
PathBuf::from("/Applications/CrossOver.app/Contents/SharedSupport/CrossOver"),
] {
push_wine_root(&mut candidates, root);
}
for bin in [
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
] {
if has_wine_loader(&bin) {
candidates.push((bin, None));
}
}
for binary in ["wine64", "wine32on64", "wine"] {
if let Some(path) = find_in_path(binary) {
if let Some(bin) = path.parent() {
candidates.push((bin.to_path_buf(), None));
break;
}
}
}
candidates.into_iter().find(|(bin, _)| has_wine_loader(bin))
}
fn push_wine_root(candidates: &mut Vec<(PathBuf, Option<PathBuf>)>, root: PathBuf) {
let bin = root.join("bin");
if has_wine_loader(&bin) {
candidates.push((bin, Some(root)));
}
}
fn has_wine_loader(bin: &Path) -> bool {
["wine32on64", "wine64", "wine"]
.iter()
.any(|loader| bin.join(loader).is_file())
}
fn wine_version_for(bin: &Path) -> Option<String> {
let loader = ["wine64", "wine", "wine32on64"]
.iter()
.map(|name| bin.join(name))
.find(|path| path.is_file())?;
let output = std::process::Command::new(loader)
.arg("--version")
.env("WINEDEBUG", "-all")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!version.is_empty()).then_some(version)
}
fn prefix_architecture(prefix: &Path) -> Option<String> {
if prefix.join("drive_c/windows/syswow64").is_dir() {
Some("win64".to_string())
} else if prefix.join("drive_c/windows/system32").is_dir() {
Some("win32".to_string())
} else {
None
}
}
fn find_in_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(binary))
.find(|candidate| candidate.is_file())
}
fn pe_machine(exe: &Path) -> Option<u16> {
use std::io::{Read, Seek, SeekFrom};
let mut file = fs::File::open(exe).ok()?;
let mut signature = [0u8; 2];
file.read_exact(&mut signature).ok()?;
if &signature != b"MZ" {
return None;
}
file.seek(SeekFrom::Start(0x3c)).ok()?;
let mut offset = [0u8; 4];
file.read_exact(&mut offset).ok()?;
file.seek(SeekFrom::Start(u32::from_le_bytes(offset) as u64))
.ok()?;
let mut header = [0u8; 6];
file.read_exact(&mut header).ok()?;
if &header[..4] != b"PE\0\0" {
return None;
}
Some(u16::from_le_bytes([header[4], header[5]]))
}
+25 -12
View File
@@ -104,12 +104,12 @@ impl PresenceActor {
self.reconnecting = false;
match result {
ReconnectResult::Connected(client) => {
println!("Presence: reconnect succeeded.");
log::info!(target: "launcher", "Presence: reconnect succeeded");
self.client = Some(client);
self.push_presence().await;
}
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);
}
None => {
eprintln!("Presence: initial connect failed; spawning reconnect task.");
log::warn!(
target: "launcher",
"Presence: initial connect failed; spawning reconnect task"
);
let _ = resp.send(false);
self.spawn_reconnect_task();
}
@@ -145,7 +148,7 @@ impl PresenceActor {
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
if let Some(mut client) = self.client.take() {
let _ = client.clear_activity().await;
println!("Presence: disconnected.");
log::info!(target: "launcher", "Presence: disconnected");
}
let _ = resp.send(());
}
@@ -182,9 +185,12 @@ impl PresenceActor {
let activity = builder.build();
/* println!("Presence: updating activity: {:?}", activity); */
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.spawn_reconnect_task();
}
@@ -201,9 +207,12 @@ impl PresenceActor {
const MAX_ATTEMPTS: u32 = 5;
for attempt in 1..=MAX_ATTEMPTS {
let delay = Duration::from_secs(5 * attempt as u64);
println!(
log::info!(
target: "launcher",
"Presence: reconnect attempt {}/{} in {:?}…",
attempt, MAX_ATTEMPTS, delay
attempt,
MAX_ATTEMPTS,
delay
);
tokio::time::sleep(delay).await;
@@ -221,16 +230,16 @@ async fn try_connect_once() -> Option<AsyncDiscordIpcClient> {
match AsyncDiscordIpcClient::new("1032772293220384808").await {
Ok(mut client) => match client.connect().await {
Ok(_) => {
println!("Presence: IPC connected.");
log::info!(target: "launcher", "Presence: IPC connected");
Some(client)
}
Err(e) => {
eprintln!("Presence: IPC connect error: {:?}", e);
log::warn!(target: "launcher", "Presence: IPC connect error: {:?}", e);
None
}
},
Err(e) => {
eprintln!("Presence: failed to create client: {:?}", e);
log::warn!(target: "launcher", "Presence: failed to create client: {:?}", e);
None
}
}
@@ -256,7 +265,11 @@ pub async fn connect() -> bool {
pub async fn disconnect() {
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;
}
}
+2
View File
@@ -6,4 +6,6 @@ use std::sync::Mutex;
pub struct AppState {
pub config: Mutex<Config>,
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>>,
}
+502
View File
@@ -0,0 +1,502 @@
//! Loopback HTTP server for theme assets on Linux and macOS.
//!
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
//! protocol, and WKWebView's range handling makes large media stall there.
//! A real loopback HTTP server gives WebKit normal status codes, range
//! responses and MIME types, so v1 and v2 theme assets load the same way they
//! do in a browser without loading a whole video into a blob first.
use http_body_util::Full;
use http_range::HttpRange;
use hyper::body::{Bytes, Incoming};
use hyper::header::{
ACCEPT_RANGES, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ORIGIN, RANGE, VARY,
};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
use tokio::net::TcpListener;
type Body = Full<Bytes>;
/// Keep individual responses small enough that a media seek cannot allocate
/// an unbounded buffer. WebKit asks for the next range as it plays.
const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
struct ThemeServer {
port: u16,
root: PathBuf,
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
}
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
/// Bind the server on a random loopback port. The root is the themes folder.
pub fn start(root: PathBuf, media_root: PathBuf) -> Result<u16, String> {
if let Some(server) = SERVER.get() {
return Ok(server.port);
}
let root = std::fs::canonicalize(&root)
.map_err(|error| format!("Could not resolve the themes folder: {error}"))?;
let media_root = std::fs::canonicalize(&media_root)
.map_err(|error| format!("Could not resolve the theme media cache: {error}"))?;
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("Could not configure the theme asset server: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("Could not read the theme asset server address: {error}"))?
.port();
let previews = Arc::new(Mutex::new(HashMap::new()));
let server = ThemeServer {
port,
root: root.clone(),
previews: previews.clone(),
};
let _ = SERVER.set(server);
let root = Arc::new(root);
let media_root = Arc::new(media_root);
tauri::async_runtime::spawn(async move {
let listener = match TcpListener::from_std(listener) {
Ok(listener) => listener,
Err(error) => {
log::error!(target: "launcher", "Theme asset server failed: {error}");
return;
}
};
log::info!(
target: "launcher",
"Theme asset server listening on http://127.0.0.1:{port}/themes"
);
loop {
let (stream, _) = match listener.accept().await {
Ok(connection) => connection,
Err(error) => {
log::debug!(target: "launcher", "Theme asset server accept error: {error}");
continue;
}
};
let root = root.clone();
let media_root = media_root.clone();
let previews = previews.clone();
tokio::spawn(async move {
let service = service_fn(move |request| {
handle(request, root.clone(), media_root.clone(), previews.clone())
});
if let Err(error) = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await
{
log::debug!(target: "launcher", "Theme asset connection ended: {error}");
}
});
}
});
Ok(port)
}
pub fn base_url() -> Option<String> {
SERVER
.get()
.map(|server| format!("http://127.0.0.1:{}/themes", server.port))
}
/// The cache folder for optimized theme media, next to the themes folder.
pub fn media_root() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Could not locate the home directory".to_string())?;
let root = PathBuf::from(home)
.join(".ezpplauncher")
.join("media-cache");
std::fs::create_dir_all(&root)
.map_err(|error| format!("Failed to create the theme media cache: {error}"))?;
std::fs::canonicalize(&root)
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
}
/// Map a loopback theme URL back to the file it serves. Installed theme files
/// are restricted to the themes folder; editor preview URLs must match a token
/// registered through `preview_url`.
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
let server = SERVER.get()?;
let url = url::Url::parse(value).ok()?;
if url.scheme() != "http"
|| url.host_str() != Some("127.0.0.1")
|| url.port() != Some(server.port)
{
return None;
}
let path = percent_encoding::percent_decode_str(url.path())
.decode_utf8_lossy()
.to_string();
if let Some(relative) = path.strip_prefix("/themes/") {
return safe_path(&server.root, relative);
}
let token = path.strip_prefix("/preview/")?;
server
.previews
.lock()
.ok()
.and_then(|previews| previews.get(token).cloned())
}
/// Public URL for a file in the optimized media cache.
pub fn media_url(file_name: &str) -> Option<String> {
let server = SERVER.get()?;
Some(format!(
"http://127.0.0.1:{}/media/{}",
server.port, file_name
))
}
/// Register a file picked in the theme editor so its preview can be served
/// over HTTP too. The token is only a lookup key; unregistered paths are never
/// readable through the server.
pub fn preview_url(path: &Path) -> Result<String, String> {
let server = SERVER
.get()
.ok_or_else(|| "The theme asset server is not running".to_string())?;
let path = std::fs::canonicalize(path)
.map_err(|error| format!("Could not resolve the preview asset: {error}"))?;
if !path.is_file() {
return Err("The preview asset is not a file".to_string());
}
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
let token = hex::encode(&hasher.finalize()[..16]);
server
.previews
.lock()
.map_err(|_| "Could not register the preview asset".to_string())?
.insert(token.clone(), path);
Ok(format!("http://127.0.0.1:{}/preview/{token}", server.port))
}
async fn handle(
request: Request<Incoming>,
root: Arc<PathBuf>,
media_root: Arc<PathBuf>,
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
) -> Result<Response<Body>, Infallible> {
let origin = request
.headers()
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.and_then(allowed_origin);
if request.method() == Method::OPTIONS {
return Ok(preflight_response(origin.as_deref()));
}
if request.method() != Method::GET && request.method() != Method::HEAD {
return Ok(text_response(
StatusCode::METHOD_NOT_ALLOWED,
"Method not allowed",
origin.as_deref(),
));
}
let range = request
.headers()
.get(RANGE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let path = percent_encoding::percent_decode_str(request.uri().path())
.decode_utf8_lossy()
.to_string();
log::debug!(
target: "launcher",
"Theme asset request: {path} range={range:?}"
);
let file = if let Some(relative) = path.strip_prefix("/themes/") {
safe_path(&root, relative)
} else if let Some(relative) = path.strip_prefix("/media/") {
safe_path(&media_root, relative)
} else if let Some(token) = path.strip_prefix("/preview/") {
previews
.lock()
.ok()
.and_then(|previews| previews.get(token).cloned())
} else {
None
};
let response = match file {
Some(file) => {
serve_file(request.method(), &file, range.as_deref(), origin.as_deref()).await
}
None => Ok(text_response(
StatusCode::NOT_FOUND,
"Not found",
origin.as_deref(),
)),
};
Ok(response.unwrap_or_else(|message| {
log::warn!(target: "launcher", "Theme asset request failed: {message}");
text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&message,
origin.as_deref(),
)
}))
}
fn safe_path(root: &Path, relative: &str) -> Option<PathBuf> {
let relative = relative.trim_start_matches('/');
if relative.is_empty()
|| relative
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return None;
}
let candidate = root.join(relative);
let canonical = std::fs::canonicalize(candidate).ok()?;
canonical.starts_with(root).then_some(canonical)
}
async fn serve_file(
method: &Method,
path: &Path,
range_header: Option<&str>,
origin: Option<&str>,
) -> Result<Response<Body>, String> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|error| format!("Could not read theme asset: {error}"))?;
if !metadata.is_file() {
return Ok(text_response(StatusCode::NOT_FOUND, "Not found", origin));
}
let length = metadata.len();
let range = match range_header {
Some(header) => match parse_range(header, length) {
Ok(range) => range,
Err(()) => return Ok(range_not_satisfiable(length, origin)),
},
None => None,
};
let (status, start, end) = match range {
Some((start, end)) => (StatusCode::PARTIAL_CONTENT, start, end),
None => (StatusCode::OK, 0, length.saturating_sub(1)),
};
let body_length = if length == 0 {
0
} else {
end.saturating_sub(start) + 1
};
let body = if method == Method::HEAD || body_length == 0 {
Vec::new()
} else {
let mut file = tokio::fs::File::open(path)
.await
.map_err(|error| format!("Could not open theme asset: {error}"))?;
if start > 0 {
file.seek(SeekFrom::Start(start))
.await
.map_err(|error| format!("Could not seek theme asset: {error}"))?;
}
let mut buffer = vec![0_u8; body_length as usize];
file.read_exact(&mut buffer)
.await
.map_err(|error| format!("Could not read theme asset: {error}"))?;
buffer
};
let mut builder = Response::builder()
.status(status)
.header(CONTENT_TYPE, content_type(path))
.header(CONTENT_LENGTH, body_length.to_string())
.header(ACCEPT_RANGES, "bytes")
.header("Cache-Control", "no-store")
.header("X-Content-Type-Options", "nosniff")
.header("Cross-Origin-Resource-Policy", "cross-origin");
if status == StatusCode::PARTIAL_CONTENT {
builder = builder.header(CONTENT_RANGE, format!("bytes {start}-{end}/{length}"));
}
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin")
.header(
ACCESS_CONTROL_EXPOSE_HEADERS,
"Content-Range, Content-Length, Accept-Ranges",
);
}
builder
.body(Full::new(body.into()))
.map_err(|error| format!("Could not build theme asset response: {error}"))
}
fn parse_range(header: &str, length: u64) -> Result<Option<(u64, u64)>, ()> {
if length == 0 {
return Ok(None);
}
let ranges = HttpRange::parse(header, length).map_err(|_| ())?;
if ranges.len() != 1 {
return Err(());
}
let range = &ranges[0];
let start = range.start;
let end = (start + range.length - 1).min(length - 1);
if start >= length || end < start {
return Err(());
}
// Don't allocate the entire file for a single open-ended range request.
Ok(Some((start, end.min(start + MAX_RANGE_BYTES - 1))))
}
fn range_not_satisfiable(length: u64, origin: Option<&str>) -> Response<Body> {
let mut builder = Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(CONTENT_RANGE, format!("bytes */{length}"))
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(CONTENT_LENGTH, "0");
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin");
}
builder
.body(Full::new(Bytes::new()))
.expect("range response is valid")
}
fn preflight_response(origin: Option<&str>) -> Response<Body> {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
.header(
ACCESS_CONTROL_EXPOSE_HEADERS,
"Content-Range, Content-Length, Accept-Ranges",
)
.header(ACCESS_CONTROL_MAX_AGE, "600");
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin");
}
builder
.body(Full::new(Bytes::new()))
.expect("preflight response is valid")
}
fn text_response(status: StatusCode, message: &str, origin: Option<&str>) -> Response<Body> {
let mut builder = Response::builder()
.status(status)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(CONTENT_LENGTH, message.len().to_string())
.header("X-Content-Type-Options", "nosniff");
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin");
}
builder
.body(Full::new(Bytes::copy_from_slice(message.as_bytes())))
.expect("text response is valid")
}
fn allowed_origin(origin: &str) -> Option<String> {
if origin == "tauri://localhost"
|| origin == "http://tauri.localhost"
|| origin == "https://tauri.localhost"
|| origin == "null"
|| origin.starts_with("http://localhost:")
|| origin.starts_with("http://127.0.0.1:")
{
Some(origin.to_string())
} else {
None
}
}
fn content_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"webm" => "video/webm",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"ogv" => "video/ogg",
"mp3" => "audio/mpeg",
"ogg" | "oga" => "audio/ogg",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"flac" => "audio/flac",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
+160 -40
View File
@@ -1,7 +1,112 @@
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
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 {
let mut found = 0;
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>>(
osu_folder_path: P,
) -> Option<std::collections::HashMap<String, String>> {
if !osu_folder_path.as_ref().exists() {
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 osu_config_path = find_osu_user_config_path(osu_folder_path)?;
let mut config_map = std::collections::HashMap::new();
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(
osu_folder_path: &str,
key_values: &[(&str, Option<&str>)],
) -> Result<bool, String> {
let current_user = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.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 osu_config_path = find_osu_user_config_path(osu_folder_path)
.ok_or_else(|| "osu! user config file does not exist".to_string())?;
let mut lines = fs::read_to_string(&osu_config_path)
.map_err(|e| e.to_string())?
@@ -178,23 +264,47 @@ pub fn get_osu_config<P: AsRef<Path>>(
return Some(config_map);
}
#[cfg(not(windows))]
pub fn is_osuwinello_available() -> bool {
use std::process::Command;
Command::new("osu-wine")
.arg("--info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
#[cfg(target_os = "linux")]
pub fn get_osuwinello_path() -> Option<PathBuf> {
let mut search_paths =
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect::<Vec<_>>();
for path in ["/usr/local/bin", "/usr/bin", "/bin"] {
let path = PathBuf::from(path);
if !search_paths.contains(&path) {
search_paths.push(path);
}
}
if let Some(home) = std::env::var_os("HOME") {
let path = Path::new(&home).join(".local/bin");
if !search_paths.contains(&path) {
search_paths.push(path);
}
}
search_paths
.into_iter()
.map(|path| path.join("osu-wine"))
.find(|path| path.is_file())
}
#[cfg(windows)]
#[cfg(not(target_os = "linux"))]
pub fn get_osuwinello_path() -> Option<PathBuf> {
None
}
#[cfg(target_os = "linux")]
pub fn is_osuwinello_available() -> bool {
get_osuwinello_path().is_some()
}
#[cfg(not(target_os = "linux"))]
pub fn is_osuwinello_available() -> bool {
false
}
#[cfg(not(windows))]
#[cfg(target_os = "linux")]
pub fn is_wmctrl_available() -> bool {
use std::process::Command;
Command::new("wmctrl")
@@ -205,12 +315,12 @@ pub fn is_wmctrl_available() -> bool {
.is_ok()
}
#[cfg(windows)]
#[cfg(not(target_os = "linux"))]
pub fn is_wmctrl_available() -> bool {
false
}
#[cfg(not(windows))]
#[cfg(target_os = "linux")]
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
use std::process::Command;
let find_title = || -> Option<String> {
@@ -242,6 +352,11 @@ pub fn get_window_title_by_pid(target_pid: Pid) -> String {
find_title().unwrap_or_default()
}
#[cfg(target_os = "macos")]
pub fn get_window_title_by_pid(_pid: Pid) -> String {
String::new()
}
#[cfg(windows)]
pub fn get_window_title_by_pid(pid: Pid) -> String {
use std::ffi::OsString;
@@ -306,11 +421,16 @@ pub async fn is_net8_installed() -> bool {
match output_result {
Ok(output) => {
if !output.status.success() {
eprintln!(
"Error: `dotnet --list-runtimes` failed with status: {}",
log::warn!(
target: "launcher",
"`dotnet --list-runtimes` failed with 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;
}
+12 -8
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EZPPLauncher",
"version": "3.2.5",
"version": "3.2.7",
"identifier": "farm.EZPPFarm.Launcher",
"mainBinaryName": "ezpplauncher",
"build": {
@@ -28,24 +28,28 @@
"security": {
"csp": {
"default-src": "'self' tauri: customprotocol: asset:",
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost https://analytics.ez-pp.farm",
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost",
"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",
"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",
"media-src": "'self' asset: http://asset.localhost https://asset.localhost blob:",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
"worker-src": ["'self'", "blob:"],
"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: 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 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 http://127.0.0.1:* blob:",
"font-src": [
"'self'",
"asset:",
"blob:",
"data:",
"http://asset.localhost",
"https://asset.localhost"
"https://asset.localhost",
"http://127.0.0.1:*",
"https://cdn.jsdelivr.net"
]
},
"assetProtocol": {
"enable": true,
"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"
}
}
}
+7
View File
@@ -6,6 +6,10 @@
@import "@fontsource-variable/sora";
@import "@fontsource/space-mono";
@import "@fontsource/bebas-neue";
@import "@fontsource-variable/montserrat";
@import "@fontsource-variable/playfair-display";
@import "@fontsource-variable/jetbrains-mono";
:root {
--radius: 0.625rem;
@@ -158,6 +162,9 @@
-ms-user-select: none;
user-select: none;
}
html {
overscroll-behavior: none;
}
body {
@apply bg-background text-foreground;
}
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
type ConfettiCannonProps = {
colors?: string[];
count?: number;
};
let { colors = ['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24'], count = 820 }: ConfettiCannonProps =
$props();
let canvas = $state<HTMLCanvasElement>();
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
width: number;
height: number;
rotation: number;
spin: number;
flutterPhase: number;
flutterSpeed: number;
swayPhase: number;
swaySpeed: number;
swayAmount: number;
terminalVelocity: number;
wind: number;
color: string;
delay: number;
age: number;
lifetime: number;
};
onMount(() => {
const element = canvas;
if (!element) return;
const context = element.getContext('2d');
if (!context) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const random = (min: number, max: number) => Math.random() * (max - min) + min;
let width = 0;
let height = 0;
let dpr = 1;
let frame = 0;
let lastTime = 0;
let elapsed = 0;
let particles: Particle[] = [];
const spawn = () => {
const rect = element.getBoundingClientRect();
width = rect.width || window.innerWidth;
height = rect.height || window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
element.width = Math.max(1, Math.round(width * dpr));
element.height = Math.max(1, Math.round(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
particles = Array.from({ length: count }, (_, index) => {
const fromLeft = index % 2 === 0;
const direction = fromLeft ? 1 : -1;
const streamer = Math.random() < 0.35;
const pieceWidth = streamer ? random(3, 5) : random(5, 8);
const pieceHeight = streamer ? random(12, 24) : random(7, 14);
return {
x: fromLeft ? random(0, 28) : width - random(0, 28),
y: height - random(0, 16),
// Aim inward and up from each bottom corner. Horizontal drag and
// gravity turn the straight launch into a natural arc.
vx: direction * random(160, 900),
vy: -random(1050, 1450),
width: pieceWidth,
height: pieceHeight,
rotation: random(0, Math.PI * 2),
spin: random(-4, 4),
flutterPhase: random(0, Math.PI * 2),
flutterSpeed: random(2.5, 6.5),
swayPhase: random(0, Math.PI * 2),
swaySpeed: random(2.2, 4.2),
swayAmount: random(4, 14),
terminalVelocity: random(130, 230),
wind: random(-8, 8),
color: colors[Math.floor(Math.random() * colors.length)],
delay: random(0, 0.25),
age: 0,
lifetime: random(7, 9.5),
};
});
};
const paint = (time: number) => {
if (lastTime === 0) lastTime = time;
const delta = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
elapsed += delta;
context.clearRect(0, 0, width, height);
let alive = false;
for (const particle of particles) {
if (elapsed < particle.delay) {
alive = true;
continue;
}
if (particle.age >= particle.lifetime) continue;
particle.age += delta;
if (particle.age >= particle.lifetime) continue;
alive = true;
particle.vx *= Math.exp(-0.5 * delta);
if (particle.vy < 0) {
// The upward burst is slowed by gravity...
particle.vy += 1400 * delta;
} else {
// ...then the paper drifts down at its own slow terminal speed
// instead of accelerating like a solid object.
particle.vy += (particle.terminalVelocity - particle.vy) * (1 - Math.exp(-2.6 * delta));
}
particle.x += (particle.vx + particle.wind) * delta;
particle.y += particle.vy * delta;
particle.rotation += particle.spin * delta;
if (particle.x < -80 || particle.x > width + 80 || particle.y > height + 120) {
continue;
}
const fadeStart = particle.lifetime - 1.8;
const alpha =
particle.age > fadeStart ? Math.max(0, 1 - (particle.age - fadeStart) / 1.8) : 1;
if (alpha <= 0) continue;
const flutter = Math.cos(particle.flutterPhase + particle.age * particle.flutterSpeed);
const sway = Math.sin(particle.age * particle.swaySpeed + particle.swayPhase);
const scaleX = 0.15 + Math.abs(flutter) * 0.85;
context.save();
context.translate(particle.x + sway * particle.swayAmount, particle.y + sway * 3.5);
context.rotate(particle.rotation + flutter * 0.5);
context.scale(scaleX, 1);
context.globalAlpha = alpha;
context.fillStyle = particle.color;
context.fillRect(
-particle.width / 2,
-particle.height / 2,
particle.width,
particle.height
);
context.restore();
}
if (alive) {
frame = requestAnimationFrame(paint);
} else {
context.clearRect(0, 0, width, height);
}
};
spawn();
frame = requestAnimationFrame(paint);
return () => {
cancelAnimationFrame(frame);
};
});
</script>
<canvas
bind:this={canvas}
aria-hidden="true"
class="pointer-events-none absolute inset-0 z-20 h-full w-full"
></canvas>
@@ -0,0 +1,416 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { launcherStream } from '$lib/global';
import { getReleaseStream, getVersion, isOsuCorrupted } from '$lib/osuUtil';
import { osuInstallationPath, patch } from '$lib/userSettings';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
CheckCircle2,
ClipboardCopy,
Info,
LoaderCircle,
RefreshCw,
Wrench,
XCircle,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
type InstallationDiagnostics = {
platform: string;
osVersion?: string;
osArch: string;
launcherVersion: string;
osuFolder: string;
resolvedFolder?: string;
osuWritable?: boolean;
patcherPath?: string;
patcherInstalled: boolean;
patcherFileCount: number;
patcherTotalSize: number;
hostNet8: boolean;
osuwinello: boolean;
wmctrl: boolean;
winePrefix?: string;
wineBinary?: string;
wineAppBundle?: string;
wineVersion?: string;
winePrefixArch?: string;
wineNet8?: boolean;
wineError?: string;
ffmpegStatus?: string;
logsDirectory: string;
};
type Check = {
label: string;
detail: string;
status: 'ok' | 'warning' | 'error' | 'info';
};
let { onRepair }: { onRepair?: () => void } = $props();
let checking = $state(true);
let checks = $state<Check[]>([]);
let lastChecked = $state<Date | undefined>(undefined);
let platformName = $state('');
let launcherVersionName = $state('');
let osVersion = $state('');
let osArch = $state('');
let logsDirectory = $state('');
const icons = {
ok: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
info: Info,
};
const platformLabel = (platform: string) => {
switch (platform) {
case 'macos':
return 'macOS';
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
default:
return platform || 'unknown';
}
};
async function runChecks() {
checking = true;
checks = [];
try {
const folder = $osuInstallationPath;
const diagnostics = await invoke<InstallationDiagnostics>('installation_diagnostics', {
folder,
});
platformName = diagnostics.platform;
launcherVersionName = diagnostics.launcherVersion;
osVersion = diagnostics.osVersion ?? '';
osArch = diagnostics.osArch;
logsDirectory = diagnostics.logsDirectory;
const resolved = diagnostics.resolvedFolder;
// Only the experimental patcher build targets .NET 8.
const needsNet8 = $patch && $launcherStream === 'experimental';
const result: Check[] = [];
result.push({
label: 'Launcher',
detail: `${diagnostics.launcherVersion || 'unknown version'} · ${
diagnostics.osVersion || platformLabel(diagnostics.platform)
}${diagnostics.osArch ? ` (${diagnostics.osArch})` : ''}`,
status: 'ok',
});
if (!folder.trim()) {
result.push({
label: 'osu! installation',
detail: 'No installation path is configured. Run the setup wizard to pick osu!.',
status: 'error',
});
} else if (!resolved) {
result.push({
label: 'osu! installation',
detail: `The configured path could not be resolved: ${folder}`,
status: 'error',
});
} else {
result.push({
label: 'osu! installation',
detail: resolved,
status: 'ok',
});
try {
const [version, stream] = await Promise.all([
getVersion(resolved),
getReleaseStream(resolved),
]);
result.push({
label: 'osu! version',
detail: `${stream ?? 'unknown stream'} · ${version || 'version unavailable'}`,
status: version ? 'ok' : 'warning',
});
} catch (error) {
result.push({
label: 'osu! version',
detail: error instanceof Error ? error.message : String(error),
status: 'warning',
});
}
try {
const corrupted = await isOsuCorrupted(resolved);
result.push({
label: 'Game files',
detail: corrupted
? 'osu!.exe or a required file looks corrupted. Re-run the updater or setup.'
: 'The osu! executable passed the integrity check.',
status: corrupted ? 'error' : 'ok',
});
} catch (error) {
result.push({
label: 'Game files',
detail: error instanceof Error ? error.message : String(error),
status: 'warning',
});
}
}
if (typeof diagnostics.osuWritable === 'boolean') {
result.push({
label: 'osu! folder access',
detail: diagnostics.osuWritable
? 'The launcher can write to this folder, so updates and patcher downloads can be installed.'
: 'The launcher cannot write to this folder. Updates and patcher downloads will fail; check folder permissions.',
status: diagnostics.osuWritable ? 'ok' : 'error',
});
}
const selectedStream = $launcherStream;
const effectiveStream = diagnostics.platform === 'windows' ? selectedStream : 'stable';
result.push({
label: 'Patcher stream',
detail:
diagnostics.platform === 'windows'
? `${selectedStream} selected`
: `${effectiveStream} (selected: ${selectedStream}; macOS/Linux always use stable)`,
status: 'ok',
});
result.push({
label: 'Patcher',
detail: diagnostics.patcherInstalled
? `${diagnostics.patcherFileCount} file${
diagnostics.patcherFileCount === 1 ? '' : 's'
}, ${formatBytes(diagnostics.patcherTotalSize)} · ${diagnostics.patcherPath}`
: 'Patcher files are not installed yet. They will be downloaded automatically the next time you launch osu!.',
status: diagnostics.patcherInstalled ? 'ok' : $patch ? 'warning' : 'info',
});
result.push({
label: 'Logs',
detail: diagnostics.logsDirectory || 'The logs folder is unavailable.',
status: diagnostics.logsDirectory ? 'ok' : 'warning',
});
if (diagnostics.platform === 'macos') {
if (diagnostics.wineError) {
result.push({
label: 'Wine wrapper',
detail: diagnostics.wineError,
status: 'error',
});
} else {
result.push({
label: 'Wine prefix',
detail: diagnostics.winePrefix || 'Wine prefix not detected.',
status: diagnostics.winePrefix ? 'ok' : 'error',
});
result.push({
label: 'Wine runtime',
detail: diagnostics.wineBinary || 'No Wine loader found inside the wrapper.',
status: diagnostics.wineBinary ? 'ok' : 'error',
});
result.push({
label: 'Wine version',
detail: diagnostics.wineVersion || 'Could not determine the Wine version.',
status: diagnostics.wineVersion ? 'ok' : 'warning',
});
if (diagnostics.winePrefixArch) {
result.push({
label: 'Wine prefix architecture',
detail:
diagnostics.winePrefixArch === 'win64'
? '64-bit (win64)'
: diagnostics.winePrefixArch === 'win32'
? '32-bit (win32)'
: diagnostics.winePrefixArch,
status: 'ok',
});
}
if (diagnostics.wineAppBundle) {
result.push({
label: 'App wrapper',
detail: diagnostics.wineAppBundle,
status: 'ok',
});
}
result.push({
label: '.NET 8 in prefix (experimental)',
detail:
diagnostics.wineNet8 === true
? 'The Microsoft.WindowsDesktop.App 8 runtime was found.'
: 'The Windows .NET 8 Desktop Runtime was not found in this prefix. Only the experimental patcher stream needs it.',
status: diagnostics.wineNet8 === true ? 'ok' : needsNet8 ? 'error' : 'warning',
});
result.push({
label: 'FFmpeg (theme video)',
detail: diagnostics.ffmpegStatus
? diagnostics.ffmpegStatus
: 'FFmpeg was not found. Large theme videos will stay on WebKit’s software decoder and may stutter.',
status: diagnostics.ffmpegStatus ? 'ok' : 'warning',
});
}
} else if (diagnostics.platform === 'linux') {
result.push({
label: 'osu-winello',
detail: diagnostics.osuwinello
? 'osu-winello was found.'
: 'osu-winello was not found in PATH. Launching osu! may fail.',
status: diagnostics.osuwinello ? 'ok' : 'error',
});
result.push({
label: 'wmctrl',
detail: diagnostics.wmctrl
? 'wmctrl is available for window detection.'
: 'wmctrl is missing. The patcher may not detect the osu! window.',
status: diagnostics.wmctrl ? 'ok' : $patch ? 'warning' : 'info',
});
} else {
result.push({
label: '.NET 8 Desktop Runtime (experimental)',
detail: diagnostics.hostNet8
? 'Microsoft.WindowsDesktop.App 8 was found.'
: 'The .NET 8 Desktop Runtime was not found. Only the experimental patcher stream needs it.',
status: diagnostics.hostNet8 ? 'ok' : needsNet8 ? 'error' : 'warning',
});
}
checks = result;
lastChecked = new Date();
} catch (error) {
checks = [
{
label: 'Diagnostics',
detail: error instanceof Error ? error.message : String(error),
status: 'error',
},
];
} finally {
checking = false;
}
}
async function copyReport() {
const body = checks
.map((check) => `[${check.status.toUpperCase()}] ${check.label}: ${check.detail}`)
.join('\n');
const report = [
'EZPPLauncher diagnostics',
`Generated: ${new Date().toISOString()}`,
`Launcher: ${launcherVersionName || 'unknown'}`,
`Platform: ${osVersion || platformLabel(platformName)}${osArch ? ` (${osArch})` : ''}`,
`Logs: ${logsDirectory || 'unknown'}`,
'',
body,
].join('\n');
try {
await navigator.clipboard.writeText(report);
sileo.success({
title: 'Diagnostics copied',
description: 'Paste it into your support report.',
});
} catch {
sileo.error({ title: 'Could not copy diagnostics' });
}
}
onMount(runChecks);
</script>
<div class="divide-y divide-theme-800/50 border-t border-theme-800/50">
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
<div class="min-w-0">
<p class="text-sm font-medium">System checks</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Inspect the selected osu! install, folder permissions, Wine wrapper, patcher, FFmpeg and
runtime dependencies.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-1.5">
{#if lastChecked}
<span class="mr-1 text-[10px] text-muted-foreground">
{lastChecked.toLocaleTimeString()}
</span>
{/if}
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={copyReport}
>
<ClipboardCopy class="size-4" />
Copy report
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={runChecks}
disabled={checking}
>
<RefreshCw class={checking ? 'size-4 animate-spin' : 'size-4'} />
Refresh
</Button>
{#if onRepair}
<Button variant="secondary" size="sm" onclick={onRepair}>
<Wrench class="size-4" />
Repair setup
</Button>
{/if}
</div>
</div>
<div class="space-y-2 py-3">
{#if checking}
<div class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin" />
Checking your installation…
</div>
{:else}
{#each checks as check (check.label)}
{@const Icon = icons[check.status]}
<div
class="flex items-start gap-2.5 rounded-lg border border-theme-800/70 bg-theme-950/60 px-3 py-2"
>
<Icon
class="mt-0.5 size-4 shrink-0 {check.status === 'ok'
? 'text-emerald-400'
: check.status === 'warning'
? 'text-amber-300'
: check.status === 'error'
? 'text-destructive'
: 'text-muted-foreground'}"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-xs font-medium text-theme-100">{check.label}</p>
<Badge
variant={check.status === 'error'
? 'destructive'
: check.status === 'warning'
? 'secondary'
: 'outline'}
class="px-1.5 py-0 text-[9px] capitalize"
>
{check.status}
</Badge>
</div>
<p class="mt-0.5 break-words text-[10px] leading-relaxed text-muted-foreground">
{check.detail}
</p>
</div>
</div>
{/each}
{/if}
</div>
</div>
+337
View File
@@ -0,0 +1,337 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import { openLogsFolder } from '$lib/osuUtil';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
Copy,
FileText,
FolderOpen,
LoaderCircle,
RefreshCw,
Search,
Terminal,
X,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
type LogBundleInfo = {
path: string;
name: string;
size: number;
modified: number;
};
type LogBundleEntry = {
name: string;
content: string;
};
let { onclose }: { onclose: () => void } = $props();
let bundles = $state<LogBundleInfo[]>([]);
let selectedPath = $state<string | undefined>(undefined);
let entries = $state<LogBundleEntry[]>([]);
let selectedLog = $state('launcher.log');
let query = $state('');
let errorsOnly = $state(false);
let loadingBundles = $state(true);
let loadingEntries = $state(false);
let errorMessage = $state('');
const selectedBundle = $derived(bundles.find((bundle) => bundle.path === selectedPath));
const activeEntry = $derived(entries.find((entry) => entry.name === selectedLog));
const allLines = $derived(activeEntry?.content.split(/\r?\n/) ?? []);
const filteredLines = $derived.by(() => {
const text = query.trim().toLowerCase();
return allLines.filter((line) => {
if (errorsOnly && !/(error|warn|panic|fatal|failed)/i.test(line)) return false;
return !text || line.toLowerCase().includes(text);
});
});
const visibleLines = $derived(filteredLines.slice(0, 5000));
async function loadBundles() {
loadingBundles = true;
errorMessage = '';
try {
bundles = await invoke<LogBundleInfo[]>('list_log_bundles');
if (bundles.length === 0) {
selectedPath = undefined;
entries = [];
return;
}
const stillThere = bundles.some((bundle) => bundle.path === selectedPath);
const next = stillThere ? selectedPath : bundles[0].path;
selectedPath = next;
await loadBundle(next!);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
loadingBundles = false;
}
}
async function loadBundle(path: string) {
selectedPath = path;
loadingEntries = true;
errorMessage = '';
try {
entries = await invoke<LogBundleEntry[]>('read_log_bundle', { path });
if (!entries.some((entry) => entry.name === selectedLog)) {
selectedLog = entries[0]?.name ?? 'launcher.log';
}
} catch (error) {
entries = [];
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
loadingEntries = false;
}
}
async function refresh() {
await loadBundles();
}
async function copyVisible() {
const text = filteredLines.join('\n');
try {
await navigator.clipboard.writeText(text);
sileo.success({
title: 'Log lines copied',
description: `${filteredLines.length} lines from ${selectedLog}.`,
});
} catch {
sileo.error({ title: 'Could not copy the log' });
}
}
async function openFolder() {
try {
await openLogsFolder();
} catch (error) {
sileo.error({
title: 'Could not open the logs folder',
description: error instanceof Error ? error.message : String(error),
});
}
}
function lineClass(line: string) {
if (/(error|panic|fatal|failed)/i.test(line)) return 'text-red-300';
if (/(warn|deprecated|missing)/i.test(line)) return 'text-amber-200';
if (/(info|started|loaded|saved)/i.test(line)) return 'text-theme-200';
return 'text-theme-300';
}
onMount(loadBundles);
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') onclose();
}}
/>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-5 backdrop-blur-sm"
role="presentation"
>
<div
class="flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="log-viewer-title"
>
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-theme-800 p-3">
<div class="flex min-w-0 items-center gap-2">
<div class="flex size-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Terminal class="size-4" />
</div>
<div class="min-w-0">
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2>
<p class="truncate text-[10px] text-muted-foreground">
Launcher, osu! and patcher output, grouped by session
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={openFolder}
>
<FolderOpen class="size-3.5" />
Folder
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={refresh}
disabled={loadingBundles}
>
<RefreshCw class={loadingBundles ? 'size-3.5 animate-spin' : 'size-3.5'} />
Refresh
</Button>
<Button variant="ghost" size="icon" class="size-8" title="Close logs" onclick={onclose}>
<X class="size-4" />
</Button>
</div>
</header>
<div class="grid min-h-0 flex-1 grid-cols-[230px_minmax(0,1fr)]">
<aside class="flex min-h-0 flex-col border-r border-theme-800 bg-black/20">
<div
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
>
<span>Bundles</span>
<span>{bundles.length}</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
{#if loadingBundles}
<div class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin" />
Loading…
</div>
{:else if bundles.length === 0}
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5
MiB.
</div>
{:else}
{#each bundles as bundle (bundle.path)}
<button
type="button"
class="mb-1 w-full rounded-md border px-2 py-2 text-left transition {bundle.path ===
selectedPath
? 'border-primary/40 bg-primary/10'
: 'border-transparent hover:border-theme-800 hover:bg-theme-900/60'}"
onclick={() => loadBundle(bundle.path)}
>
<span class="flex items-center gap-1.5">
<FileText class="size-3.5 shrink-0 text-primary" />
<span class="truncate text-[11px] font-medium text-theme-100">
{bundle.name}
</span>
</span>
<span class="mt-1 block pl-5 text-[9px] text-muted-foreground">
{new Date(bundle.modified).toLocaleString()} · {formatBytes(bundle.size)}
</span>
</button>
{/each}
{/if}
</div>
</aside>
<main class="flex min-h-0 min-w-0 flex-col">
{#if errorMessage}
<div
class="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
>
<AlertTriangle class="size-4" />
{errorMessage}
</div>
{/if}
<div class="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-theme-800 p-2">
{#if entries.length > 0}
{#each entries as entry (entry.name)}
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] {selectedLog === entry.name
? 'bg-primary/15 text-primary'
: 'text-muted-foreground'}"
onclick={() => (selectedLog = entry.name)}
>
{entry.name}
</Button>
{/each}
{/if}
<div class="relative ml-auto w-52">
<Search
class="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-7 border-theme-800 bg-theme-950 pl-7 text-[10px]"
placeholder="Filter lines…"
bind:value={query}
/>
</div>
<div
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1"
>
<Checkbox id="log-errors-only" bind:checked={errorsOnly} />
<label for="log-errors-only" class="text-[10px] text-muted-foreground">
Errors only
</label>
</div>
<Button
variant="outline"
size="sm"
class="h-7 border-theme-800 bg-theme-950 text-[10px]"
onclick={copyVisible}
disabled={filteredLines.length === 0}
>
<Copy class="size-3" />
Copy
</Button>
</div>
<div class="min-h-0 flex-1 overflow-auto bg-black/25">
{#if loadingEntries}
<div
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
>
<LoaderCircle class="size-4 animate-spin" />
Reading bundle…
</div>
{:else if !selectedBundle}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
>
<Terminal class="size-8 opacity-40" />
<p class="text-xs">Choose a log bundle to inspect it.</p>
</div>
{:else if filteredLines.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
>
<Search class="size-6 opacity-40" />
<p class="text-xs">No lines match this filter.</p>
</div>
{:else}
<div class="p-3 font-mono text-[10px] leading-relaxed">
{#each visibleLines as line, index (index)}
<div class="whitespace-pre-wrap break-words {lineClass(line)}">{line || ' '}</div>
{/each}
{#if filteredLines.length > visibleLines.length}
<p class="mt-2 text-amber-200">
Showing the first {visibleLines.length} matching lines. Narrow the filter to see more.
</p>
{/if}
</div>
{/if}
</div>
<footer
class="flex shrink-0 items-center justify-between border-t border-theme-800 px-3 py-1.5 text-[9px] text-muted-foreground"
>
<span>{filteredLines.length} matching lines</span>
<span>
{selectedBundle
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
: 'No bundle selected'}
</span>
</footer>
</main>
</div>
</div>
</div>
@@ -0,0 +1,92 @@
<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) return;
if (!src) {
renderedFamily = family;
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,764 @@
<script lang="ts">
import {
analyzeThemeSvelteSource,
type ThemeSvelteAnalysis,
type ThemeSvelteDiagnostic,
} from '$lib/themeSvelte';
import {
THEME_SVELTE_COMPONENT_PLACEHOLDER,
THEME_SVELTE_COMPONENT_SKELETON,
} from '$lib/themeSvelteSnippets';
import { Component, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
import type * as Monaco from 'monaco-editor';
import { onMount } from 'svelte';
type MonacoApi = typeof import('monaco-editor');
let {
value = '',
onchange,
variables = [],
height = 320,
}: {
value?: string;
onchange?: (value: string) => void;
variables?: string[];
height?: number;
} = $props();
const LANGUAGE_ID = 'ezpp-theme-svelte';
const THEME_ID = 'ezpp-theme-svelte-dark';
const MARKER_OWNER = 'ezpp-theme-svelte';
// Keep these split so the Svelte parser cannot mistake a string literal for
// the end of this component's own script block.
const SCRIPT_OPEN = '<scr' + 'ipt>';
const SCRIPT_CLOSE = '</scr' + 'ipt>';
const STYLE_OPEN = '<sty' + 'le>';
const STYLE_CLOSE = '</sty' + 'le>';
const propDocs: { label: string; documentation: string; insert: string }[] = [
{
label: '$props',
insert:
'let { variables = {}, layer = {}, theme = {}, asset = (path) => path, screen = "home" } = $props();',
documentation:
'Receive live theme props: `variables`, `layer`, `theme`, `screen` and the `asset(path)` helper.',
},
{
label: 'variables',
insert: 'variables.${1:name}',
documentation: 'Live rule variables. They update when a Logic rule changes a variable.',
},
{
label: 'layer',
insert: 'layer.${1:width}',
documentation:
'The layer bounds: `layer.id`, `layer.name`, `layer.width` and `layer.height`.',
},
{
label: 'theme',
insert: 'theme.${1:name}',
documentation: 'Theme metadata: `theme.name`, `theme.author` and `theme.accentColor`.',
},
{
label: 'asset',
insert: 'asset("${1:image.png}")',
documentation:
'Resolve a file inside the theme `assets/` folder. Works in the launcher and editor preview.',
},
{
label: 'screen',
insert: 'screen',
documentation: 'The current launcher screen. Custom layers only render on Home today.',
},
{
label: '$state',
insert: 'let ${1:value} = $state(${2:0});',
documentation: 'Create reactive component state.',
},
{
label: '$derived',
insert: 'const ${1:value} = $derived(${2:expression});',
documentation: 'Create a value derived from other reactive state.',
},
{
label: '$effect',
insert: '$effect(() => {\n\t$0\n});',
documentation: 'Run a side effect when reactive values change.',
},
{
label: 'onMount',
insert: 'import { onMount } from "svelte";\n\nonMount(() => {\n\t$0\n});',
documentation: 'Run code after the component mounts. Import it from `svelte`.',
},
{
label: 'onDestroy',
insert: 'import { onDestroy } from "svelte";\n\nonDestroy(() => {\n\t$0\n});',
documentation: 'Clean up timers, listeners and observers before the layer unmounts.',
},
{
label: 'tick',
insert: 'import { tick } from "svelte";\n\nawait tick();',
documentation: 'Wait for pending DOM updates.',
},
{
label: 'import transition',
insert: 'import { ${1:fade} } from "svelte/transition";',
documentation:
'Import transitions such as `fade`, `fly`, `slide`, `scale`, `blur` and `draw` from `svelte/transition`.',
},
{
label: 'import store',
insert: 'import { ${1:writable} } from "svelte/store";',
documentation: 'Svelte stores are available from `svelte/store`.',
},
{
label: 'SvelteMap',
insert: 'import { SvelteMap } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteMap();',
documentation: 'Reactive Map from `svelte/reactivity`.',
},
{
label: 'SvelteSet',
insert: 'import { SvelteSet } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteSet();',
documentation: 'Reactive Set from `svelte/reactivity`.',
},
{
label: '{#if}',
insert: '{#if ${1:condition}}\n\t$0\n{/if}',
documentation: 'Conditionally render markup.',
},
{
label: '{#each}',
insert: '{#each ${1:items} as ${2:item}}\n\t$0\n{/each}',
documentation: 'Render a list. Add `(${3:item.id})` for a keyed each block.',
},
{
label: '{#snippet}',
insert: '{#snippet ${1:name}(${2:argument})}\n\t$0\n{/snippet}',
documentation: 'Declare a reusable snippet and render it with `{@render name(argument)}`.',
},
{
label: '{@render}',
insert: '{@render ${1:name}(${2:argument})}',
documentation: 'Render a snippet declared in this component.',
},
{
label: SCRIPT_OPEN,
insert: `${SCRIPT_OPEN}\n\t$0\n${SCRIPT_CLOSE}`,
documentation: 'Plain JavaScript only. TypeScript is not preprocessed in theme layers.',
},
{
label: STYLE_OPEN,
insert: `${STYLE_OPEN}\n\t$0\n${STYLE_CLOSE}`,
documentation: 'Scoped CSS for this component.',
},
];
let host = $state<HTMLDivElement>();
let monaco: MonacoApi | undefined;
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
let model: Monaco.editor.ITextModel | undefined;
let ready = $state(false);
let loadError = $state('');
let expanded = $state(false);
let lineCount = $state(1);
let cursorLine = $state(1);
let cursorColumn = $state(1);
let errorCount = $state(0);
let warningCount = $state(0);
let checking = $state(false);
let diagnostics = $state<ThemeSvelteDiagnostic[]>([]);
let currentValue = '';
let validateTimer: ReturnType<typeof setTimeout> | undefined;
let validationToken = 0;
const ensureLanguage = (api: MonacoApi) => {
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
api.languages.register({ id: LANGUAGE_ID });
}
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
comments: { blockComment: ['<!--', '-->'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
indentationRules: {
increaseIndentPattern: /<[^/!][^>]*>\s*$|\{[#:@][^}]*$/,
decreaseIndentPattern: /^\s*<\/|\{\/|\{:else/,
},
});
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
tokenizer: {
root: [
[/<!--/, { token: 'comment', next: '@comment' }],
[/<scr(?:ipt)\b[^>]*>/, { token: 'tag', next: '@script' }],
[/<sty(?:le)\b[^>]*>/, { token: 'tag', next: '@style' }],
[/<\/?[\w:-]+/, 'tag'],
[/[a-zA-Z_][\w-]*(?=\s*=)/, 'attribute.name'],
[/"[^"]*"|'[^']*'/, 'string'],
[/\{[#:/@][^}]*\}/, 'keyword'],
[/\{[^}]*\}/, 'expression'],
[/[<>/=]/, 'delimiter'],
[/[^<{]+/, 'text'],
[/[<{]/, 'delimiter'],
],
comment: [
[/[^-]+/, 'comment'],
[/-->/, { token: 'comment', next: '@pop' }],
[/./, 'comment'],
],
script: [
[/<\/script\s*>/, { token: 'tag', next: '@pop' }],
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@blockComment'],
[
/\b(?:import|from|let|const|var|if|else|for|while|return|function|async|await|new|typeof|export|class|extends|try|catch|finally|throw|break|continue|switch|case|default|of|in)\b/,
'keyword',
],
[/\$(?:state|derived|effect|props|bindable|inspect)(?:\.\w+)?/, 'predefined'],
[
/\b(?:onMount|onDestroy|tick|mount|unmount|fade|fly|slide|scale|blur|draw|writable|readable|derived|SvelteMap|SvelteSet|Math|Number|String|Boolean|JSON)\b/,
'predefined',
],
[/\b\d+(?:\.\d+)?\b/, 'number'],
[/[A-Za-z_$][\w$]*/, 'identifier'],
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|[=!<>+\-*/%?:]/, 'operator'],
[/[{}()[\].,;]/, 'delimiter'],
[/[^}\s]/, 'text'],
],
blockComment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment', next: '@pop' }],
[/[*/]/, 'comment'],
],
style: [
[/<\/style\s*>/, { token: 'tag', next: '@pop' }],
[/\/\*/, 'comment', '@blockComment'],
[/[.#][\w-]+/, 'attribute.name'],
[
/\b(?:var|calc|rgb|rgba|linear-gradient|radial-gradient|url|blur|clamp|min|max)\b/,
'predefined',
],
[/[a-zA-Z-]+(?=\s*:)/, 'type'],
[/#[0-9a-fA-F]{3,8}\b/, 'number.hex'],
[/\b\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|deg|s|ms)?\b/, 'number'],
[/"[^"]*"|'[^']*'/, 'string'],
[/[{}();:,]/, 'delimiter'],
[/[^}\s]/, 'text'],
],
},
});
api.editor.defineTheme(THEME_ID, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
{ token: 'predefined', foreground: '8be9fd' },
{ token: 'tag', foreground: 'ff79c6' },
{ token: 'attribute.name', foreground: '50fa7b' },
{ token: 'identifier', foreground: 'f8f8f2' },
{ token: 'expression', foreground: 'f8f8f2' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'number.hex', foreground: 'bd93f9' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'operator', foreground: 'ff79c6' },
{ token: 'type', foreground: '8be9fd' },
{ token: 'text', foreground: 'f8f8f2' },
{ token: 'delimiter', foreground: 'a1a1aa' },
],
colors: {
'editor.background': '#08080c',
'editor.foreground': '#f4f4f5',
'editorLineNumber.foreground': '#52525b',
'editorLineNumber.activeForeground': '#e4e4e7',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ff006e33',
'editor.inactiveSelectionBackground': '#ff006e22',
'editorCursor.foreground': '#ff006e',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#52525b',
'editorBracketHighlight.foreground1': '#ff79c6',
'editorBracketHighlight.foreground2': '#8be9fd',
'editorBracketHighlight.foreground3': '#f1fa8c',
'editorGutter.background': '#08080c',
'editorWidget.background': '#121218',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#121218',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#ff006e26',
'editorSuggestWidget.highlightForeground': '#ff79c6',
'editorHoverWidget.background': '#121218',
'editorHoverWidget.border': '#3f3f46',
'editorError.foreground': '#f87171',
'editorWarning.foreground': '#fbbf24',
'editorOverviewRuler.errorForeground': '#f87171',
'editorOverviewRuler.warningForeground': '#fbbf24',
'scrollbarSlider.background': '#52525b55',
'scrollbarSlider.hoverBackground': '#71717a88',
'scrollbarSlider.activeBackground': '#a1a1aa99',
},
});
};
const completionRange = (
api: MonacoApi,
currentModel: Monaco.editor.ITextModel,
position: Monaco.Position
) => {
const word = currentModel.getWordUntilPosition(position);
return new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
);
};
const completionPrefixes = ['variables.', 'layer.', 'theme.'];
// When the user has already typed `variables.`, Monaco replaces only the
// word after the dot. Strip the prefix from the insertion so the completion
// does not produce `variables.variables.score`.
const stripTypedPrefix = (
insertText: string,
currentModel: Monaco.editor.ITextModel,
range: Monaco.Range
) => {
const typed = currentModel
.getLineContent(range.startLineNumber)
.slice(0, range.startColumn - 1);
for (const prefix of completionPrefixes) {
if (typed.endsWith(prefix) && insertText.startsWith(prefix)) {
return insertText.slice(prefix.length);
}
}
return insertText;
};
const registerIntellisense = (api: MonacoApi) => {
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['.', '{', '#', '"', "'", ':', '<'],
provideCompletionItems(currentModel, position) {
const range = completionRange(api, currentModel, position);
const suggestions: Monaco.languages.CompletionItem[] = [];
for (const entry of propDocs) {
suggestions.push({
label: entry.label,
kind:
entry.label.charCodeAt(0) === 36 || entry.label.startsWith('{')
? api.languages.CompletionItemKind.Keyword
: entry.label.startsWith('import')
? api.languages.CompletionItemKind.Module
: api.languages.CompletionItemKind.Property,
insertText: stripTypedPrefix(entry.insert, currentModel, range),
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.label}`,
});
}
suggestions.push({
label: 'Svelte component skeleton',
kind: api.languages.CompletionItemKind.Snippet,
insertText: THEME_SVELTE_COMPONENT_SKELETON,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: 'A ready-to-edit layer with props, markup and scoped styles.',
sortText: '0_skeleton',
});
for (const variable of variables) {
suggestions.push({
label: `variables.${variable}`,
kind: api.languages.CompletionItemKind.Variable,
insertText: stripTypedPrefix(`variables.${variable}`, currentModel, range),
range,
documentation: `Live rule variable \`${variable}\`. Change it from a Logic rule.`,
sortText: `0_${variable}`,
});
}
return { suggestions, incomplete: false };
},
});
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(currentModel, position) {
const word = currentModel.getWordAtPosition(position);
if (!word) return null;
const entry = propDocs.find(
(candidate) => candidate.label === word.word || candidate.label === `${word.word}`
);
if (!entry) return null;
return {
range: new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
),
contents: [{ value: `**${entry.label}**\n\n${entry.documentation}` }],
};
},
});
return () => {
completion.dispose();
hover.dispose();
};
};
const syncDiagnostics = (analysis: ThemeSvelteAnalysis) => {
if (!monaco || !model) return;
const all: ThemeSvelteDiagnostic[] = analysis.ok
? analysis.warnings
: [...analysis.errors, ...analysis.warnings];
const markers: Monaco.editor.IMarkerData[] = all.map((diagnostic) => {
const lineNumber = Math.min(Math.max(1, diagnostic.line), model!.getLineCount());
const maxColumn = model!.getLineMaxColumn(lineNumber);
const startColumn = Math.min(Math.max(1, diagnostic.column), maxColumn);
const endLine = Math.min(
Math.max(lineNumber, diagnostic.endLine ?? lineNumber),
model!.getLineCount()
);
const endMaxColumn = model!.getLineMaxColumn(endLine);
const endColumn = Math.min(
Math.max(startColumn, diagnostic.endColumn ?? startColumn + 1),
endMaxColumn
);
return {
severity:
diagnostic.severity === 'error'
? monaco!.MarkerSeverity.Error
: monaco!.MarkerSeverity.Warning,
message: diagnostic.message,
source: 'EZPP Svelte layer',
startLineNumber: lineNumber,
startColumn,
endLineNumber: endLine,
endColumn: Math.max(startColumn + 1, endColumn),
};
});
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
errorCount = all.filter((diagnostic) => diagnostic.severity === 'error').length;
warningCount = all.filter((diagnostic) => diagnostic.severity === 'warning').length;
diagnostics = all;
};
const validate = async (source: string) => {
const token = ++validationToken;
checking = true;
const analysis = await analyzeThemeSvelteSource(source, 'ThemeLayer.svelte');
if (token !== validationToken) return;
checking = false;
syncDiagnostics(analysis);
};
const scheduleValidation = (source: string, immediate = false) => {
if (validateTimer) clearTimeout(validateTimer);
checking = true;
validateTimer = setTimeout(
() => {
void validate(source);
},
immediate ? 0 : 350
);
};
const updateStats = (source: string) => {
lineCount = Math.max(1, source.split('\n').length);
};
$effect(() => {
const next = value;
if (!model || !monaco || next === currentValue) return;
currentValue = next;
model.setValue(next);
updateStats(next);
scheduleValidation(next);
});
$effect(() => {
if (!expanded) return;
requestAnimationFrame(() => editor?.layout());
});
onMount(() => {
let disposed = false;
let disposeIntellisense: (() => void) | undefined;
void (async () => {
try {
const { default: EditorWorker } =
await import('monaco-editor/editor/editor.worker.js?worker');
if (disposed) return;
type WorkerHost = typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
};
(globalThis as WorkerHost).MonacoEnvironment ??= {
getWorker: () => new EditorWorker(),
};
const api = await import('monaco-editor');
if (disposed) return;
monaco = api;
ensureLanguage(api);
if (!host) return;
currentValue = value;
updateStats(currentValue);
model = api.editor.createModel(currentValue, LANGUAGE_ID);
editor = api.editor.create(host, {
model,
theme: THEME_ID,
ariaLabel: 'Custom Svelte component source',
automaticLayout: true,
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
fontSize: 11,
lineHeight: 18,
fontLigatures: true,
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: true,
folding: true,
minimap: { enabled: false },
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
insertSpaces: true,
scrollBeyondLastLine: false,
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'line',
fixedOverflowWidgets: true,
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
suggest: { showWords: false, preview: true },
wordBasedSuggestions: 'off',
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
});
editor.onDidChangeModelContent(() => {
const next = model?.getValue() ?? '';
currentValue = next;
updateStats(next);
scheduleValidation(next);
onchange?.(next);
});
editor.onDidChangeCursorPosition((event) => {
cursorLine = event.position.lineNumber;
cursorColumn = event.position.column;
});
disposeIntellisense = registerIntellisense(api);
ready = true;
scheduleValidation(currentValue, true);
editor.focus();
} catch (error) {
console.error('Could not load the Monaco Svelte editor', error);
loadError = error instanceof Error ? error.message : String(error);
scheduleValidation(value, true);
}
})();
return () => {
disposed = true;
if (validateTimer) clearTimeout(validateTimer);
disposeIntellisense?.();
editor?.dispose();
model?.dispose();
editor = undefined;
model = undefined;
monaco = undefined;
};
});
function toggleExpanded() {
expanded = !expanded;
requestAnimationFrame(() => {
editor?.layout();
editor?.focus();
});
}
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape' && expanded) {
event.preventDefault();
expanded = false;
}
}}
/>
{#if expanded}
<button
type="button"
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close expanded component editor"
onclick={() => (expanded = false)}
></button>
{/if}
<div
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
: 'relative rounded-md'}"
style={expanded ? '' : `height:${height}px`}
>
<div
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
>
<Component class="size-3 shrink-0 text-primary" />
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
Svelte component
</span>
<span class="ml-auto flex items-center gap-1.5">
{#if loadError}
<span class="text-[8px] text-destructive">Editor unavailable</span>
{:else if checking}
<span class="text-[8px] text-muted-foreground">Checking…</span>
{:else if errorCount > 0}
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
<CircleAlert class="size-2.5" />
{errorCount}
</span>
{:else}
<span class="text-[8px] text-emerald-400">Ready</span>
{/if}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
onclick={toggleExpanded}
>
{#if expanded}
<Minimize2 class="size-3" />
{:else}
<Maximize2 class="size-3" />
{/if}
</button>
{#if expanded}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Close editor"
aria-label="Close expanded editor"
onclick={() => (expanded = false)}
>
<X class="size-3" />
</button>
{/if}
</span>
</div>
{#if loadError}
<textarea
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
spellcheck={false}
{value}
placeholder={THEME_SVELTE_COMPONENT_PLACEHOLDER}
oninput={(event) => {
const next = (event.currentTarget as HTMLTextAreaElement).value;
onchange?.(next);
scheduleValidation(next);
}}
></textarea>
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
Monaco could not load: {loadError}
</p>
{:else}
<div class="relative min-h-0 flex-1">
<div bind:this={host} class="absolute inset-0"></div>
{#if !ready}
<div
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
>
Loading editor…
</div>
{/if}
</div>
{/if}
{#if diagnostics.length > 0}
<div class="max-h-24 shrink-0 overflow-y-auto border-t border-theme-800 bg-theme-950/70">
{#each diagnostics.slice(0, 8) as diagnostic, index (index)}
<div
class="flex items-start gap-1.5 border-b border-theme-800/60 px-2 py-1 last:border-b-0"
>
<CircleAlert
class="mt-px size-2.5 shrink-0 {diagnostic.severity === 'error'
? 'text-destructive'
: 'text-amber-300'}"
/>
<p class="min-w-0 flex-1 text-[8px] leading-relaxed text-theme-100">
{diagnostic.message}
</p>
<button
type="button"
class="shrink-0 font-mono text-[8px] text-muted-foreground hover:text-primary"
title="Jump to this line"
onclick={() => {
editor?.setPosition({ lineNumber: diagnostic.line, column: diagnostic.column });
editor?.revealLineInCenter(diagnostic.line);
editor?.focus();
}}
>
{diagnostic.line}:{diagnostic.column}
</button>
</div>
{/each}
</div>
{/if}
<div
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
>
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
<span>Ln {cursorLine}, Col {cursorColumn}</span>
<span class="ml-auto">
{#if checking}
Checking…
{:else if errorCount > 0}
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
{#if warningCount > 0}
<span class="text-amber-300">
· {warningCount} warning{warningCount === 1 ? '' : 's'}</span
>
{/if}
{:else if warningCount > 0}
<span class="text-amber-300">{warningCount} warning{warningCount === 1 ? '' : 's'}</span>
{:else}
No errors
{/if}
</span>
</div>
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { themeClassTokens } from '$lib/themeV2';
import { Code2, RotateCcw } from '@lucide/svelte';
let {
idPrefix,
value,
onchange,
}: {
idPrefix: string;
value?: string;
onchange: (value: string | undefined) => void;
} = $props();
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const invalidTokens = $derived(
typeof value === 'string'
? value
.trim()
.split(/\s+/)
.filter(Boolean)
.filter((token) => themeClassTokens(token).length === 0)
: []
);
function setValue(raw: string) {
onchange(raw.trim() ? raw : undefined);
}
</script>
{#snippet resetAction()}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground"
disabled={!value}
onclick={() => onchange(undefined)}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="CSS classes"
icon={Code2}
open={false}
actions={resetAction}
contentClass="space-y-2 pt-2.5"
>
<div class="space-y-1">
<Label for={`${idPrefix}-css-classes`} class={label}>Classes</Label>
<Input
id={`${idPrefix}-css-classes`}
class="h-8 border-theme-800 bg-theme-950 font-mono text-xs text-theme-50"
value={value ?? ''}
placeholder="glow-card hero-title"
spellcheck="false"
autocomplete="off"
oninput={(event) => setValue(event.currentTarget.value)}
/>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Separate multiple classes with spaces and style them from the theme's Custom CSS, for example
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">.glow-card</code>.
</p>
{#if invalidTokens.length > 0}
<p class="text-[10px] leading-relaxed text-amber-400">
Ignored: <span class="font-mono">{invalidTokens.join(' ')}</span>. Use letters, numbers,
dashes and underscores.
</p>
{/if}
</ThemeSection>
@@ -0,0 +1,132 @@
<script lang="ts">
import { compileThemeSvelte } from '$lib/themeSvelte';
import { themeAssetUrl, type ThemeV2ScriptValue } from '$lib/themeV2';
import { TriangleAlert } from '@lucide/svelte';
import { mount, unmount } from 'svelte';
let {
source,
assets = '',
assetOverrides = {},
variables = {},
screen = 'home',
layerId,
layerName,
width,
height,
themeName = '',
themeAuthor = '',
accentColor = '#ff006e',
interactive = false,
}: {
source: string;
assets?: string;
assetOverrides?: Record<string, string>;
variables?: Record<string, ThemeV2ScriptValue>;
screen?: string;
layerId: string;
layerName?: string;
width: number;
height: number;
themeName?: string;
themeAuthor?: string;
accentColor?: string;
interactive?: boolean;
} = $props();
type ComponentInstance = ReturnType<typeof mount>;
let host = $state<HTMLDivElement>();
let error = $state('');
let loading = $state(true);
let instance: ComponentInstance | undefined;
let generation = 0;
// A $state proxy can be passed to mount() so $props() in the custom
// component remains reactive when a logic rule changes a variable.
const componentProps = $state<Record<string, unknown>>({
variables: {},
screen: 'home',
layer: { id: '', name: undefined, width: 0, height: 0 },
theme: { name: '', author: '', accentColor: '#ff006e' },
asset: (path: string) => path,
});
const errorMessage = (value: unknown) =>
value instanceof Error ? `${value.name}: ${value.message}` : String(value);
$effect(() => {
componentProps.variables = { ...variables };
componentProps.screen = screen;
componentProps.layer = { id: layerId, name: layerName, width, height };
componentProps.theme = { name: themeName, author: themeAuthor, accentColor };
componentProps.asset = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
});
$effect(() => {
const current = ++generation;
const currentSource = source;
let mounted: ComponentInstance | undefined;
loading = true;
void (async () => {
const result = await compileThemeSvelte(currentSource, `layer-${layerId}.svelte`);
if (current !== generation || !host) return;
if (!result.component) {
error = result.errors[0]?.message ?? 'The custom component could not be compiled.';
loading = false;
return;
}
try {
mounted = mount(result.component, { target: host, props: componentProps });
instance = mounted;
error = '';
} catch (mountError) {
error = errorMessage(mountError);
} finally {
loading = false;
}
})();
return () => {
// Invalidate a compile that is still in flight so it cannot mount into
// a host that has already been removed with this layer.
generation += 1;
loading = false;
if (mounted) {
void unmount(mounted, { outro: false });
if (instance === mounted) instance = undefined;
}
};
});
$effect(() => {
if (!interactive || !error) return;
console.error(`[theme] Svelte layer ${layerId} failed`, error);
});
</script>
<div class="relative h-full w-full">
<div bind:this={host} class="h-full w-full"></div>
{#if interactive && loading}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-theme-700 bg-theme-950/65 text-[10px] text-muted-foreground"
>
Compiling Svelte…
</div>
{/if}
{#if interactive && error}
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-destructive/50 bg-destructive/12 p-3 text-center"
>
<TriangleAlert class="size-4 shrink-0 text-destructive" />
<p class="text-[10px] font-semibold text-destructive">Svelte layer failed</p>
<p class="line-clamp-4 font-mono text-[9px] leading-relaxed text-destructive/90">{error}</p>
<p class="text-[8px] text-muted-foreground">Fix the error in the layer inspector.</p>
</div>
{/if}
</div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Slider from '$lib/components/ui/slider/slider.svelte';
import type { ThemeV2Filters } from '$lib/themeV2';
let {
filters,
onchange,
}: {
filters?: ThemeV2Filters;
onchange: (filters: ThemeV2Filters) => void;
} = $props();
type Control = {
key: keyof ThemeV2Filters;
label: string;
min: number;
max: number;
step: number;
fallback: number;
unit: string;
};
const controls: Control[] = [
{ key: 'blur', label: 'Blur', min: 0, max: 40, step: 1, fallback: 0, unit: 'px' },
{ key: 'brightness', label: 'Brightness', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
{ key: 'contrast', label: 'Contrast', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
{ key: 'saturate', label: 'Saturation', min: 0, max: 4, step: 0.05, fallback: 1, unit: '' },
{
key: 'hueRotate',
label: 'Hue rotate',
min: -360,
max: 360,
step: 1,
fallback: 0,
unit: 'deg',
},
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
{ key: 'sepia', label: 'Sepia', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
{ key: 'invert', label: 'Invert', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
];
const valueOf = (control: Control) => filters?.[control.key] ?? control.fallback;
const display = (control: Control) => {
const value = valueOf(control);
return `${control.step < 1 ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)}${control.unit}`;
};
const isDefault = $derived(controls.every((control) => valueOf(control) === control.fallback));
function setValue(key: keyof ThemeV2Filters, value: number) {
onchange({ ...(filters ?? {}), [key]: value });
}
</script>
{#snippet filterActions()}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground"
disabled={isDefault}
onclick={() => onchange({})}
>
Reset
</Button>
{/snippet}
<ThemeSection
title="Filters"
open={false}
actions={filterActions}
contentClass="space-y-2.5 pt-2.5"
>
<div class="space-y-2.5">
{#each controls as control (control.key)}
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="text-[10px] text-muted-foreground">{control.label}</span>
<span class="font-mono text-[9px] text-theme-100">{display(control)}</span>
</div>
<Slider
min={control.min}
max={control.max}
step={control.step}
value={valueOf(control)}
onValueChange={(value) => setValue(control.key, value)}
/>
</div>
{/each}
</div>
</ThemeSection>
@@ -0,0 +1,236 @@
<script lang="ts">
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import ThemeTransformControls from '$lib/components/theme/ThemeTransformControls.svelte';
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 {
type ThemeV2Behavior,
type ThemeV2BehaviorTarget,
type ThemeV2Element,
type ThemeV2Group,
type ThemeV2MaskShape,
} from '$lib/themeV2';
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
let {
group = $bindable(),
behaviors,
elements,
groups,
variables = [],
focusToken = 0,
onungroup,
onpickmask,
onaddbehavior,
ondeleterule,
}: {
group: ThemeV2Group;
behaviors: ThemeV2Behavior[];
elements: ThemeV2Element[];
groups: ThemeV2Group[];
variables?: string[];
focusToken?: number;
onungroup: () => void;
onpickmask: () => void;
onaddbehavior: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
ondeleterule: (id: string) => 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-3 p-3">
<div class="flex items-center gap-2 border-b border-theme-800 pb-2.5">
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
<Boxes class="size-4" />
</div>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-theme-50">Group</p>
<p class="truncate font-mono text-[10px] text-muted-foreground">{group.id}</p>
</div>
</div>
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
<div class="space-y-1">
<Label for="group-name" class={label}>Group name</Label>
<Input
id="group-name"
class={field}
value={group.name ?? ''}
placeholder="Group name"
oninput={(event) => (group.name = (event.currentTarget as HTMLInputElement).value)}
/>
</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((group.opacity ?? 1) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={group.opacity ?? 1}
onValueChange={(value) => (group.opacity = value)}
/>
</div>
</ThemeSection>
<ThemeTransformControls
transform={group}
idPrefix="group"
includeTranslate
onchange={(patch) => Object.assign(group, patch)}
/>
<ThemeSection
title="Group state"
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
>
<div class="flex items-center gap-1.5">
<Checkbox
id="group-hidden"
checked={group.hidden === true}
onCheckedChange={(checked) => (group.hidden = checked === true)}
/>
<Label for="group-hidden" class="text-[10px] font-normal text-muted-foreground">Hidden</Label>
</div>
<div class="flex items-center gap-1.5">
<Checkbox
id="group-locked"
checked={group.locked === true}
onCheckedChange={(checked) => (group.locked = checked === true)}
/>
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
</div>
</ThemeSection>
<ThemeCssClasses
idPrefix="group"
value={group.cssClasses}
onchange={(value) => (group.cssClasses = value)}
/>
<ThemeLinkedLogic
target={{ type: 'group', id: group.id }}
{behaviors}
{elements}
{groups}
{variables}
{focusToken}
onadd={onaddbehavior}
ondelete={ondeleterule}
/>
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="group-mask-shape" class={label}>Clip shape</Label>
<Select.Root
type="single"
value={group.mask?.shape ?? 'none'}
onValueChange={(value) => {
if (!value) return;
if (value === 'none') {
group.mask = undefined;
return;
}
group.mask = {
shape: value as ThemeV2MaskShape,
radius: group.mask?.radius ?? 24,
sides: group.mask?.sides ?? 6,
innerRadius: group.mask?.innerRadius ?? 0.5,
src: group.mask?.src,
fit: group.mask?.fit ?? 'cover',
};
}}
>
<Select.Trigger
id="group-mask-shape"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{group.mask?.shape ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="none" label="None" />
<Select.Item value="rectangle" label="Rectangle" />
<Select.Item value="ellipse" label="Ellipse" />
<Select.Item value="polygon" label="Polygon" />
<Select.Item value="star" label="Star" />
<Select.Item value="image" label="Image alpha" />
</Select.Content>
</Select.Root>
</div>
{#if group.mask?.shape === 'polygon' || group.mask?.shape === 'star'}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="group-mask-sides" class={label}>Points</Label>
<Input
id="group-mask-sides"
class={field}
type="number"
min="3"
max="24"
value={group.mask.sides ?? 6}
oninput={(event) => {
if (group.mask) {
group.mask.sides = Number((event.currentTarget as HTMLInputElement).value) || 3;
}
}}
/>
</div>
{#if group.mask.shape === 'star'}
<div class="space-y-1">
<Label for="group-mask-inner" class={label}>Inner radius</Label>
<Input
id="group-mask-inner"
class={field}
type="number"
min="0.1"
max="0.95"
step="0.05"
value={group.mask.innerRadius ?? 0.5}
oninput={(event) => {
if (group.mask) {
group.mask.innerRadius =
Number((event.currentTarget as HTMLInputElement).value) || 0.5;
}
}}
/>
</div>
{/if}
</div>
{/if}
{#if group.mask?.shape === 'image'}
<div class="space-y-2 rounded-md border border-theme-800 bg-black/20 p-2">
<p class="truncate font-mono text-[10px] text-theme-100">
{group.mask.src ?? 'No mask image yet'}
</p>
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onpickmask}>
<RefreshCw class="size-3.5" />
Choose mask image
</Button>
</div>
{/if}
<p class="text-[9px] leading-relaxed text-muted-foreground">
The mask clips every layer in this group. Image masks use the picture's alpha channel.
</p>
</ThemeSection>
<ThemeFilterControls filters={group.filters} onchange={(filters) => (group.filters = filters)} />
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onungroup}>
<Ungroup class="size-3.5" />
Ungroup group
</Button>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import { THEME_ICON_BODIES } from '$lib/themeIconData';
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
let {
name,
set = DEFAULT_THEME_ICON_SET,
size = '1em',
class: className = '',
}: {
name: ThemeIconName;
set?: ThemeIconSet;
size?: number | string;
class?: string;
} = $props();
const icon = $derived(
THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name]
);
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${icon.width} ${icon.height}`}
width={size}
height={size}
class={className}
aria-hidden="true"
focusable="false"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html icon.body}
</svg>
@@ -0,0 +1,195 @@
<script lang="ts">
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
import type {
ThemeV2Behavior,
ThemeV2BehaviorTarget,
ThemeV2Element,
ThemeV2Group,
} from '$lib/themeV2';
import { AlertTriangle, ChevronDown, Plus, Zap } from '@lucide/svelte';
let {
target,
behaviors,
elements,
groups,
variables = [],
focusToken = 0,
onadd,
ondelete,
}: {
target: ThemeV2BehaviorTarget;
behaviors: ThemeV2Behavior[];
elements: ThemeV2Element[];
groups: ThemeV2Group[];
variables?: string[];
focusToken?: number;
onadd: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
ondelete: (id: string) => void;
} = $props();
let open = $state(false);
let selectedId = $state<string | undefined>(undefined);
let section = $state<HTMLElement>();
let lastFocusToken = 0;
const linkedBehaviors = $derived(
behaviors.filter(
(behavior) => behavior.target?.type === target.type && behavior.target?.id === target.id
)
);
const pointerRuleCount = $derived(
linkedBehaviors.filter(
(behavior) =>
behavior.trigger.type === 'click' ||
behavior.trigger.type === 'hover' ||
behavior.trigger.type === 'leave'
).length
);
const interactiveTargets = $derived(
target.type === 'group'
? themeGroupInteractiveCount(groups, elements, target.id)
: elements.find((element) => element.id === target.id)?.interactive
? 1
: 0
);
const needsInteractive = $derived(pointerRuleCount > 0 && interactiveTargets === 0);
$effect(() => {
if (!focusToken || focusToken === lastFocusToken) return;
lastFocusToken = focusToken;
open = true;
if (linkedBehaviors.length === 1) selectedId = linkedBehaviors[0].id;
requestAnimationFrame(() => section?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
});
function addRule() {
const behavior = onadd(target);
open = true;
if (behavior) selectedId = behavior.id;
}
function deleteRule(id: string) {
ondelete(id);
if (selectedId === id) selectedId = undefined;
}
</script>
<section bind:this={section} class="border-t border-theme-800 pt-2">
<div class="flex items-center gap-1.5">
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-theme-900/60"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Zap class="size-3.5 shrink-0 text-primary" />
<span class="text-[11px] font-semibold text-theme-100">Logic</span>
{#if linkedBehaviors.length > 0}
<span class="rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] text-muted-foreground">
{linkedBehaviors.length}
</span>
{/if}
{#if needsInteractive}
<span title="A linked pointer rule needs an interactive layer">
<AlertTriangle class="size-3 text-amber-300" />
</span>
{/if}
<ChevronDown
class="ml-auto size-3 shrink-0 text-muted-foreground transition-transform {open
? 'rotate-180'
: ''}"
/>
</button>
<Button
variant="secondary"
size="sm"
class="h-6 shrink-0 gap-1 px-1.5 text-[9px]"
title="Add a rule linked to this layer or group"
onclick={addRule}
>
<Plus class="size-3" />
Add
</Button>
</div>
{#if open}
<div class="mt-1.5 space-y-1.5">
{#if linkedBehaviors.length === 0}
<p
class="rounded-md border border-dashed border-theme-800 px-2 py-1.5 text-[9px] leading-relaxed text-muted-foreground"
>
No rules linked yet. Add one to react to clicks, hovers, timers or screen changes.
</p>
{:else}
{#each linkedBehaviors as behavior (behavior.id)}
<div class="overflow-hidden rounded-md border border-theme-800 bg-black/20">
<button
type="button"
class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left transition-colors hover:bg-theme-900/60"
aria-expanded={selectedId === behavior.id}
onclick={() => (selectedId = selectedId === behavior.id ? undefined : behavior.id)}
>
<span
class="size-1.5 shrink-0 rounded-full {behavior.enabled === false
? 'bg-theme-600'
: 'bg-emerald-400'}"
></span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5">
<span class="truncate text-[10px] font-medium text-theme-100">
{behavior.name?.trim() || 'Rule'}
</span>
{#if behavior.once}
<span class="rounded bg-theme-900 px-1 py-0.5 text-[8px] text-muted-foreground">
once
</span>
{/if}
</span>
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
{themeBehaviorSummary(behavior, elements, groups)}
</span>
</span>
<ChevronDown
class="size-3 shrink-0 text-muted-foreground transition-transform {selectedId ===
behavior.id
? 'rotate-180'
: ''}"
/>
</button>
{#if selectedId === behavior.id}
<div class="border-t border-theme-800 bg-theme-950/60">
<ThemeBehaviorInspector
{behavior}
{elements}
{groups}
{variables}
embedded
ondelete={() => deleteRule(behavior.id)}
/>
</div>
{/if}
</div>
{/each}
{/if}
{#if needsInteractive}
<div
class="flex items-start gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-1.5"
>
<AlertTriangle class="mt-0.5 size-3 shrink-0 text-amber-300" />
<p class="text-[9px] leading-relaxed text-amber-100/80">
{#if target.type === 'group'}
Mark at least one layer in this group as <strong>Interactive</strong>.
{:else}
Enable <strong>Interactive</strong> for this layer.
{/if}
</p>
</div>
{/if}
</div>
{/if}
</section>
@@ -0,0 +1,47 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import { AlertTriangle, Zap } from '@lucide/svelte';
let {
count,
warning = false,
onopen,
}: {
count: number;
warning?: boolean;
onopen: () => void;
} = $props();
const title = $derived(
warning
? `${count} linked logic rule${count === 1 ? '' : 's'} · needs an interactive layer · Open in inspector`
: `${count} linked logic rule${count === 1 ? '' : 's'} · Open in inspector`
);
</script>
{#if count > 0}
<button
type="button"
class="-m-0.5 shrink-0 cursor-pointer rounded-full p-0.5 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
{title}
aria-label={title}
onclick={(event) => {
event.stopPropagation();
onopen();
}}
>
<Badge
variant="outline"
class="pointer-events-none h-4 gap-0.5 border px-1 py-0 text-[8px] font-semibold leading-none {warning
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
: 'border-primary/25 bg-primary/10 text-primary'}"
>
{#if warning}
<AlertTriangle class="size-2.5" />
{:else}
<Zap class="size-2.5" />
{/if}
{count}
</Badge>
</button>
{/if}
@@ -0,0 +1,72 @@
<script lang="ts">
import lottie, { type AnimationItem } from 'lottie-web';
import { onDestroy, onMount } from 'svelte';
let {
src,
loop = true,
autoplay = true,
speed = 1,
}: {
src: string;
loop?: boolean;
autoplay?: boolean;
speed?: number;
} = $props();
let container = $state<HTMLDivElement | undefined>(undefined);
let animation: AnimationItem | undefined;
let reducedMotion = false;
onMount(() => {
if (!container || !src) return;
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
let cancelled = false;
const start = (animationData?: unknown) => {
if (cancelled || !container) return;
try {
animation = lottie.loadAnimation({
container,
renderer: 'svg',
loop,
autoplay: autoplay && !reducedMotion,
...(animationData ? { animationData } : { path: src }),
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
});
animation.setSpeed(speed);
if (reducedMotion) {
animation.addEventListener('DOMLoaded', () => animation?.goToAndStop(0, true));
}
} catch (error) {
console.warn('Could not load the Lottie layer', error);
}
};
// Fetching the JSON ourselves works with Tauri's asset protocols, where
// lottie-web's internal XHR can be blocked. Fall back to its own loader.
void fetch(src)
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((data) => start(data))
.catch(() => start());
return () => {
cancelled = true;
};
});
$effect(() => {
if (!animation) return;
animation.loop = loop;
animation.setSpeed(speed);
if (autoplay && !reducedMotion) animation.play();
else animation.pause();
});
onDestroy(() => {
animation?.destroy();
animation = undefined;
});
</script>
<div bind:this={container} class="h-full w-full" aria-hidden="true"></div>
@@ -0,0 +1,890 @@
<script lang="ts">
import { isThemeClockVariable, THEME_CLOCK_VARIABLE_NAMES } from '$lib/themeClock';
import { parseThemeScript } from '$lib/themeScriptLang';
import type { ThemeV2Element, ThemeV2Group, ThemeV2ScriptScreen } from '$lib/themeV2';
import { Braces, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
import type * as Monaco from 'monaco-editor';
import { onMount } from 'svelte';
type MonacoApi = typeof import('monaco-editor');
let {
value = '',
onchange,
variables = [],
elements = [],
groups = [],
height = 280,
}: {
value?: string;
onchange?: (value: string) => void;
variables?: string[];
elements?: ThemeV2Element[];
groups?: ThemeV2Group[];
height?: number;
} = $props();
const LANGUAGE_ID = 'ezpp-theme-script';
const THEME_ID = 'ezpp-theme-script-dark';
const MARKER_OWNER = 'ezpp-theme-script';
const screens: ThemeV2ScriptScreen[] = ['home', 'themes', 'settings', 'login'];
const statementDocs: Record<string, string> = {
let: 'Declare a theme variable.\n\n`let score = 0;`',
const: 'Declare a value that will not be reassigned.\n\n`const bonus = 10;`',
var: 'Declare a theme variable using the older JavaScript keyword.\n\n`var score = 0;`',
if: 'Run a block only when a condition is true.\n\n`if (score >= 10) { ... }`',
else: 'Run a block when the `if` condition is false.\n\n`else { ... }`',
for: 'Run a bounded loop.\n\n`for (let i = 0; i < 3; i++) { ... }`',
while: 'Run while a condition stays true. Loops stop after 1,000 iterations.',
true: 'Boolean true.',
false: 'Boolean false.',
screen: 'The current launcher screen, such as `"home"` or `"themes"`.',
Math: 'JavaScript math helpers such as `Math.floor(value)` and `Math.random()`.',
};
const actionFunctions: {
name: string;
label: string;
insert: string;
documentation: string;
}[] = [
{
name: 'showLayer',
label: 'showLayer(id)',
insert: 'showLayer(${1:"layer_id"});',
documentation: 'Make a layer visible.',
},
{
name: 'hideLayer',
label: 'hideLayer(id)',
insert: 'hideLayer(${1:"layer_id"});',
documentation: 'Hide a layer.',
},
{
name: 'toggleLayer',
label: 'toggleLayer(id)',
insert: 'toggleLayer(${1:"layer_id"});',
documentation: 'Flip a layer between visible and hidden.',
},
{
name: 'showGroup',
label: 'showGroup(id)',
insert: 'showGroup(${1:"group_id"});',
documentation: 'Make a group visible.',
},
{
name: 'hideGroup',
label: 'hideGroup(id)',
insert: 'hideGroup(${1:"group_id"});',
documentation: 'Hide a group.',
},
{
name: 'toggleGroup',
label: 'toggleGroup(id)',
insert: 'toggleGroup(${1:"group_id"});',
documentation: 'Flip a group between visible and hidden.',
},
{
name: 'goTo',
label: 'goTo(screen)',
insert: 'goTo(${1:"themes"});',
documentation: 'Navigate to `home`, `themes`, `settings` or `login`.',
},
{
name: 'navigate',
label: 'navigate(screen)',
insert: 'navigate(${1:"themes"});',
documentation: 'Alias for `goTo`.',
},
{
name: 'openUrl',
label: 'openUrl(url)',
insert: 'openUrl(${1:"https://example.com"});',
documentation: 'Open a website after a confirmation dialog. Click triggers only.',
},
{
name: 'launch',
label: 'launch()',
insert: 'launch();',
documentation: 'Start the normal osu! launch flow. Click triggers only.',
},
{
name: 'stop',
label: 'stop()',
insert: 'stop();',
documentation: 'End the current script immediately.',
},
];
const functions: {
name: string;
label: string;
insert: string;
documentation: string;
}[] = [
{
name: 'min',
label: 'min(a, b, ...)',
insert: 'min(${1:a}, ${2:b})',
documentation: 'Returns the smallest number.',
},
{
name: 'max',
label: 'max(a, b, ...)',
insert: 'max(${1:a}, ${2:b})',
documentation: 'Returns the largest number.',
},
{
name: 'clamp',
label: 'clamp(value, min, max)',
insert: 'clamp(${1:value}, ${2:0}, ${3:100})',
documentation: 'Keeps a number inside a range.',
},
{
name: 'round',
label: 'round(value)',
insert: 'round(${1:value})',
documentation: 'Rounds to the nearest whole number.',
},
{
name: 'floor',
label: 'floor(value)',
insert: 'floor(${1:value})',
documentation: 'Rounds down.',
},
{
name: 'ceil',
label: 'ceil(value)',
insert: 'ceil(${1:value})',
documentation: 'Rounds up.',
},
{
name: 'abs',
label: 'abs(value)',
insert: 'abs(${1:value})',
documentation: 'Returns the absolute value.',
},
{
name: 'random',
label: 'random()',
insert: 'random()',
documentation: 'Returns a random number between 0 and 1.',
},
{
name: 'chance',
label: 'chance(percent)',
insert: 'chance(${1:25})',
documentation: 'Returns true with the given percentage chance.',
},
{
name: 'visible',
label: 'visible(layerId)',
insert: 'visible(${1:"layer_id"})',
documentation: 'Whether a layer is currently visible.',
},
{
name: 'groupVisible',
label: 'groupVisible(groupId)',
insert: 'groupVisible(${1:"group_id"})',
documentation: 'Whether a group is currently visible.',
},
{
name: 'contains',
label: 'contains(text, search)',
insert: 'contains(${1:text}, ${2:"search"})',
documentation: 'Whether text contains a smaller string.',
},
{
name: 'length',
label: 'length(text)',
insert: 'length(${1:text})',
documentation: 'Returns the number of characters in text.',
},
{
name: 'number',
label: 'number(value)',
insert: 'number(${1:value})',
documentation: 'Converts a value to a number.',
},
{
name: 'string',
label: 'string(value)',
insert: 'string(${1:value})',
documentation: 'Converts a value to text.',
},
{
name: 'boolean',
label: 'boolean(value)',
insert: 'boolean(${1:value})',
documentation: 'Converts a value to true or false.',
},
];
const snippets: { label: string; insert: string; documentation: string }[] = [
{
label: 'let … = …;',
insert: 'let ${1:name} = ${2:0};',
documentation: 'Declare a theme variable.',
},
{
label: 'if (…) { … }',
insert: 'if (${1:condition}) {\n\t$0\n}',
documentation: 'Run statements when a condition matches.',
},
{
label: 'if (…) { … } else { … }',
insert: 'if (${1:condition}) {\n\t$0\n} else {\n\t\n}',
documentation: 'Run one of two blocks.',
},
{
label: 'for (…) { … }',
insert: 'for (let ${1:i} = 0; ${1:i} < ${2:3}; ${1:i}++) {\n\t$0\n}',
documentation: 'Run a bounded loop.',
},
{
label: 'while (…) { … }',
insert: 'while (${1:condition}) {\n\t$0\n}',
documentation: 'Run while a condition stays true.',
},
{
label: 'clicks += 1;',
insert: '${1:clicks} += ${2:1};',
documentation: 'Add to a number variable.',
},
{
label: 'showLayer(…);',
insert: 'showLayer(${1:"layer_id"});',
documentation: 'Make a layer visible.',
},
{
label: 'hideLayer(…);',
insert: 'hideLayer(${1:"layer_id"});',
documentation: 'Hide a layer.',
},
{
label: 'toggleLayer(…);',
insert: 'toggleLayer(${1:"layer_id"});',
documentation: 'Flip a layer between visible and hidden.',
},
{
label: 'goTo(…);',
insert: 'goTo(${1:"themes"});',
documentation: 'Navigate to another launcher screen.',
},
{
label: 'openUrl(…);',
insert: 'openUrl(${1:"https://example.com"});',
documentation: 'Open a website after a confirmation dialog.',
},
];
let host = $state<HTMLDivElement | undefined>(undefined);
let monaco: MonacoApi | undefined;
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
let model: Monaco.editor.ITextModel | undefined;
let ready = $state(false);
let loadError = $state('');
let expanded = $state(false);
let lineCount = $state(1);
let cursorLine = $state(1);
let cursorColumn = $state(1);
let errorCount = $state(0);
let currentValue = '';
const ensureLanguage = (api: MonacoApi) => {
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
api.languages.register({ id: LANGUAGE_ID });
}
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
indentationRules: {
increaseIndentPattern: /\{[^}"']*$/,
decreaseIndentPattern: /^\s*\}/,
},
});
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
tokenizer: {
root: [
[/\s+/, 'white'],
[/\/\/.*$/, 'comment'],
[/\/\*/, { token: 'comment', next: '@comment' }],
[/"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
[/'/, { token: 'string.quote', bracket: '@open', next: '@singleString' }],
[/`/, { token: 'string.quote', bracket: '@open', next: '@templateString' }],
[
/\b(?:let|const|var|if|else|for|while|do|function|return|break|continue|switch|case|default|new|delete|typeof|instanceof|in|of|class|try|catch|finally|throw|this)\b/,
'keyword',
],
[/\b(?:true|false|null|undefined)\b/, 'keyword'],
[
/\b(?:min|max|clamp|round|floor|ceil|abs|random|chance|visible|groupVisible|contains|length|number|string|boolean|Number|String|Boolean|showLayer|hideLayer|toggleLayer|showGroup|hideGroup|toggleGroup|goTo|navigate|openUrl|launch|stop|Math)\b/,
'predefined',
],
[/\b(?:screen)\b/, 'constant'],
[/\b\d+(?:\.\d+)?\b/, 'number'],
[/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'],
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|\*\*|[=!<>+\-*/%?:]/, 'operator'],
[/[{}()[\]]/, '@brackets'],
[/[,;]/, 'delimiter'],
[/[^\s]/, 'invalid'],
],
string: [
[/[^"\\]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
singleString: [
[/[^'\\]+/, 'string'],
[/\\./, 'string.escape'],
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
templateString: [
[/[^`\\]+/, 'string'],
[/\\./, 'string.escape'],
[/`/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
comment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment', next: '@pop' }],
[/[*/]/, 'comment'],
],
},
});
api.editor.defineTheme(THEME_ID, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
{ token: 'predefined', foreground: '8be9fd' },
{ token: 'constant', foreground: 'ffb86c' },
{ token: 'identifier', foreground: 'f8f8f2' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'string.escape', foreground: 'ffb86c' },
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'operator', foreground: 'ff79c6' },
{ token: 'invalid', foreground: 'ff5555' },
],
colors: {
'editor.background': '#08080c',
'editor.foreground': '#f4f4f5',
'editorLineNumber.foreground': '#52525b',
'editorLineNumber.activeForeground': '#e4e4e7',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ff006e33',
'editor.inactiveSelectionBackground': '#ff006e22',
'editorCursor.foreground': '#ff006e',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#52525b',
'editorBracketHighlight.foreground1': '#ff79c6',
'editorBracketHighlight.foreground2': '#8be9fd',
'editorBracketHighlight.foreground3': '#f1fa8c',
'editorGutter.background': '#08080c',
'editorWidget.background': '#121218',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#121218',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#ff006e26',
'editorSuggestWidget.highlightForeground': '#ff79c6',
'editorHoverWidget.background': '#121218',
'editorHoverWidget.border': '#3f3f46',
'editorError.foreground': '#f87171',
'editorOverviewRuler.errorForeground': '#f87171',
'scrollbarSlider.background': '#52525b55',
'scrollbarSlider.hoverBackground': '#71717a88',
'scrollbarSlider.activeBackground': '#a1a1aa99',
},
});
};
const completionRange = (
api: MonacoApi,
currentModel: Monaco.editor.ITextModel,
position: Monaco.Position
) => {
const word = currentModel.getWordUntilPosition(position);
return new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
);
};
const insideString = (text: string) => {
let inside = false;
for (let index = 0; index < text.length; index += 1) {
if (text[index] !== '"') continue;
let backslashes = 0;
for (let check = index - 1; check >= 0 && text[check] === '\\'; check -= 1) {
backslashes += 1;
}
if (backslashes % 2 === 0) inside = !inside;
}
return inside;
};
const registerIntellisense = (api: MonacoApi) => {
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['"', '.'],
provideCompletionItems(currentModel, position) {
const range = completionRange(api, currentModel, position);
const line = currentModel.getLineContent(position.lineNumber);
const before = line.slice(0, position.column - 1);
const suggestions: Monaco.languages.CompletionItem[] = [];
if (insideString(before)) {
const prefix = before.slice(0, before.lastIndexOf('"')).toLowerCase();
const wantsScreen = /(?:goTo|navigate)\s*\(\s*$/.test(prefix);
const wantsGroup = /(?:showGroup|hideGroup|toggleGroup|groupVisible)\s*\(\s*$/.test(
prefix
);
const wantsLayer = /(?:showLayer|hideLayer|toggleLayer|visible)\s*\(\s*$/.test(prefix);
if (wantsScreen) {
for (const screen of screens) {
suggestions.push({
label: screen,
kind: api.languages.CompletionItemKind.EnumMember,
insertText: screen,
range,
documentation: `Go to the ${screen} screen.`,
sortText: `0_${screen}`,
});
}
} else if (wantsGroup) {
for (const group of groups) {
suggestions.push({
label: group.name?.trim() || group.id,
detail: group.id,
kind: api.languages.CompletionItemKind.Struct,
insertText: group.id,
range,
documentation: `Group id: \`${group.id}\``,
sortText: `0_${group.id}`,
});
}
} else if (wantsLayer) {
for (const element of elements) {
suggestions.push({
label: element.name?.trim() || element.id,
detail: element.id,
kind: api.languages.CompletionItemKind.Field,
insertText: element.id,
range,
documentation: `${element.type} layer id: \`${element.id}\``,
sortText: `0_${element.id}`,
});
}
}
return { suggestions, incomplete: false };
}
for (const snippet of snippets) {
suggestions.push({
label: snippet.label,
kind: api.languages.CompletionItemKind.Snippet,
insertText: snippet.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: snippet.documentation,
sortText: `0_${snippet.label}`,
});
}
for (const entry of Object.entries(statementDocs)) {
suggestions.push({
label: entry[0],
kind: api.languages.CompletionItemKind.Keyword,
insertText: entry[0],
range,
documentation: entry[1],
sortText: `1_${entry[0]}`,
});
}
for (const variable of new Set([...THEME_CLOCK_VARIABLE_NAMES, ...variables])) {
const clock = isThemeClockVariable(variable);
suggestions.push({
label: variable,
kind: api.languages.CompletionItemKind.Variable,
insertText: variable,
range,
documentation: clock
? `Built-in clock value, read-only and refreshed every second. Display it in text layers with \`{${variable}}\`.`
: `Theme variable. Assign it with \`${variable} += 1;\` and display it in text layers with \`{${variable}}\`.`,
sortText: `${clock ? '1' : '2'}_${variable}`,
});
}
for (const screen of screens) {
suggestions.push({
label: `"${screen}"`,
kind: api.languages.CompletionItemKind.EnumMember,
insertText: `"${screen}"`,
range,
documentation: 'Screen name for `goTo()` and `screen`.',
sortText: `3_${screen}`,
});
}
for (const entry of actionFunctions) {
suggestions.push({
label: entry.label,
kind: api.languages.CompletionItemKind.Function,
insertText: entry.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.name}`,
});
}
for (const entry of functions) {
suggestions.push({
label: entry.label,
kind: api.languages.CompletionItemKind.Function,
insertText: entry.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.name}`,
});
}
return { suggestions, incomplete: false };
},
});
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(currentModel, position) {
const word = currentModel.getWordAtPosition(position);
if (!word) return null;
let contents = statementDocs[word.word];
let kind = 'Keyword';
if (!contents) {
const entry = [...functions, ...actionFunctions].find(
(candidate) => candidate.name === word.word
);
if (entry) {
contents = `**${entry.label}**\n\n${entry.documentation}`;
kind = 'Function';
}
}
if (!contents && (isThemeClockVariable(word.word) || variables.includes(word.word))) {
contents = isThemeClockVariable(word.word)
? `**${word.word}** — built-in clock value\n\nRefreshes every second. Use it in text layers as \`{${word.word}}\` or read it directly in logic.`
: `**${word.word}** — theme variable\n\nUse it in text layers as \`{${word.word}}\`.`;
kind = 'Variable';
}
if (!contents) {
const element = elements.find((candidate) => candidate.id === word.word);
if (element) {
contents = `**${element.name?.trim() || element.type}**\n\nLayer id: \`${element.id}\``;
kind = 'Layer';
}
}
if (!contents) {
const group = groups.find((candidate) => candidate.id === word.word);
if (group) {
contents = `**${group.name?.trim() || 'Group'}**\n\nGroup id: \`${group.id}\``;
kind = 'Group';
}
}
if (!contents) return null;
return {
range: new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
),
contents: [{ value: `_${kind}_\n\n${contents}` }],
};
},
});
return () => {
completion.dispose();
hover.dispose();
};
};
const syncDiagnostics = (source: string) => {
if (!monaco || !model) return;
const result = parseThemeScript(source);
const markers: Monaco.editor.IMarkerData[] = result.errors.map((error) => {
const lineNumber = Math.min(Math.max(1, error.line), model!.getLineCount());
const maxColumn = model!.getLineMaxColumn(lineNumber);
const startColumn = Math.min(Math.max(1, error.column), maxColumn);
return {
severity: monaco!.MarkerSeverity.Error,
message: error.message,
source: 'EZPP theme script',
startLineNumber: lineNumber,
startColumn,
endLineNumber: lineNumber,
endColumn: Math.min(maxColumn, startColumn + 1),
};
});
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
errorCount = markers.length;
};
const updateStats = (source: string) => {
lineCount = Math.max(1, source.split('\n').length);
};
$effect(() => {
const next = value;
if (!model || !monaco || next === currentValue) return;
currentValue = next;
model.setValue(next);
updateStats(next);
syncDiagnostics(next);
});
$effect(() => {
if (!expanded) return;
requestAnimationFrame(() => editor?.layout());
});
onMount(() => {
let disposed = false;
let disposeIntellisense: (() => void) | undefined;
void (async () => {
try {
const { default: EditorWorker } =
await import('monaco-editor/editor/editor.worker.js?worker');
if (disposed) return;
type WorkerHost = typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
};
(globalThis as WorkerHost).MonacoEnvironment = {
getWorker: () => new EditorWorker(),
};
const api = await import('monaco-editor');
if (disposed) return;
monaco = api;
ensureLanguage(api);
if (!host) return;
currentValue = value;
updateStats(currentValue);
model = api.editor.createModel(currentValue, LANGUAGE_ID);
editor = api.editor.create(host, {
model,
theme: THEME_ID,
ariaLabel: 'Advanced theme rule script',
automaticLayout: true,
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
fontSize: 12,
lineHeight: 19,
fontLigatures: true,
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: true,
folding: true,
minimap: { enabled: false },
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
insertSpaces: true,
scrollBeyondLastLine: false,
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'line',
fixedOverflowWidgets: true,
quickSuggestions: { other: true, comments: false, strings: false },
suggestOnTriggerCharacters: true,
suggest: { showWords: false, preview: true },
wordBasedSuggestions: 'off',
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
});
editor.onDidChangeModelContent(() => {
const next = model?.getValue() ?? '';
currentValue = next;
updateStats(next);
syncDiagnostics(next);
onchange?.(next);
});
editor.onDidChangeCursorPosition((event) => {
cursorLine = event.position.lineNumber;
cursorColumn = event.position.column;
});
disposeIntellisense = registerIntellisense(api);
syncDiagnostics(currentValue);
updateStats(currentValue);
ready = true;
editor.focus();
} catch (error) {
console.error('Could not load the Monaco script editor', error);
loadError = error instanceof Error ? error.message : String(error);
}
})();
return () => {
disposed = true;
disposeIntellisense?.();
editor?.dispose();
model?.dispose();
editor = undefined;
model = undefined;
monaco = undefined;
};
});
function toggleExpanded() {
expanded = !expanded;
requestAnimationFrame(() => {
editor?.layout();
editor?.focus();
});
}
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape' && expanded) {
event.preventDefault();
expanded = false;
}
}}
/>
{#if expanded}
<button
type="button"
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close expanded script editor"
onclick={() => (expanded = false)}
></button>
{/if}
<div
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
: 'relative rounded-md'}"
style={expanded ? '' : `height:${height}px`}
>
<div
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
>
<Braces class="size-3 shrink-0 text-primary" />
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
Advanced script
</span>
<span class="ml-auto flex items-center gap-1.5">
{#if loadError}
<span class="text-[8px] text-destructive">Editor unavailable</span>
{:else if errorCount > 0}
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
<CircleAlert class="size-2.5" />
{errorCount}
</span>
{:else}
<span class="text-[8px] text-emerald-400">Ready</span>
{/if}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
onclick={toggleExpanded}
>
{#if expanded}
<Minimize2 class="size-3" />
{:else}
<Maximize2 class="size-3" />
{/if}
</button>
{#if expanded}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Close editor"
aria-label="Close expanded editor"
onclick={() => (expanded = false)}
>
<X class="size-3" />
</button>
{/if}
</span>
</div>
{#if loadError}
<textarea
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
spellcheck={false}
{value}
placeholder={'score += 1;\nif (score >= 3) {\n showLayer("secret");\n}'}
oninput={(event) => {
const next = (event.currentTarget as HTMLTextAreaElement).value;
onchange?.(next);
}}
></textarea>
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
Monaco could not load: {loadError}
</p>
{:else}
<div class="relative min-h-0 flex-1">
<div bind:this={host} class="absolute inset-0"></div>
{#if !ready}
<div
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
>
Loading editor…
</div>
{/if}
</div>
<div
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
>
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
<span>Ln {cursorLine}, Col {cursorColumn}</span>
<span class="ml-auto">
{#if errorCount > 0}
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
{:else}
No errors
{/if}
</span>
</div>
{/if}
</div>
@@ -0,0 +1,72 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import type { Component, Snippet } from 'svelte';
let {
title,
open = $bindable(true),
icon: Icon,
badge,
description,
actions,
children,
class: className = '',
contentClass = 'space-y-3 pt-2.5',
headingClass = 'text-xs font-semibold text-theme-100',
bordered = true,
}: {
title: string;
open?: boolean;
icon?: Component<{ class?: string }>;
badge?: string | number;
description?: string;
actions?: Snippet;
children?: Snippet;
class?: string;
contentClass?: string;
headingClass?: string;
bordered?: boolean;
} = $props();
</script>
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
<div class="flex items-center gap-1.5">
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
aria-expanded={open}
onclick={() => (open = !open)}
>
{#if Icon}
<Icon class="size-3.5 shrink-0 text-primary" />
{/if}
<span class="truncate {headingClass}">{title}</span>
{#if badge !== undefined && badge !== ''}
<span
class="shrink-0 rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground"
>
{badge}
</span>
{/if}
<ChevronDown
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
? 'rotate-180'
: ''}"
/>
</button>
{#if actions}
<div class="flex shrink-0 items-center gap-1">
{@render actions()}
</div>
{/if}
</div>
{#if open}
<div class={contentClass}>
{#if description}
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
{/if}
{@render children?.()}
</div>
{/if}
</section>
+236
View File
@@ -0,0 +1,236 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import { scopeThemeCss } from '$lib/themeCssScope';
import { fontStack, monoFontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
import {
readableTextColor,
themeFontFaceCss,
type ThemeV2Definition,
type ThemeV2Group,
type ThemeV2ScriptValue,
} from '$lib/themeV2';
let {
definition,
assets = '',
assetOverrides = {},
selectedIds = [],
selectedGroupId,
interactive = false,
scale = 1,
variables = {},
onselect,
onselectmany,
onselectgroup,
onmove,
onpatch,
onpatchgroup,
oncontext,
oncontextgroup,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
selectedIds?: string[];
selectedGroupId?: string;
interactive?: boolean;
scale?: number;
variables?: Record<string, ThemeV2ScriptValue>;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
onselectgroup?: (id: string) => void;
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
oncontext?: (id: string | undefined, event: MouseEvent) => void;
oncontextgroup?: (id: string, event: MouseEvent) => void;
} = $props();
const bodyFont = $derived(definition.typography?.body);
const headingFont = $derived(definition.typography?.heading);
const monoFont = $derived(definition.typography?.mono);
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
const accentText = $derived(readableTextColor(accent));
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
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}`,
`--font-mono:${monoFontStack(monoFont)}`,
].join(';')
);
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
const customCss = $derived(scopeThemeCss(definition.css, '.theme-stage'));
const elements = $derived(definition.elements ?? []);
</script>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${fontFaces}\n${customCss}</style>`}
<div
class="theme-stage dark relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
style={stageStyle}
role="presentation"
>
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
<div class="relative z-10 grid h-full grid-cols-[0.085fr_1fr]">
<aside
class="flex flex-col items-center gap-2 border-r border-theme-900 bg-black/40 p-3 backdrop-blur-sm"
>
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
</div>
<span
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
>
3.2.7
</span>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
>
<ThemeIcon name="home" set={iconSet} class="size-5" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
>
<ThemeIcon name="themes" set={iconSet} class="size-5" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
>
<ThemeIcon name="settings" set={iconSet} class="size-5" />
</div>
<div
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
>
<ThemeIcon name="user" set={iconSet} 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">
<ThemeIcon name="music" set={iconSet} 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">
<ThemeIcon name="brush" set={iconSet} 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">
<ThemeIcon name="wifi" set={iconSet} 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"
>
<ThemeIcon
name="launch"
set={iconSet}
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}
groups={definition.groups}
{assets}
{assetOverrides}
themeName={definition.name}
themeAuthor={definition.author}
themeVersion={definition.version}
accentColor={definition.accentColor}
{iconSet}
{interactive}
{selectedIds}
{selectedGroupId}
{scale}
{variables}
{onselect}
{onselectmany}
{onselectgroup}
{onmove}
{onpatch}
{onpatchgroup}
{oncontext}
{oncontextgroup}
/>
</div>
@@ -0,0 +1,364 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
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 type {
ThemeV2TextDecoration,
ThemeV2TextStyle,
ThemeV2TextTransform,
} from '$lib/themeV2';
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
let {
element,
onchange,
}: {
element: ThemeV2TextStyle;
onchange: (patch: Partial<ThemeV2TextStyle>) => 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';
const colorField = 'h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5';
const bold = $derived((element.fontWeight ?? 600) >= 700);
const italic = $derived(element.fontStyle === 'italic');
const underline = $derived((element.textDecoration ?? 'none').includes('underline'));
const strike = $derived((element.textDecoration ?? 'none').includes('line-through'));
const gradient = $derived(element.fillMode === 'gradient');
const transformOptions: { value: ThemeV2TextTransform; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'uppercase', label: 'UPPERCASE' },
{ value: 'lowercase', label: 'lowercase' },
{ value: 'capitalize', label: 'Capitalize' },
];
const inputValue = (event: Event) => (event.currentTarget as HTMLInputElement).value;
function toggleDecoration(nextUnderline: boolean, nextStrike: boolean) {
const parts: string[] = [];
if (nextUnderline) parts.push('underline');
if (nextStrike) parts.push('line-through');
onchange({ textDecoration: (parts.join(' ') || 'none') as ThemeV2TextDecoration });
}
function setFillMode(value: ThemeV2TextStyle['fillMode']) {
const fillMode = value === 'gradient' ? 'gradient' : 'solid';
onchange({
fillMode,
...(fillMode === 'gradient'
? {
gradientFrom: element.gradientFrom ?? '#ff006e',
gradientTo: element.gradientTo ?? '#7c3aed',
}
: {}),
});
}
function resetTextStyle() {
onchange({
fontWeight: 600,
fontStyle: 'normal',
textDecoration: 'none',
textTransform: 'none',
fillMode: 'solid',
gradientFrom: '#ff006e',
gradientTo: '#7c3aed',
gradientAngle: 90,
outlineWidth: 0,
outlineColor: '#000000',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
shadowColor: '#000000',
});
}
</script>
{#snippet textStyleActions()}
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
title="Reset bold, italic, decoration, case, fill, outline and shadow"
onclick={resetTextStyle}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="Text style"
open={false}
actions={textStyleActions}
contentClass="space-y-2 pt-2.5"
>
<div class="grid grid-cols-4 gap-1">
<Button
variant={bold ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Bold"
aria-label="Bold"
aria-pressed={bold}
onclick={() => onchange({ fontWeight: bold ? 400 : 700 })}
>
<Bold class="size-3.5" />
</Button>
<Button
variant={italic ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Italic"
aria-label="Italic"
aria-pressed={italic}
onclick={() => onchange({ fontStyle: italic ? 'normal' : 'italic' })}
>
<Italic class="size-3.5" />
</Button>
<Button
variant={underline ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Underline"
aria-label="Underline"
aria-pressed={underline}
onclick={() => toggleDecoration(!underline, strike)}
>
<Underline class="size-3.5" />
</Button>
<Button
variant={strike ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Strikethrough"
aria-label="Strikethrough"
aria-pressed={strike}
onclick={() => toggleDecoration(underline, !strike)}
>
<Strikethrough class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-text-transform" class={label}>Case</Label>
<Select.Root
type="single"
value={element.textTransform ?? 'none'}
onValueChange={(value) => {
if (value) onchange({ textTransform: value as ThemeV2TextTransform });
}}
>
<Select.Trigger
id="element-text-transform"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{transformOptions.find((option) => option.value === (element.textTransform ?? 'none'))
?.label ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each transformOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-fill-mode" class={label}>Fill</Label>
<Select.Root
type="single"
value={element.fillMode ?? 'solid'}
onValueChange={(value) => setFillMode(value as ThemeV2TextStyle['fillMode'])}
>
<Select.Trigger
id="element-fill-mode"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.fillMode === 'gradient' ? 'Gradient' : 'Solid'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="solid" label="Solid" />
<Select.Item value="gradient" label="Gradient" />
</Select.Content>
</Select.Root>
</div>
</div>
{#if gradient}
<div class="space-y-2">
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Gradient from</Label>
<span class="font-mono text-[9px] text-theme-100"
>{element.gradientFrom ?? '#ff006e'}</span
>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.gradientFrom ?? '#ff006e'}
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.gradientFrom ?? '#ff006e'}
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Gradient to</Label>
<span class="font-mono text-[9px] text-theme-100">{element.gradientTo ?? '#7c3aed'}</span>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.gradientTo ?? '#7c3aed'}
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.gradientTo ?? '#7c3aed'}
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<Label for="element-gradient-angle" class={label}>Gradient angle</Label>
<Input
id="element-gradient-angle"
class={field}
type="number"
min="0"
max="360"
step="1"
value={element.gradientAngle ?? 90}
onchange={(event) =>
onchange({
gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)),
})}
/>
</div>
</div>
{:else}
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Color</Label>
<span class="font-mono text-[9px] text-theme-100">{element.color ?? '#ffffff'}</span>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.color ?? '#ffffff'}
oninput={(event) => onchange({ color: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.color ?? '#ffffff'}
oninput={(event) => onchange({ color: inputValue(event) })}
/>
</div>
</div>
{/if}
<div class="space-y-1">
<Label class={label}>Outline</Label>
<div class="grid grid-cols-[72px_40px_1fr] gap-2">
<Input
class={field}
type="number"
min="0"
max="20"
step="1"
aria-label="Outline width"
title="Outline width in pixels; 0 disables it"
value={element.outlineWidth ?? 0}
onchange={(event) =>
onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })}
/>
<Input
class={colorField}
type="color"
aria-label="Outline color"
value={element.outlineColor ?? '#000000'}
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
/>
<Input
class="{field} min-w-0 font-mono"
aria-label="Outline color hex"
value={element.outlineColor ?? '#000000'}
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<Label class={label}>Shadow</Label>
<div class="grid grid-cols-3 gap-2">
<Input
class={field}
type="number"
min="0"
max="40"
step="1"
aria-label="Shadow blur"
title="Shadow blur"
value={element.shadowBlur ?? 0}
onchange={(event) =>
onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })}
/>
<Input
class={field}
type="number"
min="-40"
max="40"
step="1"
aria-label="Shadow offset X"
title="Shadow offset X"
value={element.shadowOffsetX ?? 0}
onchange={(event) =>
onchange({
shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
})}
/>
<Input
class={field}
type="number"
min="-40"
max="40"
step="1"
aria-label="Shadow offset Y"
title="Shadow offset Y"
value={element.shadowOffsetY ?? 0}
onchange={(event) =>
onchange({
shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
})}
/>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
aria-label="Shadow color"
value={element.shadowColor ?? '#000000'}
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
aria-label="Shadow color hex"
value={element.shadowColor ?? '#000000'}
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
/>
</div>
</div>
</ThemeSection>
@@ -0,0 +1,260 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import type { ThemeV2Transform } from '$lib/themeV2';
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
type TransformTarget = ThemeV2Transform & { offsetX?: number; offsetY?: number };
type TransformPatch = Partial<TransformTarget>;
let {
transform,
idPrefix,
includeTranslate = false,
onchange,
}: {
transform: TransformTarget;
idPrefix: string;
includeTranslate?: boolean;
onchange: (patch: TransformPatch) => 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';
const scaleX = $derived(transform.scaleX ?? transform.scale ?? 1);
const scaleY = $derived(transform.scaleY ?? transform.scale ?? 1);
const isDefault = $derived(
(transform.rotation ?? 0) === 0 &&
scaleX === 1 &&
scaleY === 1 &&
(transform.skewX ?? 0) === 0 &&
(transform.skewY ?? 0) === 0 &&
!transform.flipX &&
!transform.flipY &&
(!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0))
);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function numberValue(value: string): number | undefined {
if (value.trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function setScale(axis: 'scaleX' | 'scaleY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const scale = clamp(parsed / 100, 0.05, 10);
onchange(axis === 'scaleX' ? { scaleX: scale } : { scaleY: scale });
}
function setSkew(axis: 'skewX' | 'skewY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const skew = clamp(parsed, -89, 89);
onchange(axis === 'skewX' ? { skewX: skew } : { skewY: skew });
}
function setRotation(value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
onchange({ rotation: clamp(parsed, -360, 360) });
}
function rotateBy(delta: number) {
const next = (transform.rotation ?? 0) + delta;
onchange({ rotation: ((((next + 180) % 360) + 360) % 360) - 180 });
}
function setOffset(axis: 'offsetX' | 'offsetY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const offset = clamp(parsed, -2000, 2000);
onchange(axis === 'offsetX' ? { offsetX: offset } : { offsetY: offset });
}
function resetTransform() {
onchange({
rotation: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
flipX: false,
flipY: false,
...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}),
});
}
</script>
{#snippet transformActions()}
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
title={includeTranslate
? 'Reset translation, rotation, scale, skew and flips'
: 'Reset rotation, scale, skew and flips'}
disabled={isDefault}
onclick={resetTransform}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="Transform"
open={false}
actions={transformActions}
contentClass="space-y-2 pt-2.5"
>
{#if includeTranslate}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="{idPrefix}-translate-x" class={label}>Translate X</Label>
<Input
id="{idPrefix}-translate-x"
class={field}
type="number"
min="-2000"
max="2000"
value={transform.offsetX ?? 0}
onchange={(event) => setOffset('offsetX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-translate-y" class={label}>Translate Y</Label>
<Input
id="{idPrefix}-translate-y"
class={field}
type="number"
min="-2000"
max="2000"
value={transform.offsetY ?? 0}
onchange={(event) => setOffset('offsetY', event.currentTarget.value)}
/>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="{idPrefix}-scale-x" class={label}>Scale X %</Label>
<Input
id="{idPrefix}-scale-x"
class={field}
type="number"
min="5"
max="1000"
step="5"
value={Math.round(scaleX * 100)}
onchange={(event) => setScale('scaleX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-scale-y" class={label}>Scale Y %</Label>
<Input
id="{idPrefix}-scale-y"
class={field}
type="number"
min="5"
max="1000"
step="5"
value={Math.round(scaleY * 100)}
onchange={(event) => setScale('scaleY', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-skew-x" class={label}>Skew X °</Label>
<Input
id="{idPrefix}-skew-x"
class={field}
type="number"
min="-89"
max="89"
step="1"
value={transform.skewX ?? 0}
onchange={(event) => setSkew('skewX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-skew-y" class={label}>Skew Y °</Label>
<Input
id="{idPrefix}-skew-y"
class={field}
type="number"
min="-89"
max="89"
step="1"
value={transform.skewY ?? 0}
onchange={(event) => setSkew('skewY', event.currentTarget.value)}
/>
</div>
</div>
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
<Label for="{idPrefix}-rotation" class={label}>Rotation °</Label>
<Input
id="{idPrefix}-rotation"
class={field}
type="number"
min="-360"
max="360"
step="1"
value={transform.rotation ?? 0}
onchange={(event) => setRotation(event.currentTarget.value)}
/>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant={transform.flipX ? 'default' : 'secondary'}
size="icon"
class="size-7"
title="Flip horizontally"
aria-label="Flip horizontally"
aria-pressed={transform.flipX === true}
onclick={() => onchange({ flipX: !transform.flipX })}
>
<FlipHorizontal2 class="size-3.5" />
</Button>
<Button
variant={transform.flipY ? 'default' : 'secondary'}
size="icon"
class="size-7"
title="Flip vertically"
aria-label="Flip vertically"
aria-pressed={transform.flipY === true}
onclick={() => onchange({ flipY: !transform.flipY })}
>
<FlipVertical2 class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-7"
title="Rotate left 90°"
aria-label="Rotate left 90 degrees"
onclick={() => rotateBy(-90)}
>
<RotateCcw class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-7"
title="Rotate right 90°"
aria-label="Rotate right 90 degrees"
onclick={() => rotateBy(90)}
>
<RotateCw class="size-3.5" />
</Button>
</div>
</div>
</ThemeSection>
@@ -0,0 +1,252 @@
<script lang="ts">
import { custom_theme_volume } from '$lib/global';
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
import type { ThemeV2Definition } from '$lib/themeV2';
import { themeAssetUrl } from '$lib/themeV2';
import { reduceAnimations } from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils';
let {
definition,
assets = '',
assetOverrides = {},
preview = false,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
preview?: boolean;
} = $props();
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
const assetUrl = (path: string | undefined) => {
if (!path) return '';
return assetOverrides[path] ?? themeAssetUrl(assets, path);
};
const background = $derived(definition.background);
const overlay = $derived(definition.overlay);
const effects = $derived(definition.effects);
const random = (seed: number) => {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
};
const particles = $derived.by(() => {
if ($reduceAnimations) return [];
if (!effects || effects.type === 'none') return [];
const count = Math.min(120, Math.max(0, Math.round(effects.density ?? 30)));
const speed = Math.max(0.2, effects.speed ?? 1);
return Array.from({ length: count }, (_, index) => {
const value = (offset: number) => random(index + 1 + offset);
const isStar = effects.type === 'stars';
const size = isStar ? 1 + value(1) * 2.5 : 4 + value(1) * 7;
const duration = (isStar ? 2 + value(2) * 3 : 9 + value(2) * 11) / speed;
return {
id: index,
left: value(3) * 100,
top: isStar ? value(4) * 100 : value(4) * 15,
size,
duration,
delay: value(5) * duration,
drift: -90 + value(6) * 180,
opacity: 0.25 + value(7) * 0.65,
rotate: value(8) * 360,
};
});
});
function trackMedia(node: HTMLElement) {
if (preview) return {};
return trackMediaContainer(node);
}
// `muted` is not reliably reflected after load, so keep the live property in
// sync when the theme toggles it in the editor.
function applyMuted(node: HTMLVideoElement, muted: boolean) {
node.muted = muted;
return {
update(value: boolean) {
node.muted = value;
},
};
}
$effect(() => {
if (!audioElement || !definition.audio) return;
audioElement.volume = $custom_theme_volume;
if (!preview) {
audioElement.play().catch(() => {
// Autoplay can be rejected until the window has been interacted with.
});
}
});
</script>
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
{#if background.type === 'color'}
<div
class="absolute inset-0"
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'gradient'}
<div
class="absolute inset-0"
style="background-image:linear-gradient({background.angle ??
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'image'}
{#if background.src}
<img
class="absolute inset-0 h-full w-full"
src={assetUrl(background.src)}
alt=""
aria-hidden="true"
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
/>
{/if}
{:else if background.type === 'video'}
{#if background.src}
<video
class="absolute inset-0 h-full w-full object-cover"
use:themeMediaSource={assetUrl(background.src)}
use:themeVideoLifecycle
autoplay
loop
muted={background.muted !== false}
use:applyMuted={background.muted !== false}
playsinline
disablepictureinpicture
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
></video>
{/if}
{/if}
{#if overlay && (overlay.opacity ?? 0) > 0}
<div
class="absolute inset-0 pointer-events-none"
style="background-color:{overlay.color ?? '#000000'};opacity:{overlay.opacity ?? 0}"
></div>
{/if}
{#if overlay && (overlay.vignette ?? 0) > 0}
<div
class="vignette absolute inset-0 pointer-events-none"
style="opacity:{overlay.vignette ?? 0}"
></div>
{/if}
{#if effects && effects.type !== 'none'}
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
{#each particles as particle (particle.id)}
<span
class="particle {effects.type}"
style="left:{particle.left}%;top:{particle.top}%;width:{particle.size}px;height:{particle.size}px;opacity:{particle.opacity};--particle-color:{effects.color ??
'#ffffff'};--particle-drift:{particle.drift}px;--particle-duration:{particle.duration}s;--particle-delay:-{particle.delay}s;--particle-rotate:{particle.rotate}deg;animation-duration:{particle.duration}s;animation-delay:-{particle.delay}s;"
></span>
{/each}
</div>
{/if}
{#if definition.audio && !preview}
<audio
bind:this={audioElement}
use:themeMediaSource={assetUrl(definition.audio.src)}
loop={definition.audio.loop !== false}
autoplay
></audio>
{/if}
</div>
<style lang="scss">
.vignette {
background: radial-gradient(ellipse at center, transparent 38%, rgba(0, 0, 0, 0.92) 100%);
}
.particle {
position: absolute;
display: block;
background: var(--particle-color);
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform, opacity;
&.snow {
border-radius: 9999px;
filter: blur(0.5px);
animation-name: v2-snow;
}
&.petals {
border-radius: 70% 0 70% 0;
animation-name: v2-petals;
}
&.bubbles {
border-radius: 9999px;
border: 1px solid var(--particle-color);
background: color-mix(in srgb, var(--particle-color) 16%, transparent);
animation-name: v2-bubbles;
}
&.stars {
border-radius: 9999px;
box-shadow: 0 0 6px var(--particle-color);
animation-name: v2-stars;
animation-timing-function: ease-in-out;
}
}
@keyframes v2-snow {
from {
transform: translate3d(0, -12vh, 0);
}
to {
transform: translate3d(var(--particle-drift), 112vh, 0);
}
}
@keyframes v2-petals {
from {
transform: translate3d(0, -12vh, 0) rotate(var(--particle-rotate));
}
50% {
transform: translate3d(calc(var(--particle-drift) * 0.55), 50vh, 0)
rotate(calc(var(--particle-rotate) + 180deg));
}
to {
transform: translate3d(var(--particle-drift), 112vh, 0)
rotate(calc(var(--particle-rotate) + 360deg));
}
}
@keyframes v2-bubbles {
from {
transform: translate3d(0, 110vh, 0);
}
to {
transform: translate3d(var(--particle-drift), -12vh, 0);
}
}
@keyframes v2-stars {
0%,
100% {
opacity: 0.15;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,94 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import type { ThemeV2ScriptValue } from '$lib/themeV2';
import { RotateCcw } from '@lucide/svelte';
let {
variables,
defaults,
onchange,
}: {
variables: string[];
defaults: Record<string, ThemeV2ScriptValue>;
onchange: (name: string, value: ThemeV2ScriptValue | undefined) => void;
} = $props();
const hasDefault = (name: string) => Object.prototype.hasOwnProperty.call(defaults, name);
const valueLabel = (value: ThemeV2ScriptValue) =>
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text';
const displayValue = (name: string) => {
if (!hasDefault(name)) return '';
const value = defaults[name];
return typeof value === 'string' ? value : String(value);
};
const parseValue = (raw: string): ThemeV2ScriptValue | undefined => {
if (!raw.trim()) return undefined;
if (raw.trim() === 'true') return true;
if (raw.trim() === 'false') return false;
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(raw.trim())) {
const number = Number(raw.trim());
if (Number.isFinite(number)) return number;
}
return raw;
};
</script>
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
<p class="text-[10px] leading-relaxed text-muted-foreground">
Starting values for
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
placeholders and logic variables. Leave a field empty to start at
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">0</code>.
</p>
{#if variables.length === 0}
<p
class="rounded-md border border-dashed border-theme-800 p-2.5 text-[10px] leading-relaxed text-muted-foreground"
>
Add a
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{placeholder}'}</code>
to a text layer or use a variable in a rule to see it here.
</p>
{:else}
<div class="space-y-1.5">
{#each variables as name (name)}
<div
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1.5"
>
<div class="min-w-0 flex-1">
<div class="truncate font-mono text-[10px] text-theme-100" title={`{${name}}`}>
{name}
</div>
<div class="text-[8px] leading-tight text-muted-foreground">
{hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'}
</div>
</div>
<Input
class="h-7 w-24 shrink-0 border-theme-800 bg-theme-950 px-2 font-mono text-[10px] text-theme-50"
value={displayValue(name)}
placeholder="0"
spellcheck="false"
aria-label={`Default value for ${name}`}
onchange={(event) => onchange(name, parseValue(event.currentTarget.value))}
/>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-theme-100"
title={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
aria-label={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
disabled={!hasDefault(name)}
onclick={() => onchange(name, undefined)}
>
<RotateCcw class="size-3" />
</Button>
</div>
{/each}
</div>
{/if}
</ThemeSection>
@@ -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>
{#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}>
{@render children?.()}
</a>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
className
)}
{...restProps}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>
@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more