Compare commits

..

100 Commits

Author SHA1 Message Date
HorizonCode eaffc35288 fix: build for linux 2026-06-03 01:01:17 +02:00
HorizonCode 78c7fdb756 chore: bump version 2026-06-03 01:01:03 +02:00
HorizonCode ec7ce53016 chore: bump version 2026-06-03 00:51:28 +02:00
HorizonCode 0610d4beef chore: make patch settings available to linux users, force stable patcher stream 2026-06-03 00:49:35 +02:00
HorizonCode 416067f2ea Merge branch 'master' of https://git.ez-pp.farm/EZPPFarm/EZPPLauncher 2026-06-02 23:46:02 +02:00
HorizonCode e58be09bdb chore: add patcher support for linux 2026-06-02 23:45:43 +02:00
HorizonCode ccb0c467f2 chore: add type usage for views in selectedView 2026-06-01 10:47:58 +02:00
HorizonCode 67bc968497 feat: add disableMediaControls function to prevent media key actions 2026-05-30 00:06:16 +02:00
HorizonCode dc06b53089 fix: if external imported theme is current active theme change to default for installation 2026-05-29 21:36:37 +02:00
HorizonCode d5e5fd4c43 fix: csp font-src rule, remove debug log 2026-05-28 22:52:46 +02:00
HorizonCode 29359c9242 chore: bump version 2026-05-28 22:34:34 +02:00
HorizonCode 71bd64a2cf fix: ahead value read 2026-05-28 22:21:18 +02:00
HorizonCode 7fa83d6d0a chore: add theme css support 2026-05-28 22:02:38 +02:00
HorizonCode f2e795e2c9 chore: remove old theme variables 2026-05-28 22:02:07 +02:00
HorizonCode cece28dd46 fix: only validate psu path if its not empty 2026-05-28 21:53:19 +02:00
HorizonCode 474f1f0c33 Merge branch 'master' of https://git.ez-pp.farm/EZPPFarm/EZPPLauncher 2026-05-28 11:41:26 +02:00
HorizonCode 871c40922e chore: replace json config with database 2026-05-28 11:35:42 +02:00
HorizonCode 20e8389409 chore: replace json config with database 2026-05-28 11:35:34 +02:00
HorizonCode 051a86943e fix: update Content Security Policy to include analytics and osu.ppy.sh 2026-05-28 00:39:45 +02:00
HorizonCode 7d0147edb9 fix: Content Security Policy rules 2026-05-28 00:09:23 +02:00
HorizonCode 7e2404753b fix: set csp to null, breaks assets in prod 2026-05-27 23:31:32 +02:00
HorizonCode 9952e164c5 chore: add new feature to readme 2026-05-27 23:15:59 +02:00
HorizonCode df8ee34e73 chore: move import of Stdio to head 2026-05-27 19:55:56 +02:00
HorizonCode 3a27646da8 chore: add validation for OpenTabletDriver path and improve process handling 2026-05-27 19:31:53 +02:00
HorizonCode ace3320632 chore: implement otd commands, check if otd daemon is already running 2026-05-27 18:15:50 +02:00
HorizonCode 2ba094f07e chore: remove theme delete logging, add notifications 2026-05-27 15:02:26 +02:00
HorizonCode fcf9e46a8f chore: remove theme delete logging, add notifications 2026-05-27 15:02:06 +02:00
HorizonCode 3768ab1064 chore: move download and extracting themes to tauri backend 2026-05-27 14:59:43 +02:00
HorizonCode e5e09505a0 chore: remove old unused nyancat bg 2026-05-27 11:45:27 +02:00
HorizonCode 94f78f281d chore: move theme extraction to tauri backend 2026-05-27 11:44:16 +02:00
HorizonCode d2ecc5210f refactor: replace @ alias with 2026-05-27 10:41:57 +02:00
HorizonCode 6f8036e9d4 refactor: fix tw class names 2026-05-27 10:39:00 +02:00
HorizonCode 73256f32c4 fix: correct className usage in badge component 2026-05-26 16:18:29 +02:00
HorizonCode cfb4c8a611 chore: lint 2026-05-26 14:46:29 +02:00
HorizonCode f2d8056cfd chore: update sileo type props 2026-05-26 13:31:07 +02:00
HorizonCode 2eb95cd802 chore: update dependencies 2026-05-26 13:09:30 +02:00
HorizonCode a28d56e283 fix: correct className usage in button component for proper styling 2026-05-26 12:59:26 +02:00
HorizonCode f88a33c2aa fix: if theme is a store theme and installed externally, update status in importFromFile 2026-05-26 11:39:36 +02:00
HorizonCode 8a964d0a6a chore: add import sort plugin, sort imports 2026-05-26 11:23:37 +02:00
HorizonCode cc4697efbc refactor: simplify stopOpenTabletDriver, cleanup code 2026-05-26 10:33:14 +02:00
HorizonCode 5b1e32d4eb chore: bump version and enhance security settings in tauri configuration 2026-05-25 21:54:45 +02:00
HorizonCode d0b0011910 chore: automatically start/stop otd on osu launch/close 2026-05-25 21:53:42 +02:00
HorizonCode 1467d3e296 fix: adjust layout and positioning of user stats and login prompts in Launch screen 2026-05-24 16:41:01 +02:00
HorizonCode 2ded0d4b94 feat: add login and registration prompts to the launch screen 2026-05-24 16:33:48 +02:00
HorizonCode 9d80811e83 fix: improve error handling and logging in various components 2026-05-24 16:17:16 +02:00
HorizonCode 730a27b397 chore: remove doctest 2026-05-22 13:23:04 +02:00
HorizonCode e84feb292b Merge branch 'master' of https://git.ez-pp.farm/EZPPFarm/EZPPLauncher 2026-05-22 08:18:54 +02:00
HorizonCode 485cb3acf7 fix: clear opened URLs after retrieving them 2026-05-22 08:17:02 +02:00
HorizonCode 44ad025d3d fix: font importing 2026-05-21 23:12:41 +02:00
HorizonCode ec80b13de3 chore: remove unneeded tailwind config file 2026-05-21 23:12:03 +02:00
HorizonCode 6a279de728 chore: update deps, switch from lucide-svelte to @lucide/svelte 2026-05-21 23:02:45 +02:00
HorizonCode 4e33ec3451 fix: resolve vite dynamic import warning 2026-05-21 23:02:14 +02:00
HorizonCode 6fdb2376a1 chore: switch to async rpc framework, fix buttons 2026-05-21 21:24:17 +02:00
HorizonCode afdd74175a fix: cursor smooth movement on higher refreshrate monitors 2026-05-21 20:30:00 +02:00
HorizonCode 6f8043d2d0 fix: cursor rotating on higher refreshrate monitors 2026-05-21 20:23:04 +02:00
HorizonCode f7fff7748a chore: format 2026-05-21 15:31:13 +02:00
HorizonCode d5286263b9 chore: add nsis hook on uninstall to delete user .ezpplauncher dir 2026-05-21 15:30:54 +02:00
HorizonCode 1cc1a4a0c3 chore: remove theme debug script 2026-05-21 14:54:33 +02:00
HorizonCode d2ae9e939f chore: add dynamic button for beatmap display 2026-05-21 14:48:10 +02:00
HorizonCode bde922ab9f fix: file theme import by association file 2026-05-21 14:26:16 +02:00
HorizonCode a1f96a7de3 fix: build 2026-05-21 14:25:13 +02:00
HorizonCode 3b2c902264 fix: build 2026-05-21 14:25:08 +02:00
HorizonCode 40a32d7a60 chore: add theme import for file association 2026-05-21 13:27:31 +02:00
HorizonCode 52c60acf37 chore: add drag and drop theme import 2026-05-21 13:23:03 +02:00
HorizonCode 2788cd6e8b chore: format 2026-05-21 11:44:56 +02:00
HorizonCode 2d1941db33 chore: add drag-and-drop box to Launch page 2026-05-21 11:43:59 +02:00
HorizonCode 00958d5238 chore: only handle .ezpplauncher-theme files 2026-05-21 10:51:51 +02:00
HorizonCode b8a0b448c7 fix: type def for beatmapSetCount 2026-05-21 10:42:13 +02:00
HorizonCode 09487e945e fix: cleanup type defs 2026-05-21 10:41:22 +02:00
HorizonCode 2532ab0059 fix: add ezpplauncher-theme file association to tauri.config.json 2026-05-21 10:27:44 +02:00
HorizonCode 16fe91b3a5 chore: rework presence, add dynamic button functions to presence 2026-05-21 09:21:15 +02:00
HorizonCode eca08f16c5 fix: wait spawning patcher until osu is spawned 2026-05-21 08:18:50 +02:00
HorizonCode e76d67f3a8 fix: add missing imports 2026-05-20 16:32:26 +02:00
HorizonCode 9811b36e58 wip: theme file import from fs 2026-05-20 16:09:05 +02:00
HorizonCode 0f10beb3b4 chore: bump version 2026-05-08 18:06:58 +02:00
HorizonCode 4326199a56 chore: add theme hash checking and alerts 2026-05-08 18:03:26 +02:00
HorizonCode 7f65384b1c chore: add code signing to vscode config 2026-05-08 18:02:32 +02:00
HorizonCode aacd0d1d91 chore: update readme 2026-05-08 18:02:16 +02:00
HorizonCode 079a076f99 chore: update launcher preview 2026-05-08 18:01:55 +02:00
HorizonCode 6d88c39ce2 chore: pause media when launcher is hidden 2026-05-03 17:50:06 +02:00
HorizonCode 56608a64fc chore: use folder name as identification 2026-05-03 15:24:31 +02:00
HorizonCode eaf6f5976d fix: always use underscore 2026-05-03 00:17:50 +02:00
HorizonCode 93f1f380ec fix: regex es6 issue 2026-05-03 00:16:26 +02:00
HorizonCode c47ea1f976 fix: toSafeName windows forbidden symbols 2026-05-03 00:09:01 +02:00
HorizonCode 09af78f1aa fix: use actual setted volume for fade 2026-05-02 22:21:28 +02:00
HorizonCode 3900b5c6cf chore: add volume slider
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 22:15:39 +02:00
HorizonCode adf607e24c chore: clean up comments 2026-05-02 19:56:37 +02:00
HorizonCode 1128b51778 fix: multiple downloads 2026-05-02 19:55:49 +02:00
HorizonCode d435f0c09a chore: add theme uninstall and update 2026-05-02 02:19:00 +02:00
HorizonCode b188e2dee8 chore: remove bundled themes 2026-05-02 00:39:47 +02:00
HorizonCode 8fd56ae749 chore: add custom themes
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 00:38:56 +02:00
HorizonCode b2e7d22d28 chore: add lost umbrella video 2026-04-29 23:33:51 +02:00
HorizonCode d871e06172 chore: add execution clap video 2026-04-29 23:33:15 +02:00
HorizonCode 6a1c5441c3 chore: remove konami code, add themes 2026-04-29 23:32:47 +02:00
HorizonCode ef36f49a6f fix: improve presence reconnection logic and error handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:04:06 +02:00
HorizonCode 7e40536ec8 chore: add installerIcon to NSIS configuration 2026-04-26 11:29:31 +02:00
HorizonCode 694d403aeb chore: bump version 2026-04-26 11:19:53 +02:00
HorizonCode 9515892b2f fix: remove console log 2026-04-26 11:19:42 +02:00
HorizonCode 0a58f0f63b chore: update profile settings 2026-04-26 11:19:27 +02:00
HorizonCode 60e3ef4131 chore: add umami typing 2026-04-21 09:25:59 +02:00
100 changed files with 5161 additions and 1981 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"plugins": ["prettier-plugin-svelte"],
"plugins": ["prettier-plugin-svelte", "@trivago/prettier-plugin-sort-imports"],
"overrides": [
{
"files": "*.svelte",
+3
View File
@@ -1,6 +1,9 @@
{
"svelte.enable-ts-plugin": true,
"git.enableCommitSigning": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
+8
View File
@@ -14,6 +14,7 @@ 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] Automatic ingame login
- [x] Patches the osu! client to show misses in Relax and Autopilot
- [x] Displays your EZPPFarm stats in the launcher
@@ -22,6 +23,7 @@ It enhances the osu! experience with quality-of-life features and integration sp
- [x] Displays ping to the EZPPFarm server
- [x] Discord Rich Presence
- [x] Performance Display Overlay (currently in experimental patcher release stream)
- [x] Launcher Theming
---
@@ -73,3 +75,9 @@ src-tauri/target/release/ezpplauncher.exe
├── package.json # Bun project config
└── README.md
```
---
## 🎨 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 157 KiB

+297 -306
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src\\app.css",
"baseColor": "gray"
@@ -12,5 +12,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/default"
"registry": "https://shadcn-svelte.com/registry"
}
+2 -2
View File
@@ -1,11 +1,11 @@
import prettier from 'eslint-config-prettier';
import svelteConfig from './svelte.config.js';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
+45 -44
View File
@@ -1,13 +1,13 @@
{
"name": "ezpplauncher",
"version": "3.1.5",
"version": "3.2.3",
"description": "",
"type": "module",
"scripts": {
"debug": "bun run tauri dev",
"build": "bun ./scripts/sync-version.ts && bun run vite:build && bun run tauri:build",
"build": "bun ./scripts/build.ts",
"vite:dev": "vite dev",
"vite:build": "vite build",
"vite:build": "vite build && exit 0",
"tauri:build": "tauri build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -18,64 +18,65 @@
},
"license": "GPL-3.0-only",
"dependencies": {
"@better-fetch/fetch": "^1.1.18",
"@fontsource/sora": "^5.2.6",
"@fontsource/space-mono": "^5.2.8",
"@better-fetch/fetch": "^1.1.21",
"@fontsource-variable/sora": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@number-flow/svelte": "^0.4.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.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/umami": "^2.10.1",
"animejs": "^4.1.3",
"@types/node": "^25.9.1",
"animejs": "^4.4.1",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"lucide-svelte": "^1.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"jszip": "^3.10.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-to-webcomponent": "^2.0.1",
"semver": "^7.7.2",
"sileo": "^0.1.3",
"semver": "^7.8.1",
"sileo": "^0.1.5",
"svelte-confetti": "^2.3.2",
"tw-animate-css": "^1.3.8"
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^9.35.0",
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iarna/toml": "2.2.5",
"@lucide/svelte": "^1.8.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.57.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.10.0",
"@types/bun": "^1.2.21",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2.11.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/bun": "^1.3.14",
"@types/crypto-js": "^4.2.2",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"autoprefixer": "10.4.21",
"bits-ui": "^1.4.7",
"autoprefixer": "10.5.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"eslint": "^9.35.0",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.2",
"globals": "^17.0.0",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"sass-embedded": "^1.92.1",
"svelte": "^5.55.3",
"svelte-check": "^4.3.1",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.0",
"tailwindcss": "^4.1.7",
"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.2",
"typescript-eslint": "^8.42.0",
"vite": "^8.0.8",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"vite": "^8.0.14",
"vite-plugin-devtools-json": "^1.0.0"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { spawn } from 'child_process';
function runVite(): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn('bunx', ['--bun', 'vite', 'build'], { stdio: ['inherit', 'pipe', 'pipe'] });
proc.stdout?.on('data', (data: Buffer) => {
process.stdout.write(data);
if (data.toString().includes('✔ done')) {
setTimeout(() => {
proc.kill();
resolve();
}, 500);
}
});
proc.stderr?.on('data', (data: Buffer) => process.stderr.write(data));
proc.on('close', (code) => {
if (code === 0 || code === null) resolve();
else reject(new Error(`vite build exited with code ${code}`));
});
});
}
function run(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { stdio: 'inherit' });
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
});
}
await run('bun', ['./scripts/sync-version.ts']);
await runVite();
//set NO_STRIP=1 in env for linux to prevent stripping of debug symbols in tauri builds, which causes issues with source maps
if (process.platform === 'linux') process.env.NO_STRIP = '1';
await run('bunx', ['tauri', 'build']);
+1 -1
View File
@@ -1,6 +1,6 @@
import * as toml from '@iarna/toml';
import fs from 'fs';
import path from 'path';
import * as toml from '@iarna/toml';
import { cwd } from 'process';
const packageJsonPath = path.join(cwd(), 'package.json');
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": ["@types/bun", "@types/node"]
},
"include": ["*.ts"]
}
+1273 -211
View File
File diff suppressed because it is too large Load Diff
+32 -14
View File
@@ -1,12 +1,22 @@
[package]
name = "ezpplauncher"
version = "3.1.5"
version = "3.2.3"
description = "EZPPLauncher redefined."
authors = ["HorizonCode"]
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.dev]
incremental = true # Compile your binary in smaller steps.
[profile.release]
codegen-units = 1 # Allows LLVM to perform better optimization.
lto = true # Enables link-time-optimizations.
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
panic = "abort" # Higher performance by disabling panic handlers.
strip = true # Ensures debug symbols are removed.
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
@@ -15,36 +25,44 @@ name = "ezpplauncher_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
tauri = { version = "2.10.3", features = [] }
tauri = { version = "2.11.2", features = ["protocol-asset"] }
tauri-plugin-shell = "2.3.5"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_json = "1.0.150"
serde_repr = "0.1.20"
tauri-plugin-dialog = "2.7.0"
tauri-plugin-fs = "2.5.0"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
hardware-id = "0.3.0"
tauri-plugin-cors-fetch = "5.0.0"
sysinfo = "0.38.4"
reqwest = { version = "0.13.2", features = ["json", "stream", "query"] }
sysinfo = "0.39.2"
reqwest = { version = "0.13.4", features = ["json", "stream", "query"] }
md5 = "0.8.0"
tokio = { version = "1.52.1", features = ["full"] }
open = "5.3.4"
discord-rich-presence = "1.1.0"
tokio = { version = "1.52.3", features = ["full"] }
open = "5.3.5"
presenceforge = { version = "0.2.1", features = ["tokio-runtime"] }
once_cell = "1.21.4"
futures-util = "0.3.32"
byteorder = "1.5.0"
anyhow = "1.0.102"
url = "2.5.8"
zip = "8.6.0"
sha1 = "0.11.0"
hex = "0.4.3"
rusqlite = { version = "0.40.0", features = ["bundled"] }
aes-gcm = "0.10.3"
base64 = "0.22.1"
sha2 = "0.11.0"
thiserror = "2.0.18"
[target.'cfg(windows)'.dependencies]
winreg = "0.56.0"
winapi = { version = "0.3", features = ["winuser", "wincrypt", "memoryapi", "winbase", "dpapi"] }
base64 = "0.22.1"
winapi = { version = "0.3.9", features = ["winuser", "wincrypt", "memoryapi", "winbase", "dpapi"] }
widestring = "1.2.1"
windows-sys = "0.61.2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.4.1"
tauri-plugin-single-instance = "2.4.2"
+94 -22
View File
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"shell:allow-open",
@@ -19,57 +21,127 @@
{
"identifier": "fs:allow-read",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-write",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-exists",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-read-file",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-write-file",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-mkdir",
"allow": [
{ "path": "$HOME/.ezpplauncher" },
{ "path": "$HOME/.ezpplauncher/*" },
{ "path": "$HOME/.ezpplauncher/**/*" }
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-read-dir",
"allow": [
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
},
{
"identifier": "fs:allow-remove",
"allow": [
{
"path": "$HOME/.ezpplauncher"
},
{
"path": "$HOME/.ezpplauncher/*"
},
{
"path": "$HOME/.ezpplauncher/**/*"
}
]
}
]
+15
View File
@@ -4,3 +4,18 @@
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
${EndIf}
!macroend
!macro NSIS_HOOK_PREUNINSTALL
${If} $DeleteAppDataCheckboxState = 1
MessageBox MB_YESNO "Do you want to remove all EZPPLauncher data and themes?" IDYES do_delete IDNO skip_delete
do_delete:
StrCpy $R9 1
skip_delete:
${EndIf}
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
${If} $R9 = 1
RMDir /r "$PROFILE\.ezpplauncher"
${EndIf}
!macroend
+440 -14
View File
@@ -1,12 +1,18 @@
use crate::config::ConfigError;
use hardware_id::get_id;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
use std::process::Stdio;
use sysinfo::System;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::Manager;
use tauri::State;
use tauri_plugin_fs::FsExt;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
@@ -14,12 +20,21 @@ use tokio::time::{Duration, sleep};
use crate::osudb::parse_osudb;
use crate::presence;
use crate::state::AppState;
use crate::utils::{
check_folder_completeness, encrypt_password, get_osu_config, get_osu_user_config,
get_window_title_by_pid, is_net8_installed, is_osuwinello_available, is_wmctrl_available,
set_osu_config_vals, set_osu_user_config_vals,
};
#[tauri::command]
pub fn opened_urls(app: tauri::AppHandle) -> Vec<PathBuf> {
let args = app.state::<AppState>().opened_files.lock().unwrap().clone();
app.state::<AppState>().opened_files.lock().unwrap().clear();
args
}
#[tauri::command]
pub fn get_launcher_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
@@ -64,15 +79,10 @@ pub fn valid_osu_folder(folder: String) -> bool {
check_folder_completeness(path, &osu_folder_files) >= 70.0
}
#[cfg(not(windows))]
#[tauri::command]
pub fn find_osu_installation() -> Option<String> {
None
}
#[cfg(windows)]
#[tauri::command]
pub fn find_osu_installation() -> Option<String> {
{
use winreg::RegKey;
use winreg::enums::*;
@@ -119,7 +129,8 @@ pub fn find_osu_installation() -> Option<String> {
let stripped = trimmed.strip_suffix(",0").unwrap_or(trimmed);
let path = PathBuf::from(stripped.trim());
if let Some(parent) = path.parent() {
let match_percentage = check_folder_completeness(parent, &osu_folder_files);
let match_percentage =
check_folder_completeness(parent, &osu_folder_files);
if match_percentage >= 70.0 {
return Some(parent.to_string_lossy().into());
@@ -141,7 +152,8 @@ pub fn find_osu_installation() -> Option<String> {
let stripped = trimmed.strip_suffix(",1").unwrap_or(trimmed);
let path = PathBuf::from(stripped.trim());
if let Some(parent) = path.parent() {
let match_percentage = check_folder_completeness(parent, &osu_folder_files);
let match_percentage =
check_folder_completeness(parent, &osu_folder_files);
if match_percentage >= 70.0 {
return Some(parent.to_string_lossy().into());
@@ -151,6 +163,7 @@ pub fn find_osu_installation() -> Option<String> {
}
}
}
}
None
}
@@ -163,7 +176,12 @@ pub async fn get_beatmapsets_count(folder: String) -> Option<u64> {
}
let osu_db_bytes = fs::read(osu_db_path).await.ok()?;
let osu_db = parse_osudb(osu_db_bytes).ok()?;
let beatmap_sets = osu_db.beatmaps.iter().map(|b| b.beatmapset_id).collect::<HashSet<_>>().len();
let beatmap_sets = osu_db
.beatmaps
.iter()
.map(|b| b.beatmapset_id)
.collect::<HashSet<_>>()
.len();
Some(beatmap_sets as u64)
}
@@ -242,7 +260,7 @@ pub async fn get_skins_count(folder: String) -> Option<u64> {
let skins_folder = path.join("Skins");
if !skins_folder.exists() {
return None;
return Some(0);
}
let mut count = 0;
@@ -432,14 +450,37 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
};
if patch {
sleep(Duration::from_secs(3)).await;
let patcher_exe_path = PathBuf::from(&folder)
.join("EZPPLauncher")
.join("patcher")
.join("osu!.patcher.exe");
if patcher_exe_path.exists() {
let mut sys = System::new_all();
loop {
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let mut found = false;
for (_pid, process) in sys.processes() {
if process.name() == "osu!.exe" {
let pid = process.pid();
let title = get_window_title_by_pid(pid);
if !title.is_empty() && !title.contains("updater") {
found = true;
break;
}
}
}
if found {
break;
}
sleep(Duration::from_millis(500)).await;
}
#[cfg(windows)]
{
let _ = Command::new(&patcher_exe_path)
@@ -447,6 +488,14 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
.spawn()
.map_err(|e| format!("Failed to run patcher: {e}"))?;
}
#[cfg(not(windows))]
{
let _ = Command::new("osu-wine")
.arg("--wine")
.arg(&patcher_exe_path)
.spawn()
.map_err(|e| format!("Failed to run patcher: {}", e))?;
}
}
}
@@ -829,6 +878,21 @@ pub async fn presence_update_user(user: PresenceUser) {
presence::update_user(user.username.as_deref(), user.id.as_deref());
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PresenceButton {
text: Option<String>,
url: Option<String>,
}
#[tauri::command]
pub async fn presence_update_button(button: PresenceButton) {
match (button.text.as_deref(), button.url.as_deref()) {
(Some(text), Some(url)) => presence::set_button(text, url),
_ => presence::clear_button(),
}
}
#[tauri::command]
pub async fn presence_is_connected() -> bool {
presence::has_presence().await
@@ -897,9 +961,10 @@ pub async fn download_ezpp_launcher_update(app: AppHandle, url: String) -> Resul
Ok(())
}
#[cfg(windows)]
#[tauri::command]
pub async fn install_ezpp_launcher_update(app: AppHandle) -> Result<(), String> {
#[cfg(windows)]
{
let temp_dir = app.path().temp_dir().expect("Failed to get temp directory");
let file_path = temp_dir.join("ezpplauncher_update.exe");
if !file_path.exists() {
@@ -920,12 +985,373 @@ pub async fn install_ezpp_launcher_update(app: AppHandle) -> Result<(), String>
sleep(Duration::from_millis(250)).await;
app.exit(0x0100);
}
Ok(())
}
#[tauri::command]
pub async fn run_open_tablet_driver(path: String) -> Result<(), String> {
let otd_path = PathBuf::from(&path);
if !otd_path.exists() {
return Err(format!("OpenTabletDriver not found at: {}", path));
}
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new(&otd_path)
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start OpenTabletDriver: {}", e))?;
}
Ok(())
}
#[tauri::command]
pub async fn stop_open_tablet_driver() -> Result<(), String> {
#[cfg(windows)]
{
let mut sys = System::new_all();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let process = sys
.processes()
.values()
.find(|p| p.name().eq_ignore_ascii_case("OpenTabletDriver.Daemon.exe"))
.ok_or("OpenTabletDriver is not running")?;
process.kill();
}
Ok(())
}
#[tauri::command]
pub fn is_open_tablet_driver_running() -> bool {
#[cfg(windows)]
{
let mut sys = System::new_all();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
sys.processes()
.values()
.any(|p| p.name().eq_ignore_ascii_case("OpenTabletDriver.Daemon.exe"))
}
#[cfg(not(windows))]
{
true
}
}
#[tauri::command]
pub async fn install_ezpp_launcher_update(_app: AppHandle) -> Result<(), String> {
pub async fn is_open_tablet_driver_path_valid(app: AppHandle, path: String) -> bool {
#[cfg(windows)]
{
app.fs_scope().allow_file(&path).ok();
let otd_path = PathBuf::from(&path);
otd_path.exists()
}
#[cfg(not(windows))]
{
true
}
}
#[derive(Serialize, Clone)]
pub struct ExtractProgress {
pub theme_name: String,
pub total: usize,
pub extracted: usize,
pub progress: f64,
pub current_file: String,
}
#[derive(Serialize, Clone)]
pub struct DownloadProgress {
pub theme_name: String,
pub received: u64,
pub total: u64,
pub progress: f64,
}
#[tauri::command]
pub async fn download_and_extract_theme(
app: AppHandle,
download_url: String,
expected_sha: String,
theme_folder: String,
theme_name: String,
) -> Result<(), String> {
let client = reqwest::Client::new();
let response = client
.get(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
)
.send()
.await
.map_err(|e| format!("Failed to download theme: {}", e))?;
let total = response.content_length().unwrap_or(0);
let mut received: u64 = 0;
let mut chunks: Vec<u8> = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await {
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
received += chunk.len() as u64;
chunks.extend_from_slice(&chunk);
let _ = app.emit(
"download_progress",
DownloadProgress {
theme_name: theme_name.clone(),
received,
total,
progress: if total > 0 {
received as f64 / total as f64
} else {
0.0
},
},
);
}
let sha = tokio::task::spawn_blocking({
let chunks = chunks.clone();
move || {
use sha1::{Digest, Sha1};
let header = format!("blob {}\0", chunks.len());
let mut hasher = Sha1::new();
hasher.update(header.as_bytes());
hasher.update(&chunks);
hex::encode(hasher.finalize())
}
})
.await
.map_err(|e| format!("Thread error: {}", e))?;
if sha != expected_sha {
return Err("Failed to verify downloaded theme file hash.".to_string());
}
// Parse zip on blocking thread
let entries: Vec<(String, Vec<u8>, bool)> = tokio::task::spawn_blocking(move || {
let cursor = std::io::Cursor::new(chunks);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|e| format!("Failed to read zip archive: {}", e))?;
let mut entries = Vec::new();
for i in 0..archive.len() {
let mut zip_entry = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let name = match zip_entry.enclosed_name() {
Some(p) => p.to_string_lossy().to_string(),
None => continue,
};
let is_dir = zip_entry.is_dir();
let mut buf = Vec::new();
if !is_dir {
std::io::copy(&mut zip_entry, &mut buf)
.map_err(|e| format!("Failed to read zip entry data: {}", e))?;
}
entries.push((name, buf, is_dir));
}
Ok::<_, String>(entries)
})
.await
.map_err(|e| format!("Thread error: {}", e))??;
let total_files = entries.len();
let mut extracted = 0;
for (name, buf, is_dir) in entries {
let entry_path = std::path::Path::new(&theme_folder).join(&name);
if is_dir {
tokio::fs::create_dir_all(&entry_path)
.await
.map_err(|e| format!("Failed to create directory: {}", e))?;
} else {
if let Some(parent) = entry_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
}
tokio::fs::write(&entry_path, &buf)
.await
.map_err(|e| format!("Failed to write file: {}", e))?;
}
extracted += 1;
let _ = app.emit(
"extract_progress",
ExtractProgress {
theme_name: theme_name.clone(),
total: total_files,
extracted,
progress: extracted as f64 / total_files as f64,
current_file: name,
},
);
}
Ok(())
}
#[tauri::command]
pub async fn extract_theme(
app: AppHandle,
file_path: String,
theme_folder: String,
theme_name: String,
) -> Result<(), String> {
app.fs_scope().allow_file(&file_path).ok();
let entries: Vec<(String, Vec<u8>, bool)> = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&file_path)
.map_err(|e| format!("Failed to open theme file: {}", e))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
let mut entries = Vec::new();
for i in 0..archive.len() {
let mut zip_entry = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let name = match zip_entry.enclosed_name() {
Some(p) => p.to_string_lossy().to_string(),
None => continue,
};
let is_dir = zip_entry.is_dir();
let mut buf = Vec::new();
if !is_dir {
zip_entry
.read_to_end(&mut buf)
.map_err(|e| format!("Failed to read zip entry data: {}", e))?;
}
entries.push((name, buf, is_dir));
}
Ok::<_, String>(entries)
})
.await
.map_err(|e| format!("Thread error: {}", e))??;
let total = entries.len();
let mut extracted = 0;
for (name, buf, is_dir) in entries {
let entry_path = std::path::Path::new(&theme_folder).join(&name);
if is_dir {
tokio::fs::create_dir_all(&entry_path)
.await
.map_err(|e| format!("Failed to create directory: {}", e))?;
} else {
if let Some(parent) = entry_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
}
tokio::fs::write(&entry_path, &buf)
.await
.map_err(|e| format!("Failed to write file: {}", e))?;
}
extracted += 1;
let _ = app.emit(
"extract_progress",
ExtractProgress {
theme_name: theme_name.clone(),
total,
extracted,
progress: extracted as f64 / total as f64,
current_file: name,
},
);
}
Ok(())
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ThemeInfo {
pub name: String,
pub entry: String,
pub preview: String,
}
#[tauri::command]
pub async fn read_theme_info(app: AppHandle, file_path: String) -> Result<ThemeInfo, String> {
app.fs_scope().allow_file(&file_path).ok();
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&file_path)
.map_err(|e| format!("Failed to open theme file: {}", e))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
let mut theme_config = archive
.by_name("theme.json")
.map_err(|_| "Theme config file not found".to_string())?;
let mut contents = String::new();
std::io::Read::read_to_string(&mut theme_config, &mut contents)
.map_err(|e| format!("Failed to read theme config: {}", e))?;
let theme_info: ThemeInfo = serde_json::from_str(&contents)
.map_err(|e| format!("Failed to parse theme config: {}", e))?;
Ok(theme_info)
})
.await
.map_err(|e| format!("Thread error: {}", e))?
}
#[tauri::command]
pub fn config_get(key: String, state: State<AppState>) -> Result<Option<Value>, ConfigError> {
state.config.lock().unwrap().get(&key)
}
#[tauri::command]
pub fn config_set(
key: String,
value: Value,
encrypt: bool,
state: State<AppState>,
) -> Result<(), ConfigError> {
state.config.lock().unwrap().set(&key, value, encrypt)
}
#[tauri::command]
pub fn config_exists(key: String, state: State<AppState>) -> Result<bool, ConfigError> {
state.config.lock().unwrap().exists(&key)
}
#[tauri::command]
pub fn config_delete(key: String, state: State<AppState>) -> Result<(), ConfigError> {
state.config.lock().unwrap().delete(&key)
}
#[tauri::command]
pub fn config_clear(state: State<AppState>) -> Result<(), ConfigError> {
state.config.lock().unwrap().clear()
}
#[tauri::command]
pub fn config_all(state: State<AppState>) -> Result<Vec<(String, Value)>, ConfigError> {
state.config.lock().unwrap().all()
}
+219
View File
@@ -0,0 +1,219 @@
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, KeyInit, OsRng},
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use rusqlite::{Connection, OptionalExtension, params};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("Encryption error: {0}")]
Encryption(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Base64 error: {0}")]
Base64(#[from] base64::DecodeError),
#[error("Config not initialized")]
NotInitialized,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
impl serde::Serialize for ConfigError {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
pub struct Config {
db_path: PathBuf,
conn: Option<Connection>,
hwid_key: Option<[u8; 32]>,
}
impl Config {
pub fn new(db_path: impl Into<PathBuf>) -> Self {
Self {
db_path: db_path.into(),
conn: None,
hwid_key: None,
}
}
pub fn init(&mut self, hwid: &str) -> Result<bool, ConfigError> {
let mut hasher = Sha256::new();
hasher.update(hwid.as_bytes());
let key_bytes: [u8; 32] = hasher.finalize().into();
self.hwid_key = Some(key_bytes);
let is_new = !self.db_path.exists();
if let Some(parent) = self.db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&self.db_path)?;
conn.execute_batch(
"
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
encrypted INTEGER NOT NULL DEFAULT 0
);
",
)?;
self.conn = Some(conn);
Ok(is_new)
}
pub fn get(&self, key: &str) -> Result<Option<Value>, ConfigError> {
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
let mut stmt = conn.prepare("SELECT value, encrypted FROM config WHERE key = ?1")?;
let row: Option<(String, bool)> = stmt
.query_row(params![key], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?;
match row {
None => Ok(None),
Some((raw, false)) => Ok(Some(serde_json::from_str::<Value>(&raw)?)),
Some((raw, true)) => {
let plaintext = self
.decrypt_value(&raw)
.map_err(|_| ConfigError::Encryption(format!("failed to decrypt '{}'", key)))?;
serde_json::from_str::<Value>(&plaintext)
.map(Some)
.map_err(|_| {
ConfigError::Encryption(format!("decrypted '{}' is not valid JSON", key))
})
}
}
}
pub fn exists(&self, key: &str) -> Result<bool, ConfigError> {
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM config WHERE key = ?1",
params![key],
|r| r.get(0),
)?;
Ok(count > 0)
}
/// Return all key/value pairs (decrypting encrypted ones).
pub fn all(&self) -> Result<Vec<(String, Value)>, ConfigError> {
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
let mut stmt = conn.prepare("SELECT key, value, encrypted FROM config")?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, bool>(2)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (k, raw, enc) = row?;
let v: Value = if enc {
let pt = self.decrypt_value(&raw)?;
serde_json::from_str(&pt)?
} else {
serde_json::from_str(&raw)?
};
out.push((k, v));
}
Ok(out)
}
pub fn set<T: serde::Serialize>(
&self,
key: &str,
value: T,
encrypt: bool,
) -> Result<(), ConfigError> {
let json = serde_json::to_string(&value)?;
if encrypt {
let blob = self.encrypt_value(&json)?;
self.upsert(key, &blob, true)
} else {
self.upsert(key, &json, false)
}
}
pub fn delete(&self, key: &str) -> Result<(), ConfigError> {
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
conn.execute("DELETE FROM config WHERE key = ?1", params![key])?;
Ok(())
}
pub fn clear(&self) -> Result<(), ConfigError> {
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
conn.execute("DELETE FROM config", [])?;
Ok(())
}
fn upsert(&self, key: &str, raw: &str, encrypted: bool) -> Result<(), ConfigError> {
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
conn.execute(
"INSERT INTO config (key, value, encrypted)
VALUES (?1, ?2, ?3)
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
encrypted = excluded.encrypted",
params![key, raw, encrypted as i32],
)?;
Ok(())
}
fn cipher(&self) -> Result<Aes256Gcm, ConfigError> {
let key_bytes = self.hwid_key.ok_or(ConfigError::NotInitialized)?;
let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
Ok(Aes256Gcm::new(key))
}
fn encrypt_value(&self, plaintext: &str) -> Result<String, ConfigError> {
let cipher = self.cipher()?;
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| ConfigError::Encryption(e.to_string()))?;
let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(&combined))
}
fn decrypt_value(&self, blob: &str) -> Result<String, ConfigError> {
let cipher = self.cipher()?;
let combined = BASE64.decode(blob)?;
if combined.len() < 12 {
return Err(ConfigError::Encryption("blob too short".into()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| ConfigError::Encryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| ConfigError::Encryption(e.to_string()))
}
}
+98 -20
View File
@@ -1,19 +1,32 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use hardware_id::get_id;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_fs::FsExt;
pub mod commands;
pub mod presence;
pub mod utils;
pub mod config;
pub mod osudb;
use crate::commands::{
check_for_corruption, download_ezpp_launcher_update, download_ezpp_launcher_update_files,
encrypt_string, exit, find_osu_installation, get_beatmapsets_count,
get_ezpp_launcher_update_files, get_hwid, 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_osu_running, open_url_in_browser,
presence_connect, presence_disconnect, presence_is_connected, presence_update_status,
presence_update_user, replace_ui_files, run_osu, run_osu_updater, set_osu_config_values,
set_osu_user_config_values, valid_osu_folder,
pub mod presence;
pub mod state;
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,
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,
},
state::AppState,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -26,14 +39,70 @@ pub fn run() {
let mut builder = tauri::Builder::default();
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
let app_window = app.get_webview_window("main").expect("no main window");
app_window.set_focus().expect("failed to focus");
if args.len() > 1 {
use tauri::Emitter;
let mut files = Vec::new();
for maybe_file in args.iter().skip(1) {
if maybe_file.starts_with("-") {
continue;
}
let path = PathBuf::from(maybe_file);
if path.extension().and_then(|e| e.to_str()) == Some("ezpplauncher-theme") {
app.fs_scope().allow_file(&path).ok();
files.push(path);
}
}
app.emit("opened", files.clone()).ok();
}
}));
}
let app = builder
.setup(|app| {
// ── Config ────────────────────────────────────────────────────────
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".into());
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
let hwid = get_id().unwrap_or_else(|_| "recorderinsandybridge".into());
let mut cfg = config::Config::new(db_path);
cfg.init(&hwid)?;
// ── Opened files ──────────────────────────────────────────────────
let mut opened_files = Vec::new();
let args = std::env::args().collect::<Vec<_>>();
if args.len() > 1 {
for maybe_file in args.iter().skip(1) {
if maybe_file.starts_with("-") {
continue;
}
let path = PathBuf::from(maybe_file);
if path.extension().and_then(|e| e.to_str()) == Some("ezpplauncher-theme") {
app.fs_scope().allow_file(&path).ok();
opened_files.push(path);
}
}
}
app.manage(AppState {
config: Mutex::new(cfg),
opened_files: Mutex::new(opened_files),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
opened_urls,
get_hwid,
find_osu_installation,
valid_osu_folder,
@@ -60,13 +129,27 @@ pub fn run() {
presence_disconnect,
presence_update_status,
presence_update_user,
presence_update_button,
presence_is_connected,
has_osuwinello,
has_wmctrl,
has_net8,
encrypt_string,
download_ezpp_launcher_update,
install_ezpp_launcher_update
install_ezpp_launcher_update,
read_theme_info,
extract_theme,
download_and_extract_theme,
config_get,
config_set,
config_exists,
config_delete,
config_clear,
config_all,
run_open_tablet_driver,
stop_open_tablet_driver,
is_open_tablet_driver_running,
is_open_tablet_driver_path_valid
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
@@ -75,12 +158,7 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|_app_handle, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
tauri::async_runtime::block_on(presence::disconnect());
std::process::exit(0);
}
app.run(|app, event| {
presence::handle_run_event(app, &event);
});
}
+208 -111
View File
@@ -1,15 +1,17 @@
use discord_rich_presence::{
activity::{Activity, Assets, Button, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use once_cell::sync::Lazy;
use std::future::Future;
use std::pin::Pin;
use presenceforge::{ActivityBuilder, AsyncDiscordIpcClient};
use std::sync::Mutex as StdMutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, RunEvent};
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Duration, interval};
#[derive(Clone, Debug)]
pub struct PresenceButton {
pub label: String,
pub url: String,
}
#[derive(Clone, Debug)]
pub struct PresenceData {
pub state: String,
@@ -18,6 +20,21 @@ pub struct PresenceData {
pub large_image_text: String,
pub small_image_key: Option<String>,
pub small_image_text: Option<String>,
pub dynamic_button: Option<PresenceButton>,
}
impl Default for PresenceData {
fn default() -> Self {
Self {
state: "Idle in Launcher...".to_string(),
details: " ".to_string(),
large_image_key: "ezppfarm".to_string(),
large_image_text: "EZPPFarm".to_string(),
small_image_key: None,
small_image_text: None,
dynamic_button: None,
}
}
}
#[derive(Debug)]
@@ -26,14 +43,21 @@ enum PresenceCommand {
Disconnect(oneshot::Sender<()>),
UpdateData(PresenceData),
IsConnected(oneshot::Sender<bool>),
Tick,
}
enum ReconnectResult {
Connected(AsyncDiscordIpcClient),
Failed,
}
struct PresenceActor {
receiver: mpsc::Receiver<PresenceCommand>,
client: Option<DiscordIpcClient>,
reconnect_rx: mpsc::Receiver<ReconnectResult>,
reconnect_tx: mpsc::Sender<ReconnectResult>,
client: Option<AsyncDiscordIpcClient>,
data: PresenceData,
start_timestamp: i64,
reconnecting: bool,
}
impl PresenceActor {
@@ -43,153 +67,184 @@ impl PresenceActor {
.unwrap()
.as_secs() as i64;
let data = PresenceData {
state: "Idle in Launcher...".to_string(),
details: " ".to_string(),
large_image_key: "ezppfarm".to_string(),
large_image_text: "EZPPFarm".to_string(),
small_image_key: None,
small_image_text: None,
};
let (reconnect_tx, reconnect_rx) = mpsc::channel(1);
PresenceActor {
Self {
receiver,
reconnect_rx,
reconnect_tx,
client: None,
data,
data: PresenceData::default(),
start_timestamp: start,
reconnecting: false,
}
}
async fn run(&mut self) {
let mut update_interval = interval(Duration::from_secs(5));
let mut heartbeat = interval(Duration::from_secs(5));
heartbeat.tick().await;
loop {
tokio::select! {
Some(cmd) = self.receiver.recv() => {
match cmd {
PresenceCommand::Connect(responder) => self.handle_connect(responder).await,
PresenceCommand::Disconnect(responder) => {
self.handle_disconnect(responder).await;
},
PresenceCommand::UpdateData(new_data) => {
self.data = new_data;
},
PresenceCommand::IsConnected(responder) => {
let _ = responder.send(self.client.is_some());
PresenceCommand::Connect(resp) => self.cmd_connect(resp).await,
PresenceCommand::Disconnect(resp) => self.cmd_disconnect(resp).await,
PresenceCommand::UpdateData(data) => {
self.data = data;
self.push_presence().await;
}
PresenceCommand::Tick => {
if self.client.is_some() {
self.handle_update().await;
PresenceCommand::IsConnected(resp) => {
let _ = resp.send(self.client.is_some());
}
}
}
Some(result) = self.reconnect_rx.recv() => {
self.reconnecting = false;
match result {
ReconnectResult::Connected(client) => {
println!("Presence: reconnect succeeded.");
self.client = Some(client);
self.push_presence().await;
}
_ = update_interval.tick() => {
let _ = PRESENCE_TX.send(PresenceCommand::Tick).await;
ReconnectResult::Failed => {
eprintln!("Presence: all reconnect attempts exhausted.");
}
}
}
_ = heartbeat.tick() => {
self.push_presence().await;
}
}
}
}
fn handle_connect(&mut self, responder: oneshot::Sender<bool>) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
async fn cmd_connect(&mut self, resp: oneshot::Sender<bool>) {
if self.client.is_some() {
let _ = responder.send(true);
let _ = resp.send(true);
return;
}
println!("Actor: Connecting to Discord...");
let mut dc_client = DiscordIpcClient::new("1032772293220384808");
match dc_client.connect() {
Ok(()) => {
self.client = Some(dc_client);
println!("Actor: Connected successfully.");
self.handle_update().await;
let _ = responder.send(true);
match try_connect_once().await {
Some(client) => {
self.client = Some(client);
self.reconnecting = false;
self.push_presence().await;
let _ = resp.send(true);
}
Err(e) => {
eprintln!("Failed to connect to Discord: {:?}. Retrying in 5s...", e);
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
let (resp_tx, _) = oneshot::channel();
let _ = tx.send(PresenceCommand::Connect(resp_tx)).await;
});
if !responder.is_closed() {
let _ = responder.send(false);
None => {
eprintln!("Presence: initial connect failed; spawning reconnect task.");
let _ = resp.send(false);
self.spawn_reconnect_task();
}
}
}
})
}
async fn handle_disconnect(&mut self, responder: oneshot::Sender<()>) {
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
if let Some(mut client) = self.client.take() {
println!("Actor: Disconnecting from Discord...");
let _ = client.clear_activity();
let _ = client.close();
println!("Actor: Disconnected successfully.");
let _ = client.clear_activity().await;
println!("Presence: disconnected.");
}
let _ = responder.send(());
let _ = resp.send(());
}
async fn handle_update(&mut self) {
if let Some(client) = self.client.as_mut() {
let mut assets = Assets::new()
async fn push_presence(&mut self) {
let client = match self.client.as_mut() {
Some(c) => c,
None => return,
};
let mut builder = ActivityBuilder::new()
.state(&self.data.state)
.details(&self.data.details)
.start_timestamp(self.start_timestamp as u64)
.large_image(&self.data.large_image_key)
.large_text(&self.data.large_image_text);
if let Some(key) = &self.data.small_image_key {
assets = assets.small_image(key);
builder = builder.small_image(key);
}
if let Some(text) = &self.data.small_image_text {
assets = assets.small_text(text);
builder = builder.small_text(text);
}
let activity = Activity::new()
.state(&self.data.state)
.details(&self.data.details)
.timestamps(Timestamps::new().start(self.start_timestamp))
.assets(assets)
.buttons(vec![
Button::new(
if let Some(btn) = &self.data.dynamic_button {
builder = builder.button(&btn.label, &btn.url);
} else {
builder = builder.button(
"Download the Launcher",
"https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest",
),
Button::new("Join EZPPFarm", "https://ez-pp.farm/discord"),
]);
if let Err(e) = client.set_activity(activity) {
eprintln!("Failed to set activity, disconnecting: {:?}", e);
self.client = None;
let (tx, _) = oneshot::channel();
self.handle_connect(tx).await;
} else {
println!("Actor: Presence updated successfully.");
);
}
builder = builder.button("Join EZPPFarm", "https://ez-pp.farm/discord");
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);
self.client = None;
self.spawn_reconnect_task();
}
}
fn spawn_reconnect_task(&mut self) {
if self.reconnecting {
return;
}
self.reconnecting = true;
let tx = self.reconnect_tx.clone();
tokio::spawn(async move {
const MAX_ATTEMPTS: u32 = 5;
for attempt in 1..=MAX_ATTEMPTS {
let delay = Duration::from_secs(5 * attempt as u64);
println!(
"Presence: reconnect attempt {}/{} in {:?}…",
attempt, MAX_ATTEMPTS, delay
);
tokio::time::sleep(delay).await;
if let Some(client) = try_connect_once().await {
let _ = tx.send(ReconnectResult::Connected(client)).await;
return;
}
}
let _ = tx.send(ReconnectResult::Failed).await;
});
}
}
async fn try_connect_once() -> Option<AsyncDiscordIpcClient> {
match AsyncDiscordIpcClient::new("1032772293220384808").await {
Ok(mut client) => match client.connect().await {
Ok(_) => {
println!("Presence: IPC connected.");
Some(client)
}
Err(e) => {
eprintln!("Presence: IPC connect error: {:?}", e);
None
}
},
Err(e) => {
eprintln!("Presence: failed to create client: {:?}", e);
None
}
}
}
static PRESENCE_TX: Lazy<mpsc::Sender<PresenceCommand>> = Lazy::new(|| {
let (tx, rx) = mpsc::channel(10);
let (tx, rx) = mpsc::channel(32);
let mut actor = PresenceActor::new(rx);
tokio::spawn(async move { actor.run().await });
tx
});
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> = Lazy::new(|| {
StdMutex::new(PresenceData {
state: "Idle in Launcher...".to_string(),
details: " ".to_string(),
large_image_key: "ezppfarm".to_string(),
large_image_text: "EZPPFarm".to_string(),
small_image_key: None,
small_image_text: None,
})
});
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> =
Lazy::new(|| StdMutex::new(PresenceData::default()));
pub async fn connect() -> bool {
let (tx, rx) = oneshot::channel();
@@ -201,8 +256,7 @@ pub async fn connect() -> bool {
pub async fn disconnect() {
let (tx, rx) = oneshot::channel();
// Use a timeout to ensure we don't hang the exit process if Discord is unresponsive
if let Ok(_) = PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await {
if PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await.is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
}
}
@@ -220,30 +274,73 @@ pub async fn has_presence() -> bool {
}
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
let mut data = PRESENCE_DATA.lock().unwrap();
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
if let Some(s) = state {
data.state = s.to_string();
guard.state = s.to_string();
}
if let Some(d) = details {
data.details = d.to_string();
guard.details = d.to_string();
}
if let Some(img) = large_image_key {
data.large_image_key = img.to_string();
guard.large_image_key = img.to_string();
}
let data_clone = data.clone();
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await;
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn update_user(username: Option<&str>, id: Option<&str>) {
let mut data = PRESENCE_DATA.lock().unwrap();
data.small_image_key = id.map(|id_str| format!("https://a.ez-pp.farm/{}", id_str));
data.small_image_text = username.map(|s| s.to_string());
let data_clone = data.clone();
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
guard.small_image_key = id.map(|s| format!("https://a.ez-pp.farm/{}", s));
guard.small_image_text = username.map(str::to_string);
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await;
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn set_button(label: &str, url: &str) {
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
guard.dynamic_button = Some(PresenceButton {
label: label.to_string(),
url: url.to_string(),
});
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn clear_button() {
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
guard.dynamic_button = None;
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn handle_run_event(_app: &AppHandle, event: &RunEvent) {
if let RunEvent::ExitRequested { .. } | RunEvent::Exit = event {
tauri::async_runtime::block_on(async {
disconnect().await;
});
}
}
+9
View File
@@ -0,0 +1,9 @@
// state.rs
use crate::config::Config;
use std::path::PathBuf;
use std::sync::Mutex;
pub struct AppState {
pub config: Mutex<Config>,
pub opened_files: Mutex<Vec<PathBuf>>,
}
+41 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EZPPLauncher",
"version": "3.1.5",
"version": "3.2.3",
"identifier": "farm.EZPPFarm.Launcher",
"mainBinaryName": "ezpplauncher",
"build": {
@@ -16,19 +16,45 @@
"title": "EZPPLauncher",
"width": 1000,
"height": 650,
"minWidth": 1000,
"minHeight": 650,
"decorations": false,
"resizable": false,
"maximizable": false
"maximizable": false,
"shadow": true
}
],
"withGlobalTauri": true,
"security": {
"csp": null
"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:",
"font-src": [
"'self'",
"asset:",
"blob:",
"data:",
"http://asset.localhost",
"https://asset.localhost"
]
},
"assetProtocol": {
"enable": true,
"scope": [
"$HOME/.ezpplauncher/themes/**"
]
}
}
},
"bundle": {
"publisher": "EZPPFarm",
"copyright": "EZPPFarm",
"shortDescription": "osu! launcher for EZPPFarm",
"longDescription": "EZPPLauncher is a custom osu! launcher for the EZPPFarm server, featuring automatic updates, ingame login, stat tracking and launcher theming.",
"homepage": "https://ez-pp.farm",
"license": "GPL-3.0",
"active": true,
@@ -36,6 +62,17 @@
"nsis",
"appimage"
],
"fileAssociations": [
{
"ext": [
"ezpplauncher-theme"
],
"name": "EZPPLauncher Theme",
"description": "EZPPLauncher Theme File",
"role": "Viewer",
"mimeType": "application/x-ezpplauncher-theme"
}
],
"windows": {
"webviewInstallMode": {
"silent": true,
@@ -44,6 +81,7 @@
"nsis": {
"installMode": "currentUser",
"compression": "lzma",
"installerIcon": "icons/icon.ico",
"installerHooks": "./nsis/nsis-hooks.nsh"
}
},
+4 -1
View File
@@ -4,6 +4,9 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "@fontsource-variable/sora";
@import "@fontsource/space-mono";
:root {
--radius: 0.625rem;
--background: oklch(95.36% 0.017 256.57);
@@ -41,7 +44,7 @@
--sidebar-ring: oklch(60.7% 0.226 299.68);
--font-sans:
"Sora", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Sora Variable", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono:
"Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ import type {
EZPPUserInfoResponse,
EZPPUserResponse,
EZPPUSerStatusResponse,
} from '@/types';
} from '$lib/types';
import { betterFetch } from '@better-fetch/fetch';
const BANCHO_ENDPOINT = 'https://c.ez-pp.farm/';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Release } from '@/types';
import type { Release } from '$lib/types';
import { betterFetch } from '@better-fetch/fetch';
import semver from 'semver';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { StreamsResult } from '@/types';
import type { StreamsResult } from '$lib/types';
import { betterFetch } from '@better-fetch/fetch';
const API_ENDPOINT = 'https://osu.ppy.sh/api/';
@@ -1,7 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from 'bits-ui';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import { cn } from '$lib/utils.js';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
+4 -4
View File
@@ -1,12 +1,12 @@
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Title from './alert-dialog-title.svelte';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger;
@@ -1,5 +1,5 @@
<script lang="ts">
import { reduceAnimations } from '@/userSettings';
import { reduceAnimations } from '$lib/userSettings';
//TODO: maybe dynamic background images fetched from ezpp?
@@ -1,92 +0,0 @@
<script lang="ts">
import NyanCat from '$assets/nyan-cat.gif';
import { reduceAnimations } from '@/userSettings';
import { fade } from 'svelte/transition';
//TODO: maybe dynamic background images fetched from ezpp?
const COUNT = 18;
function rand(min: number, max: number) {
return Math.random() * (max - min) + min;
}
const streaks = Array.from({ length: COUNT })
.map((_, i) => {
const depth = rand(0, 2);
const scale = 0.6 + depth * 0.8;
const speed = 0.4 + depth * 1.35;
const distance = 150;
const duration = distance / speed;
return {
width: 290 * scale,
height: 140 * scale,
x: (i / COUNT) * 200 - 50,
y: ((i * 137.5) % 100) - 20,
opacity: 0.25 + depth * 0.65,
duration,
delay: rand(0, duration),
scale,
};
})
.sort((a, b) => a.scale - b.scale);
</script>
<div class="absolute bg blur-[2px]">
<div
class="relative h-full w-full from-0% to-90% bg-gradient-to-b from-transparent to-black z-50"
></div>
{#each streaks as s, index (index)}
<div
class="streak drop-shadow-lg transition-opacity duration-1000"
style="
width:{s.width}px;
height:{s.height}px;
left:{s.x}%;
top:{s.y}%;
opacity:{$reduceAnimations ? 0 : s.opacity};
animation-duration:{s.duration}s;
animation-delay:-{s.delay}s;
"
>
<img
src={NyanCat}
alt="nyan cat"
class="w-full h-full"
style="opacity:{$reduceAnimations ? 0 : s.opacity};"
/>
</div>
{/each}
</div>
<style>
.bg {
position: fixed;
inset: 0;
overflow: hidden;
background: linear-gradient(
180deg,
var(--color-blue-800) 0%,
var(--color-blue-600) 40%,
var(--color-blue-900) 100%
);
}
.streak {
position: absolute;
animation: move linear infinite;
will-change: transform;
}
@keyframes move {
from {
transform: translate(-250vw, 0);
}
to {
transform: translate(250vw, 0);
}
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
+2 -2
View File
@@ -1,6 +1,6 @@
import Root from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
import Image from './avatar-image.svelte';
import Root from './avatar.svelte';
export {
Root,
+2 -2
View File
@@ -22,9 +22,9 @@
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -42,7 +42,7 @@
this={href ? 'a' : 'span'}
bind:this={ref}
{href}
class={cn(badgeVariants({ variant, className }))}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
+2 -2
View File
@@ -53,13 +53,13 @@
</script>
{#if href}
<a bind:this={ref} class={cn(buttonVariants({ variant, size, className }))} {href} {...restProps}>
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{...restProps}
>
@@ -1,8 +1,8 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
import Check from '@lucide/svelte/icons/check';
import Minus from '@lucide/svelte/icons/minus';
import { cn } from '$lib/utils.js';
import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
+1
View File
@@ -1,4 +1,5 @@
import Root from './checkbox.svelte';
export {
Root,
//
@@ -1,6 +1,6 @@
<script lang="ts">
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Play from 'lucide-svelte/icons/play';
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import Play from '@lucide/svelte/icons/play';
const prop: {
downloading: boolean;
@@ -17,7 +17,7 @@
</script>
<button
class="group flex items-center h-12 rounded-full bg-primary-300 disabled:bg-primary-300/70 shadow-lg shadow-primary/25 transition-all not-disabled:hover:shadow-primary/40 not-disabled:hover:brightness-110 not-disabled:active:scale-[0.98] cursor-pointer w-fit"
class="group flex items-center h-12 rounded-full bg-primary disabled:bg-primary/70 shadow-lg shadow-primary/25 transition-all not-disabled:hover:shadow-primary/40 not-disabled:hover:brightness-110 not-disabled:active:scale-[0.98] cursor-pointer w-fit"
disabled={prop.disabled}
onclick={prop.onClick}
>
@@ -1,8 +1,8 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
import Check from '@lucide/svelte/icons/check';
import Minus from '@lucide/svelte/icons/minus';
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import type { Snippet } from 'svelte';
let {
@@ -1,6 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from 'bits-ui';
import Circle from '@lucide/svelte/icons/circle';
import { cn } from '$lib/utils.js';
import Circle from '@lucide/svelte/icons/circle';
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { type WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { type WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
+1 -1
View File
@@ -1,4 +1,3 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
@@ -9,6 +8,7 @@ import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { Heart } from 'lucide-svelte';
import { Heart } from '@lucide/svelte';
interface Sparkle {
id: string;
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLInputAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Label as LabelPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,23 +1,32 @@
<script lang="ts">
import cursor_default from '$assets/cursor.png';
import cursor_additive from '$assets/cursor-additive.png';
import cursor_default from '$assets/cursor.png';
import { cursorSmoothness } from '$lib/userSettings';
import { animate } from 'animejs';
import { onMount } from 'svelte';
import { cursorSmoothness } from '@/userSettings';
let { smoothCursor = true }: { smoothCursor?: boolean } = $props();
const VELOCITY_WINDOW_MS = 16;
const SPEED_THRESHOLD = 9;
const REQUIRED_CONSECUTIVE = 3;
let mouseX = $state(0);
let mouseY = $state(0);
let lastMouseX = $state(0);
let lastMouseY = $state(0);
let lastMouseX = 0;
let lastMouseY = 0;
let isMouseDown = $state(false);
let isHoveringInteractive = $state(false);
let dragStartX = $state(0);
let dragStartY = $state(0);
let dragStartX = 0;
let dragStartY = 0;
let degrees = $state(0);
let applyRotation = $state(false);
let lastVelocityTime = 0;
let consecutiveHighSpeed = 0;
let velocityAccumX = 0;
let velocityAccumY = 0;
function isInteractive(el: Element | null): boolean {
while (el) {
const tag = el.tagName.toLowerCase();
@@ -30,7 +39,6 @@
) {
return true;
}
el = el.parentElement;
}
return false;
@@ -43,17 +51,32 @@
const deltaX = e.pageX - window.pageXOffset - dragStartX;
const deltaY = e.pageY - window.pageYOffset - dragStartY;
const velocityX = mouseX - lastMouseX;
const velocityY = mouseY - lastMouseY;
velocityAccumX += mouseX - lastMouseX;
velocityAccumY += mouseY - lastMouseY;
const dist = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
const now = performance.now();
const dt = now - lastVelocityTime;
if (!applyRotation && isMouseDown && dist > 80) applyRotation = true;
if (dt >= VELOCITY_WINDOW_MS) {
const speed = Math.sqrt(velocityAccumX ** 2 + velocityAccumY ** 2) / dt;
if (speed > SPEED_THRESHOLD) {
consecutiveHighSpeed++;
if (!applyRotation && isMouseDown && consecutiveHighSpeed >= REQUIRED_CONSECUTIVE) {
applyRotation = true;
}
} else {
consecutiveHighSpeed = 0;
}
velocityAccumX = 0;
velocityAccumY = 0;
lastVelocityTime = now;
}
let newDegrees = (Math.atan2(-deltaX, deltaY) * 180) / Math.PI + 24.3;
let diff = (newDegrees - degrees) % 360;
if (diff < -180) diff += 360;
if (diff > 180) diff -= 360;
@@ -67,26 +90,18 @@
isHoveringInteractive = isInteractive(el);
if (!isMouseDown) {
if (isHoveringInteractive) {
animate(cursorAdditive, {
opacity: 1,
opacity: isHoveringInteractive ? 1 : 0,
duration: 800,
ease: (t: number) => (t - 1) ** 5 + 1,
});
} else {
animate(cursorAdditive, {
opacity: 0,
duration: 800,
ease: (t: number) => (t - 1) ** 5 + 1,
});
}
}
animate(cursor, {
duration: smoothCursor ? $cursorSmoothness : 0,
translateX: mouseX,
translateY: mouseY,
ease: (t: number) => (t - 1) ** 5 + 1,
ease: (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
});
animate(cursor, {
@@ -107,6 +122,11 @@
dragStartX = event.clientX;
dragStartY = event.clientY;
velocityAccumX = 0;
velocityAccumY = 0;
consecutiveHighSpeed = 0;
lastVelocityTime = performance.now();
isMouseDown = true;
animate(cursorInner, {
scale: 0.9,
@@ -124,6 +144,10 @@
const handleMouseUp = () => {
isMouseDown = false;
applyRotation = false;
consecutiveHighSpeed = 0;
velocityAccumX = 0;
velocityAccumY = 0;
animate(cursorInner, {
scale: 1,
duration: 500,
@@ -1,6 +1,6 @@
<script lang="ts">
import { Progress as ProgressPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Progress as ProgressPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
@@ -74,19 +74,26 @@
<div class="wrapper" bind:this={wrapper}>
<!-- Scrollable content -->
<div class={`content ${className}`} bind:this={container} onscroll={updateScrollbar}>
<div
class={`content ${className}`}
bind:this={container}
onscroll={updateScrollbar}
id="scrollable-content"
>
{@render children?.()}
</div>
<!-- Custom scrollbar -->
<div class="scrollbar" style="top: {topOffset}px;">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="thumb"
class="thumb bg-theme-600/60! hover:bg-theme-700! active:bg-theme-700 transition-colors"
bind:this={thumb}
onpointerdown={startDrag}
onpointermove={drag}
onpointerup={endDrag}
onpointercancel={endDrag}
aria-controls="scrollable-content"
style="height:{thumbHeight}px; transform:translateY({thumbTop}px);"
></div>
</div>
@@ -117,8 +124,8 @@
position: absolute;
right: 2px;
bottom: 0;
width: 12px;
background: var(--color-theme-950);
width: 8px;
background: transparent;
border-radius: 999px;
}
@@ -126,7 +133,7 @@
.thumb {
position: absolute;
width: 100%;
background: var(--color-theme-800);
background: var(--color-theme-700);
border-radius: 999px;
cursor: grab;
transition: background 0.15s;
+4 -5
View File
@@ -1,12 +1,11 @@
import { Select as SelectPrimitive } from 'bits-ui';
import Content from './select-content.svelte';
import GroupHeading from './select-group-heading.svelte';
import Item from './select-item.svelte';
import Content from './select-content.svelte';
import Trigger from './select-trigger.svelte';
import Separator from './select-separator.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import Separator from './select-separator.svelte';
import Trigger from './select-trigger.svelte';
import { Select as SelectPrimitive } from 'bits-ui';
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group;
@@ -1,8 +1,8 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import { cn } from '$lib/utils.js';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import Check from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -17,7 +17,7 @@
bind:ref
{value}
class={cn(
'data-[highlighted]:bg-primary-300 data-[highlighted]:text-primary-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[highlighted]:bg-primary data-[highlighted]:text-primary-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...restProps}
@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from 'bits-ui';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
import type { Separator as SeparatorPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { cn } from '$lib/utils.js';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Separator as SeparatorPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -1,9 +1,9 @@
<script lang="ts">
import type { SileoToasterProps } from './types';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import type { SileoOptions } from 'sileo';
let props: SileoOptions = $props();
let props: SileoToasterProps = $props();
let rootEl: HTMLElement;
$effect(() => {
@@ -18,4 +18,4 @@
});
</script>
<div bind:this={rootEl}></div>
<div class="z-9999" bind:this={rootEl}></div>
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
import type { SileoOptions, SileoPosition } from 'sileo';
type SileoOffsetValue = number | string;
type SileoOffsetConfig = Partial<Record<'top' | 'right' | 'bottom' | 'left', SileoOffsetValue>>;
export interface SileoToasterProps {
children?: ReactNode;
position?: SileoPosition;
offset?: SileoOffsetValue | SileoOffsetConfig;
options?: Partial<SileoOptions>;
theme?: 'light' | 'dark' | 'system';
}
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
import { mode } from 'mode-watcher';
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
let { ...restProps }: SonnerProps = $props();
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
+107 -8
View File
@@ -1,14 +1,13 @@
<script lang="ts">
import Minimize from 'lucide-svelte/icons/minus';
import Close from 'lucide-svelte/icons/x';
import Logo from '$assets/logo.png';
import { config } from '$lib/config';
import { custom_theme_audio_playing, custom_theme_volume } from '$lib/global';
import { reduceAnimations } from '$lib/userSettings';
import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
import Minimize from '@lucide/svelte/icons/minus';
import Volume2 from '@lucide/svelte/icons/volume-2';
import Close from '@lucide/svelte/icons/x';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { onMount } from 'svelte';
import { launcherVersion } from '@/global';
import Badge from '../badge/badge.svelte';
import { reduceAnimations } from '@/userSettings';
onMount(() => {
const appWindow = getCurrentWindow();
@@ -21,6 +20,58 @@
</script>
<div data-tauri-drag-region class="titlebar z-[100]">
{#if $custom_theme_audio_playing}
{#if $custom_theme_audio_playing}
<div class="relative flex items-center justify-center group">
<div class="relative flex items-center justify-center p-1 cursor-pointer">
<div
class="absolute inset-0 bg-background rounded
scale-80 opacity-0
group-hover:opacity-100
group-hover:scale-100
{$reduceAnimations ? '' : 'transition-all duration-200 ease-out'}"
></div>
{#if $custom_theme_volume > 0.7}
<Volume2 class="z-10 pointer-events-none" size={14} />
{:else if $custom_theme_volume >= 0.15}
<Volume1 class="z-10 pointer-events-none" size={14} />
{:else if $custom_theme_volume > 0}
<Volume class="z-10 pointer-events-none" size={14} />
{:else}
<VolumeOff class="z-10 pointer-events-none" size={14} />
{/if}
</div>
<div
class="absolute top-full right-0 translate-x-[7px]
opacity-0 invisible pointer-events-none -translate-y-2
group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto group-hover:translate-y-0
{$reduceAnimations ? '' : 'transition-all duration-200 ease-out'}
z-50"
>
<div class="h-2 w-full"></div>
<div
class="flex items-center justify-center h-[120px] w-[35px]
bg-black/20 backdrop-blur-sm border border-white/10 rounded-xl"
>
<input
type="range"
min="0"
max="1"
step="0.01"
bind:value={$custom_theme_volume}
on:input={() => {
config.value<number>('volume').set($custom_theme_volume);
}}
class="volume-slider"
/>
</div>
</div>
</div>
{/if}
{/if}
<div class="relative flex items-center justify-center p-1 group" id="titlebar-minimize">
<div
class="absolute inset-0 bg-background rounded
@@ -63,4 +114,52 @@
padding-right: 5px;
pointer-events: auto;
}
.volume-slider {
appearance: none;
-webkit-appearance: none;
width: 100px; /* becomes vertical */
height: 6px;
background: transparent;
cursor: pointer;
transform: rotate(-90deg);
}
/* track */
.volume-slider::-webkit-slider-runnable-track {
height: 6px;
background: rgba(255, 255, 255, 0.25);
border-radius: 999px;
}
/* thumb */
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
border: none;
margin-top: -4px; /* center thumb */
}
/* Firefox */
.volume-slider::-moz-range-track {
height: 6px;
background: rgba(255, 255, 255, 0.25);
border-radius: 999px;
}
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
border: none;
}
</style>
+30 -73
View File
@@ -1,82 +1,39 @@
import { exists, mkdir, readTextFile, writeFile } from '@tauri-apps/plugin-fs';
import * as path from '@tauri-apps/api/path';
import { Crypto } from './crypto';
import { getHWID } from './osuUtil';
import { invoke } from '@tauri-apps/api/core';
export class Config {
private fileName: string;
private config: Record<string, unknown> = {};
private crypto: Crypto | undefined;
private configFilePath: string | undefined;
private encrypt: boolean;
constructor(fileName: string, encrypt?: boolean) {
this.fileName = fileName;
this.encrypt = encrypt ?? false;
interface ConfigValue<T> {
set(value: T, opts?: { encrypt?: boolean }): Promise<void>;
get(fallback: T): Promise<T>;
exists(): Promise<boolean>;
delete(): Promise<void>;
}
async init(): Promise<boolean> {
const hwid = (await getHWID()) ?? 'recorderinsandybridge';
this.crypto = new Crypto(hwid);
const homeDir = await path.homeDir();
const folderPath = await path.join(homeDir, '.ezpplauncher');
this.configFilePath = await path.join(folderPath, this.fileName);
const createFolder = !(await exists(folderPath));
if (createFolder) await mkdir(folderPath);
const configExists = await exists(this.configFilePath);
if (!configExists) return true;
await this.load();
return false;
}
private async load() {
if (!this.configFilePath) throw Error('configFilePath not set');
if (this.encrypt && !this.crypto) throw Error('crypto not initialized');
const fileStream = await readTextFile(this.configFilePath);
try {
const decryptedJSON = JSON.parse(
this.encrypt && this.crypto ? this.crypto.decrypt(fileStream) : fileStream
) as Record<string, unknown>;
this.config = decryptedJSON;
} catch {
this.config = {};
await this.save();
}
}
async save() {
if (!this.configFilePath) throw Error('configFilePath not set');
if (this.encrypt && !this.crypto) throw Error('crypto not initialized');
const encryptedJSON =
this.encrypt && this.crypto
? this.crypto.encrypt(JSON.stringify(this.config))
: JSON.stringify(this.config);
await writeFile(this.configFilePath, Buffer.from(encryptedJSON), {
append: false,
});
}
value(key: string) {
export const config = {
value<T = unknown>(key: string): ConfigValue<T> {
return {
set: <T>(val: T) => {
this.config[key] = val;
set(value: T, opts?: { encrypt?: boolean }): Promise<void> {
return invoke('config_set', { key, value, encrypt: opts?.encrypt ?? false });
},
exists: () => {
return this.config[key] !== undefined;
async get(fallback: T): Promise<T> {
const result = await invoke<T | null>('config_get', { key });
return result ?? fallback;
},
get: <T>(fallback: T): T => {
return (this.config[key] as T) ?? fallback;
exists(): Promise<boolean> {
return invoke<boolean>('config_exists', { key });
},
del: () => {
delete this.config[key];
delete(): Promise<void> {
return invoke('config_delete', { key });
},
};
},
all(): Promise<[string, unknown][]> {
return invoke<[string, unknown][]>('config_all');
},
clear(): Promise<void> {
return invoke('config_clear');
},
};
}
}
+94
View File
@@ -0,0 +1,94 @@
import { Crypto } from './crypto';
import { getHWID } from './osuUtil';
import * as path from '@tauri-apps/api/path';
import { exists, mkdir, readTextFile, writeFile, remove } from '@tauri-apps/plugin-fs';
export class Config {
private fileName: string;
private config: Record<string, unknown> = {};
private crypto: Crypto | undefined;
private configFilePath: string | undefined;
private encrypt: boolean;
constructor(fileName: string, encrypt?: boolean) {
this.fileName = fileName;
this.encrypt = encrypt ?? false;
}
async init(): Promise<boolean> {
const hwid = (await getHWID()) ?? 'recorderinsandybridge';
this.crypto = new Crypto(hwid);
const homeDir = await path.homeDir();
const folderPath = await path.join(homeDir, '.ezpplauncher');
this.configFilePath = await path.join(folderPath, this.fileName);
const createFolder = !(await exists(folderPath));
if (createFolder) await mkdir(folderPath);
const configExists = await exists(this.configFilePath);
if (configExists) {
await this.load();
return true;
}
return false;
}
private async load() {
if (!this.configFilePath) throw Error('configFilePath not set');
if (this.encrypt && !this.crypto) throw Error('crypto not initialized');
const fileStream = await readTextFile(this.configFilePath);
try {
const decryptedJSON = JSON.parse(
this.encrypt && this.crypto ? this.crypto.decrypt(fileStream) : fileStream
) as Record<string, unknown>;
this.config = decryptedJSON;
} catch (err) {
console.log(err);
this.config = {};
await this.save();
}
}
async delete() {
if (!this.configFilePath) throw Error('configFilePath not set');
await remove(this.configFilePath);
}
async save() {
if (!this.configFilePath) throw Error('configFilePath not set');
if (this.encrypt && !this.crypto) throw Error('crypto not initialized');
const encryptedJSON =
this.encrypt && this.crypto
? this.crypto.encrypt(JSON.stringify(this.config))
: JSON.stringify(this.config);
await writeFile(this.configFilePath, Buffer.from(encryptedJSON), {
append: false,
});
}
values() {
return this.config;
}
value(key: string) {
return {
set: <T>(val: T) => {
this.config[key] = val;
},
exists: () => {
return this.config[key] !== undefined;
},
get: <T>(fallback: T): T => {
return (this.config[key] as T) ?? fallback;
},
del: () => {
delete this.config[key];
},
};
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { writable } from 'svelte/store';
import type { EZPPUserInfo } from './types';
import { writable } from 'svelte/store';
export const currentUserInfo = writable<EZPPUserInfo | undefined>(undefined);
+12 -10
View File
@@ -1,19 +1,21 @@
export function estimateRefreshRate(): Promise<number> {
export async function estimateFrameRate(): Promise<number> {
return new Promise((resolve) => {
const last = performance.now();
let frames = 0;
const samples: number[] = [];
let last = performance.now();
function loop() {
const tick = () => {
const now = performance.now();
frames++;
samples.push(now - last);
last = now;
if (now - last >= 1000) {
resolve(frames - 2);
if (samples.length < 60) {
requestAnimationFrame(tick);
} else {
requestAnimationFrame(loop);
}
const avgDelta = samples.reduce((a, b) => a + b) / samples.length;
resolve(Math.round(1000 / avgDelta));
}
};
requestAnimationFrame(loop);
requestAnimationFrame(tick);
});
}
+60
View File
@@ -0,0 +1,60 @@
import { listen } from '@tauri-apps/api/event';
import { onDestroy } from 'svelte';
type TauriDragDropEvent = {
paths: string[];
position: { x: number; y: number };
};
export const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
let element = $state<HTMLDivElement | null>(null);
let dragIn = $state(false);
let isDraggingOverApp = $state(false);
const isOver = (x: number, y: number) => {
if (!element) return false;
const el = document.elementFromPoint(x, y);
return element === el || element.contains(el); // ← fix
};
const dropUnlisten = listen<TauriDragDropEvent>('tauri://drag-drop', (e) => {
const { x, y } = e.payload.position;
if (isOver(x, y)) {
onDrop(e.payload.paths);
}
dragIn = false;
isDraggingOverApp = false;
});
const overUnlisten = listen<TauriDragDropEvent>('tauri://drag-over', (e) => {
const { x, y } = e.payload.position;
isDraggingOverApp = true;
dragIn = isOver(x, y);
});
const leaveUnlisten = listen('tauri://drag-leave', () => {
dragIn = false;
isDraggingOverApp = false;
});
onDestroy(async () => {
(await dropUnlisten)();
(await overUnlisten)();
(await leaveUnlisten)();
});
return {
get ref() {
return element;
},
set ref(el: HTMLDivElement | null) {
element = el;
},
get dragIn() {
return dragIn;
},
get isDraggingOverApp() {
return isDraggingOverApp;
},
};
};
+19 -4
View File
@@ -1,8 +1,11 @@
import { writable } from 'svelte/store';
import { ezppfarm } from './api/ezpp';
import type { Component } from 'svelte';
import { browser } from '$app/environment';
import Loading from '../screens/Loading.svelte';
import { ezppfarm } from './api/ezpp';
import type { Theme } from './themes';
import type { Release } from './types';
import { setGlobalVolume } from './utils';
import type { Component } from 'svelte';
import { writable } from 'svelte/store';
export const currentView = writable<Component>(Loading);
@@ -24,7 +27,19 @@ export const currentLoadingInfo = writable<string>('Initializing...');
export const firstStartup = writable<boolean>(false);
export const launching = writable<boolean>(false);
export const nyanCatSong = writable<HTMLAudioElement | undefined>(undefined);
export const custom_themes = writable<Theme[]>([]);
export const custom_theme_container = writable<HTMLElement | undefined>(undefined);
export const active_custom_theme = writable<Theme | undefined>(undefined);
export const custom_theme_audio_playing = writable<boolean>(false);
export const custom_theme_volume = writable<number>(0.15);
custom_theme_volume.subscribe((val) => {
if (browser) {
setGlobalVolume(val);
}
});
export const openTabletDriverEnabled = writable<boolean>(false);
export const openTabletDriverPath = writable<string>('');
export const serverPing = writable<number | undefined>(undefined);
export const serverConnectionFails = writable(0);
+31 -40
View File
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import type { UpdateFile, UpdateStatus } from './types';
import { listen } from '@tauri-apps/api/event';
import { betterFetch } from '@better-fetch/fetch';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
const updateUrl = 'https://ez-pp.farm/api/ezpplauncher';
@@ -17,20 +17,14 @@ export function normalizeOsuName(name: string): string {
);
}
export const getHWID = async () => {
const hwid = await invoke('get_hwid');
return typeof hwid === 'string' ? hwid : undefined;
};
export const getHWID = async (): Promise<string | undefined> =>
await invoke<string | undefined>('get_hwid');
export const isValidOsuFolder = async (folder: string): Promise<boolean> => {
const result = await invoke('valid_osu_folder', { folder });
return typeof result === 'boolean' ? result : false;
};
export const isValidOsuFolder = async (folder: string): Promise<boolean | undefined> =>
await invoke<boolean | undefined>('valid_osu_folder', { folder });
export const autoDetectOsuInstallFolder = async () => {
const result = await invoke('find_osu_installation');
return typeof result === 'string' ? result : undefined;
};
export const autoDetectOsuInstallFolder = async () =>
await invoke<string | undefined>('find_osu_installation');
export const setUserConfigValues = async (
osuFolderPath: string,
@@ -50,29 +44,23 @@ export const setConfigValues = async (
entries,
});
export const getReleaseStream = async (folder: string) => {
const result = await invoke('get_osu_release_stream', { folder });
return typeof result === 'string' ? result : undefined;
};
export const getReleaseStream = async (folder: string): Promise<string | undefined> =>
await invoke<string | undefined>('get_osu_release_stream', { folder });
export const getVersion = async (folder: string) => {
const result = await invoke('get_osu_version', { folder });
return typeof result === 'string' ? result : undefined;
};
export const getVersion = async (folder: string): Promise<string | undefined> =>
await invoke<string | undefined>('get_osu_version', { folder });
export const getBeatmapSetsCount = async (folder: string) => {
const result = await invoke('get_beatmapsets_count', {
export const getBeatmapSetsCount = async (folder: string): Promise<number | undefined> =>
await invoke<number | undefined>('get_beatmapsets_count', {
folder,
});
return typeof result === 'number' ? result : 0;
};
export const getSkin = async (folder: string) => {
const result = await invoke('get_osu_skin', {
const result = await invoke<string | undefined>('get_osu_skin', {
folder,
});
return typeof result === 'string' ? result : 'Default';
return result || 'Default';
};
export const runUpdater = async (folder: string) => await invoke('run_osu_updater', { folder });
@@ -113,8 +101,8 @@ export const downloadEZPPLauncherUpdateFiles = async (
allFiles: UpdateFile[],
progressCallback: (file: UpdateStatus) => void
) => {
const downloadStatusListen = await listen('download-progress', (event) =>
progressCallback(event.payload as UpdateStatus)
const downloadStatusListen = await listen<UpdateStatus>('download-progress', (event) =>
progressCallback(event.payload)
);
try {
await invoke('download_ezpp_launcher_update_files', { folder, updateFiles, allFiles });
@@ -127,20 +115,17 @@ export const replaceUIFiles = async (folder: string, revert: boolean) => {
await invoke('replace_ui_files', { folder, revert });
};
export const isOsuRunning = async () => {
const result = await invoke('is_osu_running');
return typeof result === 'boolean' ? result : false;
};
export const isOsuRunning = async (): Promise<boolean> => await invoke<boolean>('is_osu_running');
export const getLauncherVersion = async () => await invoke<string>('get_launcher_version');
export const exit = async () => await invoke('exit');
export const getPlatform = async () => await invoke<string>('get_platform');
export const isOsuCorrupted = async (folder: string) =>
export const getPlatform = async (): Promise<string> => await invoke<string>('get_platform');
export const isOsuCorrupted = async (folder: string): Promise<boolean> =>
await invoke<boolean>('check_for_corruption', { folder });
export const hasWMCTRL = async () => await invoke<boolean>('has_wmctrl');
export const hasOsuWinello = async () => await invoke<boolean>('has_osuwinello');
export const hasNet8 = async () => await invoke<boolean>('has_net8');
export const encryptString = async (str: string, entropy: string) =>
export const hasWMCTRL = async (): Promise<boolean> => await invoke<boolean>('has_wmctrl');
export const hasOsuWinello = async (): Promise<boolean> => await invoke<boolean>('has_osuwinello');
export const hasNet8 = async (): Promise<boolean> => await invoke<boolean>('has_net8');
export const encryptString = async (str: string, entropy: string): Promise<string> =>
await invoke<string>('encrypt_string', { string: str, entropy });
export const downloadUpdate = async (
url: string,
@@ -161,3 +146,9 @@ export const getSkins = async (folder: string) =>
'get_skins',
{ folder }
);
export const startOpenTabletDriver = async (otdDaemonPath: string) =>
await invoke('run_open_tablet_driver', { path: otdDaemonPath });
export const stopOpenTabletDriver = async () => await invoke('stop_open_tablet_driver');
export const isOpenTabletDriverRunning = async () => await invoke('is_open_tablet_driver_running');
export const isOpenTabletDriverPathValid = async (path: string) =>
await invoke('is_open_tablet_driver_path_valid', { path });
+2
View File
@@ -16,4 +16,6 @@ export const updateStatus = async (status: {
});
export const updateUser = async (user: { username: string; id?: string | null }) =>
await invoke('presence_update_user', { user: { username: user.username, id: user.id } });
export const updateButton = async (button: { text?: string; url?: string }) =>
await invoke('presence_update_button', { button: button });
export const isConnected = async () => await invoke<boolean>('presence_is_connected');
+3 -1
View File
@@ -7,7 +7,9 @@ const osuSkinsCollator = new Intl.Collator(undefined, {
sensitivity: 'base',
});
export const sortSkins = (skins: { name: string, author: string | undefined, modified: number}[]) =>
export const sortSkins = (
skins: { name: string; author: string | undefined; modified: number }[]
) =>
skins.sort((a, b) => {
const pa = priority[a.name[0]];
const pb = priority[b.name[0]];
+457
View File
@@ -0,0 +1,457 @@
import { active_custom_theme, custom_themes } from './global';
import { disableMediaControls, setGlobalVolume } from './utils';
import { betterFetch } from '@better-fetch/fetch';
import { convertFileSrc } from '@tauri-apps/api/core';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
import { SemVer } from 'semver';
export type Theme = {
folder_name: string;
name: string;
version: string;
author: string;
scriptUrl: string;
cssStyle: string;
assets: string;
preview: string;
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
progress: number;
updateAvailable: boolean;
};
export type ThemeInfo = {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
};
type DownloadProgress = {
theme_name: string;
received: number;
total: number;
progress: number;
};
type ExtractProgress = {
theme_name: string;
total: number;
extracted: number;
progress: number;
current_file: string;
};
export const getDownloadableThemes = async () => {
const themes: Theme[] = [];
const downloadableThemes = await betterFetch<
{
name: string;
version: string;
author: string;
}[]
>('https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes.json', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
},
});
if (downloadableThemes.data) {
for (const theme of downloadableThemes.data) {
themes.push({
folder_name: `${toSafeName(theme.name)}-${generateRandomString(16)}`,
name: theme.name,
version: theme.version,
author: theme.author,
scriptUrl: '',
assets: '',
preview: `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(theme.name)}.png`,
status: 'not-installed',
progress: 0,
updateAvailable: false,
cssStyle: '',
});
}
} else {
console.error('Failed to fetch downloadable themes');
console.error(downloadableThemes.error);
}
return themes;
};
export const getThemes = async (): Promise<Theme[]> => {
const themes: Theme[] = [];
themes.push({
folder_name: 'default',
author: 'EZPPFarm',
name: 'Default',
version: '1.0.0',
scriptUrl: '',
assets: '',
preview: '',
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle: '',
});
const folderPath = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
if (await fs.exists(folderPath)) {
const themeFolders = await fs.readDir(folderPath);
for (const themeFolder of themeFolders) {
const themeFolderPath = await path.join(folderPath, themeFolder.name);
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json');
if (await fs.exists(themeConfigFilePath)) {
const themeConfig = await fs.readTextFile(themeConfigFilePath);
const theme = JSON.parse(themeConfig) as ThemeInfo;
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
const cssStylePath = await path.join(folderPath, themeFolder.name, theme.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
if (await fs.exists(scriptFile)) {
themes.push({
folder_name: themeFolder.name,
name: theme.name,
version: theme.version,
author: theme.author,
scriptUrl: convertFileSrc(await path.normalize(`${themeFolderPath}/${theme.entry}`)),
assets: convertFileSrc(await path.normalize(`${themeFolderPath}/assets`)),
preview: convertFileSrc(
await path.normalize(`${themeFolderPath}/assets/${theme.preview}`)
),
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle,
});
}
}
}
}
return themes;
};
export const downloadTheme = async (
theme: Theme,
force = false
): Promise<{ success: boolean; error?: string }> => {
if (theme.status !== 'not-installed' && !force) return { success: false };
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'downloading';
return themes;
});
const themeFileName = `${toSafeName(theme.name)}.ezpplauncher-theme`;
const themeContents = await betterFetch<{
file_contents: { sha: string; download_url: string };
}>(
`https://git.ez-pp.farm/api/v1/repos/EZPPFarm/EZPPLauncher-Themes/contents-ext/themes/${themeFileName}?ref=main`
);
if (themeContents.error) {
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
return {
success: false,
error: themeContents.error.message ?? 'An unknown error occurred',
};
}
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const themeFolder = await path.join(baseThemeFolder, toSafeName(theme.name));
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const downloadUrl = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${themeFileName}`;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'downloading';
matchedTheme.progress = 0;
}
return themes;
});
const unlistenDownload = await listen<DownloadProgress>('download_progress', (event) => {
if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'downloading';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
});
const unlistenExtract = await listen<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'extracting';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
});
try {
await invoke('download_and_extract_theme', {
downloadUrl,
expectedSha: themeContents.data.file_contents.sha,
themeFolder,
themeName: theme.name,
});
} catch (err) {
console.log(err);
const erro = err as Error;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
return { success: false, error: erro.message || 'Failed to download or extract theme.' };
} finally {
unlistenDownload();
unlistenExtract();
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
matchedTheme.folder_name = toSafeName(theme.name);
}
return themes;
});
await reloadThemes();
return { success: true };
};
export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => {
active_custom_theme.set(theme);
themeContainer.innerHTML = '';
if (theme.scriptUrl.length > 0) {
const themeScript = await import(/* @vite-ignore */ theme.scriptUrl);
themeScript.mountTheme(themeContainer, { assets: theme.assets });
}
setGlobalVolume(volume);
disableMediaControls();
};
export const deleteTheme = async (themeToUninstall: Theme) => {
if (themeToUninstall.status !== 'installed') {
return false;
}
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
if (!(await fs.exists(baseThemeFolder))) {
return false;
}
const themeFolder = await path.join(baseThemeFolder, themeToUninstall.folder_name);
if (!(await fs.exists(themeFolder))) {
return false;
}
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.status = 'deleting';
}
return themes;
});
await fs.remove(themeFolder, { recursive: true });
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.scriptUrl = '';
matchedTheme.cssStyle = '';
matchedTheme.assets = '';
matchedTheme.preview = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`;
matchedTheme.status = 'not-installed';
matchedTheme.updateAvailable = false;
}
return themes;
});
await reloadThemes();
return true;
};
export const checkThemeFromFile = async (filePath: string) => {
const normalizedFilePath = await path.normalize(filePath);
if (!(await fs.exists(normalizedFilePath))) {
return { success: false, error: 'File not found' };
}
try {
const themeInfo = await invoke<ThemeInfo>('read_theme_info', {
filePath: normalizedFilePath,
});
return { success: true, themeInfo };
} catch (err) {
console.log(err);
return { success: false, error: 'Could not read theme file' };
}
};
export const importThemeFromFile = async (themeName: string, filePath: string) => {
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const themeFolder = await path.join(baseThemeFolder, toSafeName(themeName));
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const unlisten = await listen<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== themeName) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeName);
if (matchedTheme) matchedTheme.progress = event.payload.progress;
return themes;
});
});
try {
await invoke('extract_theme', {
filePath,
themeFolder,
themeName,
});
} catch (err) {
console.log(err);
return { success: false, error: 'Failed to extract theme file.' };
} finally {
unlisten();
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeInfoObj.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
}
return themes;
});
await reloadThemes();
return { success: true };
};
export const reloadThemes = async () => {
const [downloadableThemes, installedThemes] = await Promise.all([
getDownloadableThemes(),
getThemes(),
]);
const installedMap = new Map(installedThemes.map((t) => [t.name, t]));
const combinedMap = new Map(installedThemes.map((t) => [t.name, { ...t }]));
for (const downloadable of downloadableThemes) {
const installed = installedMap.get(downloadable.name);
if (installed) {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
}
} else {
combinedMap.set(downloadable.name, downloadable);
}
}
const combinedThemes = [...combinedMap.values()];
custom_themes.update((storeThemes) => {
const storeMap = new Map(storeThemes.map((t) => [t.name, t]));
const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']);
for (const theme of combinedThemes) {
const storeTheme = storeMap.get(theme.name);
if (!storeTheme || ACTIVE_STATUSES.has(storeTheme.status)) continue;
storeTheme.scriptUrl = theme.scriptUrl;
storeTheme.assets = theme.assets;
storeTheme.preview = theme.preview;
storeTheme.version = theme.version;
storeTheme.author = theme.author;
storeTheme.updateAvailable = theme.updateAvailable;
storeTheme.cssStyle = theme.cssStyle;
}
const newThemes = combinedThemes.filter((t) => !storeMap.has(t.name));
const merged = [...storeThemes, ...newThemes];
return merged.sort((a, b) => {
if (a.name === 'Default') return -1;
if (b.name === 'Default') return 1;
if (a.status === 'installed' && b.status !== 'installed') return -1;
if (a.status !== 'installed' && b.status === 'installed') return 1;
return a.name.localeCompare(b.name);
});
});
};
export const toSafeName = (name: string) => {
return (
name
.toLowerCase()
.replace(/[<>:"/\\|?*]/g, '_')
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F]/g, '_')
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/[. ]+$/g, '_')
);
};
const generateRandomString = (length: number) => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length }, () =>
characters.charAt(Math.floor(Math.random() * characters.length))
).join('');
};
+2
View File
@@ -1,3 +1,5 @@
export type LaunchView = 'HOME' | 'LOGIN' | 'SETTINGS' | 'THEMES';
export type EZPPUserResponse = {
code: number;
message: string;
+34
View File
@@ -0,0 +1,34 @@
declare global {
const umami: Umami;
}
/**
* @see {@link https://umami.is/docs/tracker-functions|Umami Docs}
*/
interface Umami {
track(): Promise<string> | undefined;
track(event_name: string, event_data?: Record<string, unknown>): Promise<string> | undefined;
track(custom_payload: { website: string; [key: string]: unknown }): Promise<string> | undefined;
track(
callback: (props: {
hostname: string;
language: string;
referrer: string;
screen: string;
title: string;
url: string;
website: string;
}) => { website: string; [key: string]: unknown }
): Promise<string> | undefined;
/** Pass in your own ID to identify a user. */
identify(identity_id: string): Promise<void>;
/** Save data about the current session. */
identify(identity_id: string, data: Record<string, unknown>): Promise<void>;
/** To save data without a unique ID, pass in only a JSON object. */
identify(data: Record<string, unknown>): Promise<void>;
}
export {};
+1 -3
View File
@@ -1,6 +1,4 @@
import { writable } from 'svelte/store';
import { Config } from './config';
import type { EZPPUser } from './types';
import { writable } from 'svelte/store';
export const userAuth = writable<Config>(new Config('user_auth', true));
export const currentUser = writable<EZPPUser | undefined>(undefined);
-3
View File
@@ -1,7 +1,4 @@
import { writable } from 'svelte/store';
import { Config } from './config';
export const userSettings = writable<Config>(new Config('user_settings', false));
export const customCursor = writable<boolean>(false);
export const cursorSmoothening = writable<boolean>(false);
+147
View File
@@ -1,5 +1,7 @@
import { custom_theme_audio_playing } from './global';
import { invoke } from '@tauri-apps/api/core';
import { type ClassValue, clsx } from 'clsx';
import * as crypto from 'crypto-js';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
@@ -99,3 +101,148 @@ export const urlIsValidImage = async (url: string) => {
return false;
}
};
export function toURL(data: Buffer, type: string) {
return URL.createObjectURL(new Blob([new Uint8Array(data)], { type }));
}
export function setGlobalVolume(volume: number) {
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
mediaElements.forEach((element) => {
element.volume = volume;
});
}
export function fadeGlobalVolume(
volumeFrom: number,
volumeTo: number,
duration: number,
onDone?: () => void
) {
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
const startTime = performance.now();
function animate() {
const currentTime = performance.now();
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
const interpolatedVolume = volumeFrom + (volumeTo - volumeFrom) * progress;
mediaElements.forEach((element) => {
element.volume = interpolatedVolume;
});
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (onDone) onDone();
}
}
animate();
}
export const pauseGlobalMedia = () => {
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
mediaElements.forEach((element) => element.pause());
};
export const resumeGlobalMedia = () => {
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
mediaElements.forEach((element) => element.play());
};
export const setCurrentTimeGlobalMedia = (time: number) => {
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
mediaElements.forEach((element) => (element.currentTime = time));
};
export function trackMediaContainer(node: HTMLElement) {
const media = new Set<HTMLMediaElement>();
function update() {
custom_theme_audio_playing.set(Array.from(media).some((el) => !el.paused));
}
function register(el: HTMLMediaElement) {
media.add(el);
const handler = () => update();
el.addEventListener('play', handler);
el.addEventListener('pause', handler);
return () => {
media.delete(el);
el.removeEventListener('play', handler);
el.removeEventListener('pause', handler);
};
}
// find existing media inside the div
node.querySelectorAll<HTMLMediaElement>('video, audio').forEach(register);
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach((n) => {
if (n instanceof HTMLMediaElement) register(n);
if (n instanceof HTMLElement) {
n.querySelectorAll<HTMLMediaElement>('video, audio').forEach(register);
}
});
}
});
observer.observe(node, { childList: true, subtree: true });
return {
destroy() {
observer.disconnect();
},
};
}
const MEDIA_ACTIONS: MediaSessionAction[] = [
'play',
'pause',
'stop',
'previoustrack',
'nexttrack',
'seekbackward',
'seekforward',
];
export function disableMediaControls() {
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
if ('mediaSession' in navigator) {
MEDIA_ACTIONS.forEach((action) => {
try {
navigator.mediaSession.setActionHandler(action, () => {});
} catch {}
});
}
document.addEventListener(
'keydown',
(e: KeyboardEvent) => {
if (e.key.startsWith('Media')) {
e.preventDefault();
e.stopPropagation();
}
},
true
);
mediaElements.forEach((el) => {
el.disableRemotePlayback = true;
});
}
export function calculateGitBlobSha(fileBuffer: Buffer): string {
const header = Buffer.from(`blob ${fileBuffer.length}\0`);
const store = Buffer.concat([header, fileBuffer]);
return crypto.SHA1(crypto.lib.WordArray.create(store)).toString(crypto.enc.Hex);
}
+162 -186
View File
@@ -1,49 +1,44 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import NyanCatSong from '$assets/nyan-cat.mp3';
import '../app.css';
import Titlebar from '@/components/ui/titlebar/titlebar.svelte';
import * as AlertDialog from '@/components/ui/alert-dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import OsuCursor from '$lib/components/ui/osu-cursor/OsuCursor.svelte';
import SileoToast from '$lib/components/ui/sileo/sileo-toast.svelte';
import Titlebar from '$lib/components/ui/titlebar/titlebar.svelte';
import { config } from '$lib/config';
import { Config } from '$lib/config_old';
import {
active_custom_theme,
currentLoadingInfo,
custom_theme_container,
custom_theme_volume,
custom_themes,
discordPresence,
firstStartup,
launcherVersion,
nyanCatSong,
openTabletDriverEnabled,
openTabletDriverPath,
platform,
presenceLoading,
setupValues,
trackingEnabled,
} from '@/global';
import { onMount } from 'svelte';
import OsuCursor from '@/components/ui/osu-cursor/OsuCursor.svelte';
import {
cursorSmoothening,
customCursor,
osuInstallationPath,
patch,
reduceAnimations,
userSettings,
} from '@/userSettings';
} from '$lib/global';
import { exit, getLauncherVersion, getPlatform } from '$lib/osuUtil';
import * as presence from '$lib/presence';
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
import { cursorSmoothening, customCursor, patch, reduceAnimations } from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils';
import '../app.css';
import { Buffer } from 'buffer';
import SileoToast from '@/components/ui/sileo/sileo-toast.svelte';
import { userAuth } from '@/userAuthentication';
import { exit, getLauncherVersion, getPlatform } from '@/osuUtil';
import Button from '@/components/ui/button/button.svelte';
import * as presence from '@/presence';
import { fade } from 'svelte/transition';
import '@fontsource/sora';
import '@fontsource/space-mono';
import AnimatedBg from '@/components/ui/animated-bg/AnimatedBg.svelte';
import NyanCatBg from '@/components/ui/animated-bg/NyanCatBg.svelte';
import { SemVer } from 'semver';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
let { children } = $props();
let unsupported_platform = $state<boolean>(false);
let secret_enabled = $state<boolean>(false);
function disableReload() {
if (window.location.hostname !== 'tauri.localhost') {
@@ -57,18 +52,12 @@
event.key === 'F8' ||
event.key === 'F10' ||
event.key === 'F12' ||
(event.ctrlKey && event.key === 'r') ||
(event.metaKey && event.key === 'r') ||
(event.ctrlKey && event.key === 'f') ||
(event.metaKey && event.key === 'f') ||
(event.ctrlKey && event.key === 'g') ||
(event.metaKey && event.key === 'g') ||
(event.ctrlKey && event.key === 'j') ||
(event.metaKey && event.key === 'j') ||
(event.ctrlKey && event.key === 'p') ||
(event.metaKey && event.key === 'p') ||
(event.ctrlKey && event.key === 'u') ||
(event.metaKey && event.key === 'u')
((event.ctrlKey || event.metaKey) && event.key === 'r') ||
((event.ctrlKey || event.metaKey) && event.key === 'f') ||
((event.ctrlKey || event.metaKey) && event.key === 'g') ||
((event.ctrlKey || event.metaKey) && event.key === 'j') ||
((event.ctrlKey || event.metaKey) && event.key === 'p') ||
((event.ctrlKey || event.metaKey) && event.key === 'u')
) {
event.preventDefault();
}
@@ -93,167 +82,97 @@
);
}
let konami_code = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
];
onMount(async () => {
window.Buffer = Buffer;
disableReload();
setupValues();
launcherVersion.set(await getLauncherVersion());
platform.set(await getPlatform());
if ($platform !== 'windows' && $platform !== 'linux') unsupported_platform = true;
const isFirstStartup = await $userSettings.init();
firstStartup.set(isFirstStartup);
$userAuth.init();
document.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === konami_code[0].toLowerCase()) {
konami_code.shift();
if (konami_code.length === 0) {
if (secret_enabled) {
sileo.success({
title: 'Cheatcode deactivated!',
duration: 3000,
fill: '#181825',
styles: {
description: 'text-center!',
},
});
const audio = $nyanCatSong;
if (audio) {
const fadeOutDuration = 2000; // 2 seconds
const fadeOutSteps = 20;
const fadeOutStepTime = fadeOutDuration / fadeOutSteps;
const volumeStep = audio.volume / fadeOutSteps;
const oldUserConfig = new Config('user_settings', false);
const oldUserConfigExists = await oldUserConfig.init();
if (oldUserConfigExists) {
const oldUserConfigValues = oldUserConfig.values();
if (Object.keys(oldUserConfigValues).length > 0) {
await config.value<boolean>('setup_complete').set(true);
const fadeOutInterval = setInterval(() => {
if (audio.volume > 0) {
audio.volume = Math.max(0, audio.volume - volumeStep);
} else {
audio.pause();
clearInterval(fadeOutInterval);
for (const key in oldUserConfigValues) {
const value = oldUserConfigValues[key];
await config.value<typeof value>(key).set(value);
}
}, fadeOutStepTime);
}
nyanCatSong.set(undefined);
secret_enabled = false;
konami_code = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
];
return;
await oldUserConfig.delete();
}
sileo.success({
title: 'Cheatcode activated!',
duration: 3000,
fill: '#181825',
styles: {
description: 'text-center!',
},
});
const audio = new Audio(NyanCatSong);
audio.loop = true;
audio.volume = 0;
audio.play();
const fadeInVolume = 0.15;
const fadeInDuration = 2000; // 2 seconds
const fadeInSteps = 20;
const fadeInStepTime = fadeInDuration / fadeInSteps;
const volumeStep = (fadeInVolume - audio.volume) / fadeInSteps;
const fadeInInterval = setInterval(() => {
if (audio.volume < fadeInVolume) {
audio.volume = Math.min(fadeInVolume, audio.volume + volumeStep);
} else {
clearInterval(fadeInInterval);
const oldUserAuth = new Config('user_auth', true);
const oldUserAuthExists = await oldUserAuth.init();
if (oldUserAuthExists) {
const oldUserAuthValues = oldUserAuth.values();
if (Object.keys(oldUserAuthValues).length > 0) {
for (const key in oldUserAuthValues) {
const value = oldUserAuthValues[key];
await config.value<typeof value>(key).set(value);
}
}
await oldUserAuth.delete();
}
}, fadeInStepTime);
nyanCatSong.set(audio);
secret_enabled = true;
konami_code = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
];
}
} else {
konami_code = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
];
}
});
const isSetupComplete = await config.value<boolean>('setup_complete').get(false);
firstStartup.set(!isSetupComplete);
currentLoadingInfo.set('Loading config...');
const config_patching = $userSettings.value('patching');
const config_custom_cursor = $userSettings.value('custom_cursor');
const config_cursor_smoothening = $userSettings.value('cursor_smoothening');
const config_reduce_animations = $userSettings.value('reduce_animations');
const config_osu_installation_path = $userSettings.value('osu_installation_path');
const config_discord_presence = $userSettings.value('discord_presence');
const config_tracking_enabled = $userSettings.value('tracking_consent');
const config_theme = config.value<string>('theme');
const config_theme_volume = config.value<number>('volume');
const config_patching = config.value<boolean>('patching');
const config_custom_cursor = config.value<boolean>('custom_cursor');
const config_cursor_smoothening = config.value<boolean>('cursor_smoothening');
const config_reduce_animations = config.value<boolean>('reduce_animations');
const config_discord_presence = config.value<boolean>('discord_presence');
const config_tracking_enabled = config.value<boolean>('tracking_consent');
const config_otd_enabled = config.value<boolean>('otd_enabled');
const config_otd_path = config.value<string>('otd_path');
patch.set(config_patching.get(true));
customCursor.set(config_custom_cursor.get(true));
cursorSmoothening.set(config_cursor_smoothening.get(true));
reduceAnimations.set(config_reduce_animations.get(false));
osuInstallationPath.set(config_osu_installation_path.get(''));
discordPresence.set(config_discord_presence.get(true));
if (config_tracking_enabled.exists()) {
trackingEnabled.set(config_tracking_enabled.get(false));
const localThemes = await getThemes();
const lastThemeName = await config_theme.get('Default');
const last_theme = localThemes.find((t) => t.name === lastThemeName);
if (!last_theme) {
await config.value<string>('theme').set('Default');
loadTheme(localThemes[0], $custom_theme_container!, await config_theme_volume.get(0.15));
} else {
loadTheme(last_theme, $custom_theme_container!, await config_theme_volume.get(0.15));
}
patch.subscribe((val) => config_patching.set(val));
customCursor.subscribe((val) => config_custom_cursor.set(val));
cursorSmoothening.subscribe((val) => config_cursor_smoothening.set(val));
reduceAnimations.subscribe((val) => config_reduce_animations.set(val));
custom_theme_volume.set(await config_theme_volume.get(0.15));
patch.set(await config_patching.get(true));
customCursor.set(await config_custom_cursor.get(true));
cursorSmoothening.set(await config_cursor_smoothening.get(true));
reduceAnimations.set(await config_reduce_animations.get(false));
discordPresence.set(await config_discord_presence.get(true));
openTabletDriverEnabled.set(await config_otd_enabled.get(false));
openTabletDriverPath.set(await config_otd_path.get(''));
if (await config_tracking_enabled.exists()) {
trackingEnabled.set(await config_tracking_enabled.get(false));
}
patch.subscribe(async (val) => await config_patching.set(val));
customCursor.subscribe(async (val) => await config_custom_cursor.set(val));
cursorSmoothening.subscribe(async (val) => await config_cursor_smoothening.set(val));
reduceAnimations.subscribe(async (val) => await config_reduce_animations.set(val));
openTabletDriverEnabled.subscribe(async (val) => config_otd_enabled.set(val));
discordPresence.subscribe(async (val) => {
config_discord_presence.set(val);
try {
presenceLoading.set(true);
if (val) {
await presence.connect();
} else {
await presence.disconnect();
}
if (val) await presence.connect();
else await presence.disconnect();
presenceLoading.set(false);
} catch {}
} catch (err) {
console.log(err);
presenceLoading.set(false);
}
});
try {
@@ -263,15 +182,69 @@
await presence.connect();
presenceLoading.set(false);
}
} catch {}
} catch (err) {
console.log(err);
presenceLoading.set(false);
}
try {
currentLoadingInfo.set('Loading themes...');
const downloadableThemes = await getDownloadableThemes();
const combinedThemes = [...localThemes];
for (const theme of downloadableThemes) {
if (!combinedThemes.find((t) => t.name === theme.name)) combinedThemes.push(theme);
const installedTheme = localThemes.find((t) => t.name === theme.name);
if (installedTheme) {
const installedThemeVersion = new SemVer(installedTheme.version);
const downloadableThemeVersion = new SemVer(theme.version);
if (downloadableThemeVersion.compare(installedThemeVersion) > 0) {
installedTheme.updateAvailable = true;
const index = combinedThemes.findIndex((t) => t.name === theme.name);
combinedThemes[index] = installedTheme;
}
}
}
combinedThemes.sort((a, b) => {
if (a.name === 'Default') return -1;
if (b.name === 'Default') return 1;
if (a.status === 'installed' && b.status !== 'installed') return -1;
if (a.status !== 'installed' && b.status === 'installed') return 1;
return a.name.localeCompare(b.name);
});
custom_themes.set(combinedThemes);
} catch (err) {
console.log(err);
sileo.error({
title: 'An error occured!',
description: 'Failed to load themes',
});
}
platform.set(await getPlatform());
if ($platform !== 'windows' && $platform !== 'linux') unsupported_platform = true;
});
</script>
<svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${$active_custom_theme?.cssStyle || ''}</style>`}
</svelte:head>
{#if $customCursor}
<OsuCursor smoothCursor={$cursorSmoothening} />
{/if}
<SileoToast position="top-center" fill="black" />
<SileoToast
position="top-center"
options={{
fill: '#181825',
styles: {
description: 'text-white/90 text-center!',
},
}}
/>
<Titlebar />
@@ -300,15 +273,18 @@
<main>
<div class="opacity-30">
{#if secret_enabled}
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
<NyanCatBg />
</div>
{:else}
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
<div
class={$active_custom_theme && $active_custom_theme.name === 'Default'
? 'hidden'
: 'absolute z-0 top-0 left-0 w-full h-full object-cover object-center aspect-video'}
bind:this={$custom_theme_container}
use:trackMediaContainer
></div>
{#if ($active_custom_theme && $active_custom_theme.name === 'Default') || !$active_custom_theme}
<AnimatedBg />
</div>
{/if}
</div>
</div>
{@render children()}
</main>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { currentView, launcherVersion, trackingEnabled } from '@/global';
import { currentView, launcherVersion, trackingEnabled } from '$lib/global';
import { fade } from 'svelte/transition';
const View = $derived($currentView);
+680 -243
View File
File diff suppressed because it is too large Load Diff
+42 -52
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import { estimateRefreshRate } from '@/displayUtils';
import { ezppfarm } from '$lib/api/ezpp';
import { git } from '$lib/api/git';
import { config } from '$lib/config';
import { currentUserInfo } from '$lib/data';
import { estimateFrameRate } from '$lib/displayUtils';
import {
beatmapSets,
currentLoadingInfo,
@@ -15,21 +19,7 @@
osuStream,
skins,
skinsCount,
} from '@/global';
import {
cursorSmoothness,
osuInstallationPath,
preferredMode,
preferredType,
userSettings,
} from '@/userSettings';
import { animate, utils } from 'animejs';
import { onMount } from 'svelte';
import SetupWizard from './SetupWizard.svelte';
import Launch from './Launch.svelte';
import { currentUser, userAuth } from '@/userAuthentication';
import { ezppfarm } from '@/api/ezpp';
import { currentUserInfo } from '@/data';
} from '$lib/global';
import {
getBeatmapSetsCount,
getEZPPLauncherStreams,
@@ -38,10 +28,20 @@
getSkins,
getVersion,
isValidOsuFolder,
} from '@/osuUtil';
import { git } from '@/api/git';
} from '$lib/osuUtil';
import type { EZPPUser } from '$lib/types';
import { currentUser } from '$lib/userAuthentication';
import {
cursorSmoothness,
osuInstallationPath,
preferredMode,
preferredType,
} from '$lib/userSettings';
import Launch from './Launch.svelte';
import SetupWizard from './SetupWizard.svelte';
import { animate, utils } from 'animejs';
import { sileo } from 'sileo';
import type { EZPPUser } from '@/types';
import { onMount } from 'svelte';
let ezppLogo: HTMLImageElement;
let spinnerCircle: SVGCircleElement;
@@ -67,23 +67,22 @@
};
const calculateCursorSmoothness = async () => {
const refreshRate = await estimateRefreshRate();
const refreshRate = await estimateFrameRate();
const hzMin = 60;
const hzMax = 144;
const durationMin = 70;
const durationMax = 180;
const hzMax = 500;
const durationMin = 350;
const durationMax = 600;
const clamped = Math.min(Math.max(refreshRate, hzMin), hzMax);
const duration =
durationMin + ((refreshRate - hzMin) / (hzMax - hzMin)) * (durationMax - durationMin);
durationMin + ((clamped - hzMin) / (hzMax - hzMin)) * (durationMax - durationMin);
cursorSmoothness.set(Math.round(duration));
};
const prepare = async () => {
await calculateCursorSmoothness();
const username = $userAuth.value('username').get('');
const password = $userAuth.value('password').get('');
const username = await config.value<string>('username').get('');
const password = await config.value<string>('password').get('');
const shouldLogin = username.length > 0 && password.length > 0;
let userLoginResult: { message: string; user?: EZPPUser } = {
@@ -106,7 +105,8 @@
message: 'Please check your username and password.',
};
}
} catch {
} catch (err) {
console.log(err);
userLoginResult = {
message: 'There was an issue connecting to the server. Please try again later.',
};
@@ -122,7 +122,8 @@
preferredMode.set(userInfo.player.info.preferred_mode);
preferredType.set(userInfo.player.info.preferred_type);
}
} catch {
} catch (err) {
console.log(err);
userLoginResult = {
message: 'There was an issue connecting to the server. Please try again later.',
};
@@ -132,20 +133,18 @@
if (!$firstStartup) {
currentLoadingInfo.set('Checking osu installation path...');
const validFolder = await isValidOsuFolder($osuInstallationPath);
const config_osu_installation_path = config.value<string>('osu_installation_path');
const osuInstallationPathVal = await config_osu_installation_path.get('');
const validFolder = await isValidOsuFolder(osuInstallationPathVal);
if (!validFolder) {
await config_osu_installation_path.set('');
osuInstallationPath.set('');
$userSettings.value('osu_installation_path').del();
await $userSettings.save();
sileo.error({
title: 'Hmm...',
description: 'Your previously set osu! installation path seems to be invalid.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
} else {
osuInstallationPath.set(osuInstallationPathVal);
currentLoadingInfo.set('Getting osu version...');
const osuReleaseStream = await getReleaseStream($osuInstallationPath);
osuStream.set(osuReleaseStream);
@@ -158,7 +157,6 @@
currentLoadingInfo.set('Counting skins...');
const skins_list = await getSkins($osuInstallationPath);
console.log(skins_list);
if (skins_list) {
skins.set(skins_list);
skinsCount.set(skins_list.length);
@@ -180,7 +178,7 @@
const ezpplauncherStreams = await getEZPPLauncherStreams();
if (ezpplauncherStreams) launcherStreams.set(ezpplauncherStreams);
const selectedLauncherStream = $userSettings.value('patcherStream').get('stable');
const selectedLauncherStream = await config.value<string>('patcherStream').get('stable');
if ($launcherStreams.includes(selectedLauncherStream)) {
launcherStream.set(selectedLauncherStream);
}
@@ -207,20 +205,12 @@
sileo.success({
title: 'Login successful!',
description: userLoginResult.message,
fill: '#181825',
styles: {
description: 'text-center!',
},
duration: 2500,
});
} else {
sileo.error({
title: 'Login failed!',
description: userLoginResult.message,
fill: '#181825',
styles: {
description: 'text-center!',
},
});
}
}
@@ -252,19 +242,19 @@
});
</script>
<div class="flex flex-col items-center justify-center h-[100vh] w-full">
<div class="relative w-80 h-80 flex items-center justify-center">
<div class="flex flex-col items-center justify-center h-screen w-full z-10">
<div class="relative w-80 h-80 flex items-center justify-center z-10">
<svg
class="absolute top-0 left-0 w-full h-full animate-spin"
style="animation-duration: 5s;"
viewBox="0 0 208 208"
>
<circle
class="stroke-primary"
cx="104"
cy="104"
r="90"
fill="none"
stroke="#ff0098"
stroke-width="8"
stroke-linecap="round"
stroke-dasharray="180 385"
@@ -275,9 +265,9 @@
<img
src={Logo}
alt="EZPPLauncher Logo"
class="w-52 h-52 mb-2 relative z-10"
class="w-52 h-52 mb-2 relative z-10 select-auto pointer-events-none"
bind:this={ezppLogo}
/>
</div>
<span class="text-theme-200 text-sm mt-5">{$currentLoadingInfo}</span>
<span class="text-theme-200 text-sm mt-5 z-10">{$currentLoadingInfo}</span>
</div>
+30 -31
View File
@@ -1,22 +1,11 @@
<script lang="ts">
import { dev } from '$app/environment';
import Logo from '$assets/logo.png';
import Button from '@/components/ui/button/button.svelte';
import Input from '@/components/ui/input/input.svelte';
import { animate } from 'animejs';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Check, CircleCheckBig, CircleOff, LoaderCircle } from 'lucide-svelte';
import { open } from '@tauri-apps/plugin-dialog';
import Checkbox from '@/components/ui/checkbox/checkbox.svelte';
import {
cursorSmoothening,
customCursor,
osuInstallationPath,
patch,
reduceAnimations,
userSettings,
} from '@/userSettings';
import Label from '@/components/ui/label/label.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/config';
import {
beatmapSets,
currentSkin,
@@ -28,9 +17,7 @@
presenceLoading,
skins,
skinsCount,
} from '@/global';
import Launch from './Launch.svelte';
import Confetti from 'svelte-confetti';
} from '$lib/global';
import {
autoDetectOsuInstallFolder,
getBeatmapSetsCount,
@@ -39,7 +26,21 @@
getSkins,
getVersion,
isValidOsuFolder,
} from '@/osuUtil';
} from '$lib/osuUtil';
import {
cursorSmoothening,
customCursor,
osuInstallationPath,
patch,
reduceAnimations,
} from '$lib/userSettings';
import Launch from './Launch.svelte';
import { Check, CircleCheckBig, CircleOff, LoaderCircle } from '@lucide/svelte';
import { open } from '@tauri-apps/plugin-dialog';
import { animate } from 'animejs';
import { onMount } from 'svelte';
import Confetti from 'svelte-confetti';
import { fade } from 'svelte/transition';
let selectedStep = $state(1);
const steps = ['Welcome', 'Locate your osu! Installation', 'Appearance Settings'];
@@ -94,9 +95,9 @@
};
const saveConfig = async () => {
$userSettings.value('osu_installation_path').set(osuInstallPath);
await $userSettings.save();
await config.value<string>('osu_installation_path').set(osuInstallPath);
osuInstallationPath.set(osuInstallPath);
await config.value<boolean>('setup_complete').set(true);
const beatmapSetCount = await getBeatmapSetsCount(osuInstallPath);
if (beatmapSetCount) {
@@ -124,17 +125,16 @@
onMount(async () => {
const osuPath = await autoDetectOsuInstallFolder();
console.log(osuPath);
if (osuPath) {
osuInstallPath = osuPath;
autoDetectedOsuPath = true;
$userSettings.value('osu_installation_path').set(osuInstallPath);
await config.value<string>('osu_installation_path').set(osuInstallPath);
}
});
</script>
{#if wizardFinished}
<div class="relative flex flex-col items-center justify-center h-[100vh] z-50">
<div class="relative flex flex-col items-center justify-center h-screen z-50">
<div class="absolute h-fit w-fit top-1/2 left-1/2">
<Confetti amount={200} y={[-1, 1.5]} x={[-2.3, 2.3]} colorArray={['#C6A0F3']} />
</div>
@@ -143,7 +143,7 @@
<Button class="mt-4" onclick={saveConfig}>Finish</Button>
</div>
{:else}
<div class="grid grid-cols-[0.41fr_1fr] h-[100vh] z-50">
<div class="grid grid-cols-[0.41fr_1fr] h-screen z-50">
<div
class="w-full h-full border-r border-black/40 bg-black/40 backdrop-blur-md flex flex-col gap-6 p-3 z-50"
>
@@ -156,8 +156,7 @@
: 'border-black/30 bg-black/30'} rounded-lg p-2 transition-all"
>
<div
class="flex flex-col items-center justify-center h-8 w-8 border-[2px] {selectedStep >
i + 1
class="flex flex-col items-center justify-center h-8 w-8 border-2 {selectedStep > i + 1
? 'border-green-600'
: 'border-theme-600'} rounded-full"
>
@@ -268,7 +267,7 @@
<Label class="text-sm" for="setting-patch">Patching</Label>
<div class="text-muted-foreground text-xs">
Shows misses in Relax and Autopilot {#if $platform !== 'windows'}<span
class="text-red-500 bg-red-800/20 border border-red-600/20 p-0.5 mx-1 px-2 rounded-lg !text-[0.55rem]"
class="text-red-500 bg-red-800/20 border border-red-600/20 p-0.5 mx-1 px-2 rounded-lg text-[0.55rem]!"
>currently only on windows!</span
>
{/if}
@@ -372,7 +371,7 @@
else selectedStep = Math.min(selectedStep + 1, steps.length);
}}
disabled={selectedStep > steps.length ||
(selectedStep === 2 && osuInstallPath.length <= 0)}
(selectedStep === 2 && !dev && osuInstallPath.length <= 0)}
>{selectedStep >= steps.length ? 'Finish' : 'Next'}</Button
>
</div>
-1
View File
@@ -10,7 +10,6 @@ const config = {
kit: {
adapter: adapter(),
alias: {
'@/*': './src/lib/*',
'$lib/*': './src/lib/*',
'$assets/*': './src/assets/*',
},
-122
View File
@@ -1,122 +0,0 @@
import { fontFamily } from 'tailwindcss/defaultTheme';
import type { Config } from 'tailwindcss';
import tailwindcssAnimate from 'tailwindcss-animate';
const config: Config = {
darkMode: 'class',
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ['dark'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
theme: {
DEFAULT: '#181825',
50: '#E9E9F1',
100: '#DBDBE8',
200: '#BFBFD6',
300: '#A4A4C4',
400: '#8888B2',
500: '#6C6CA0',
600: '#575786',
700: '#45456B',
800: '#33334F',
900: '#212133',
950: '#181825',
},
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',
ring: 'hsl(var(--ring) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
50: '#FEFDFF',
100: '#FAF6FE',
200: '#F2E9FC',
300: '#E9DBFA',
400: '#E1CEF9',
500: '#D9C0F7',
600: '#C6A0F3',
700: '#B381EF',
800: '#A061EB',
900: '#8D41E7',
950: '#8331E5',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)',
},
destructive: {
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
},
accent: {
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)',
},
popover: {
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)',
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
xl: 'calc(var(--radius) + 4px)',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: ['"Sora"', ...fontFamily.sans],
mono: ['"Space Mono"', ...fontFamily.mono],
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--bits-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--bits-accordion-content-height)' },
to: { height: '0' },
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
},
},
},
plugins: [tailwindcssAnimate],
};
export default config;
+2 -1
View File
@@ -9,7 +9,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"types": ["@types/node"]
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
+2 -2
View File
@@ -1,7 +1,7 @@
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import devtoolsJson from 'vite-plugin-devtools-json';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import devtoolsJson from 'vite-plugin-devtools-json';
const host = process.env.TAURI_DEV_HOST;