Compare commits

..
206 Commits
Author SHA1 Message Date
HorizonCode ce9112f9e2 docs: update README with theme v2, discord presence, and platform support details 2026-11-01 11:00:32 +01:00
HorizonCode e52399911c fix(macos): resolve osu updater loop and patcher wine compatibility
- Remove host Apple Braille font entries from Wine prefix registry to prevent cold-start GDI+ AccessViolationException.
- Purge .require_update, help.txt, and _pending prior to osu! launch so the game launches directly with -devserver ez-pp.farm instead of entering the updater.
- Reset _UpdateFailCount in osu!.cfg and ensure execute.bat points to ez-pp.farm.
- Update ensure_patcher_compatibility to patch osu!.patcher.exe and embedded ManagedInjector.Lib for .NET 4.0 compatibility under Wine.
- Include theme editor, cursor, and download button refinements on themes-v2 branch.
2026-11-01 10:52:39 +01:00
HorizonCode a2b03d3014 style: format files with prettier and fix eslint issues 2026-10-31 21:27:28 +01:00
HorizonCode 7648d1fd4c feat(theme-v2): add detachable header widget, fix preview thumbnail rendering, and improve wineskin launch
- Support detaching title header to canvas with full, compact, and minimal variants
- Render detached widgets and respect chrome layout configuration in theme preview generator
- Fix macOS Wineskin launching by adding Frameworks to DYLD_FALLBACK_LIBRARY_PATH
- Fix launch button interactive handling, scaling margins, and icon-only states
- Improve player widget styling and variant display
2026-10-31 21:00:14 +01:00
HorizonCode 386c829455 feat: add discord rich presence editor window and interactive embed preview 2026-10-31 17:59:07 +01:00
HorizonCode e3abea8d83 fix: bind inspector props to resolve ownership_invalid_mutation warnings 2026-10-31 12:36:27 +01:00
HorizonCode 4dc6cb1721 feat: add date/time picker and fix launcherVersion initialization error 2026-10-31 11:00:00 +01:00
HorizonCode 54f7f81160 chore: add svelte component layer with monaco editor 2026-10-31 10:28:33 +01:00
HorizonCode 8071085e94 fix: allow window destroy 2026-10-29 16:23:27 +01:00
HorizonCode fbfa3aa54c fix: resize object opacity, context menu visibility 2026-10-29 16:18:20 +01:00
HorizonCode c02b26e8ae feat: polish theme tooling, diagnostics and launcher workflows
Expand the theme editor with multi-select, snapping, context menus, draft
recovery and export validation; add theme backups and trust prompts. Add
in-app diagnostics and rotating log bundle viewing, hardware-accelerated
theme video playback on macOS, and refresh the settings, setup wizard and
theme gallery layouts.
2026-10-29 15:41:33 +01:00
HorizonCode 87202de656 chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard 2026-10-29 10:53:12 +01:00
HorizonCode 417f54a94a fix(linux): osu-winello detection 2026-09-07 23:46:23 +02:00
HorizonCode 01628e8514 chore: update version to 3.2.7 and refactor skin handling functions 2026-08-09 23:24:50 +02:00
HorizonCode 5a06700748 chore: bump version 2026-07-20 22:21:02 +02:00
HorizonCode 74020e3817 fix: actually respect the patch setting smh 2026-07-20 22:20:03 +02:00
HorizonCode 6dc1f4f703 fix: mode select height 2026-06-26 10:58:35 +02:00
HorizonCode 21710d0e02 chore: bump version 2026-06-26 10:42:28 +02:00
HorizonCode b478e7ba95 Merge branch 'master' of https://git.ez-pp.farm/EZPPFarm/EZPPLauncher 2026-06-26 10:01:06 +02:00
HorizonCode bd368adfe5 chore: add scorev2 type and gamemodes 2026-06-26 10:00:56 +02:00
HorizonCode 85ea4167ab fix: update check on launch 2026-06-14 14:33:48 +02:00
HorizonCode adf7e487b9 feat: add update check failure dialog, ability to skip update check 2026-06-08 16:36:48 +02:00
HorizonCode 22d97a0068 chore: bump version 2026-06-07 16:59:50 +02:00
HorizonCode 992127ecb6 fix: only accept json 2026-06-07 00:13:55 +02:00
HorizonCode 0f53057897 chore: implement mirror of osu changelog 2026-06-07 00:10:24 +02:00
HorizonCode 03b326e142 Merge branch 'master' of https://git.ez-pp.farm/EZPPFarm/EZPPLauncher 2026-06-04 16:36:27 +02:00
HorizonCode 2ec8b08983 fix: dont show dynamic beatmap button on direct page 2026-06-04 16:36:20 +02:00
HorizonCode 344f15b8da fix: user config retrival on linux 2026-06-03 20:51:14 +02:00
HorizonCode 554f07412b chore: disable themes on linux for now until fix is found 2026-06-03 19:57:21 +02:00
HorizonCode f8f53c4236 fix: user config retrival on linux 2026-06-03 19:57:00 +02:00
HorizonCode 64f76d95a6 fix: remove platform dependency from patching settings in SetupWizard 2026-06-03 16:38:28 +02:00
HorizonCode 02fecd237c fix: update launcher version retrieval and improve HWID fallback 2026-06-03 16:16:11 +02:00
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
HorizonCodeandCopilot 3900b5c6cf chore: add volume slider
Co-authored-by: Copilot <[email protected]>
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
HorizonCodeandCopilot 8fd56ae749 chore: add custom themes
Co-authored-by: Copilot <[email protected]>
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
HorizonCodeandCopilot ef36f49a6f fix: improve presence reconnection logic and error handling
Co-authored-by: Copilot <[email protected]>
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
HorizonCode 7375cccc05 chore: bump version 2026-04-20 12:16:26 +02:00
HorizonCode 251bf8177c chore: add all avalilable fields to beatmap struct 2026-04-20 08:22:40 +02:00
HorizonCode bf4ce18671 chore: replace osynic_osudb with own osudb parser 2026-04-19 18:43:22 +02:00
HorizonCode 4dc83c882d todo: check if this is still needed for linux 2026-04-18 02:34:16 +02:00
HorizonCode e20f11be64 chore: bump version 2026-04-18 02:34:00 +02:00
HorizonCode 407996269e fix: do not save config in setupwizard on value change 2026-04-18 02:33:43 +02:00
HorizonCode 95c6db6a1d fix: remove unused import, set firstStartup on top 2026-04-18 02:33:24 +02:00
HorizonCode a87a4bcc54 chore: bump version and update deps 2026-04-18 02:32:54 +02:00
HorizonCode 97f072f4ce refactor: improve error handling and code readability in replace_ui_files and check_for_corruption functions 2026-04-14 22:52:16 +02:00
HorizonCode 1aa3070ea1 refactor: remove PermissionDenied variant from ReplaceUIError enum 2026-04-14 22:37:33 +02:00
HorizonCode d3c3e4e771 refactor: replace symlink approach with a marker file approach 2026-04-14 22:36:26 +02:00
HorizonCode 921dbd716c chore: update dependencies and version bump to 3.1.3 2026-04-12 22:46:15 +02:00
HorizonCode 34e82f89e8 chore: remove todo note 2026-04-11 21:50:30 +02:00
HorizonCode 901fbefd1b chore: remove unused imports 2026-04-10 12:00:16 +02:00
HorizonCode a795978827 Merge branch 'master' of https://git.ez-pp.farm/EZPPFarm/EZPPLauncher 2026-04-10 11:59:20 +02:00
HorizonCode a9152cc376 chore: remove unused imports 2026-04-10 11:59:18 +02:00
HorizonCode 8d790c7da3 chore: improve UI file replacement logic and fix corruption check 2026-04-10 11:58:11 +02:00
HorizonCode f5a2c4365b fix: check connection issues on load 2026-03-20 13:33:07 +01:00
HorizonCode a925548ae9 refactor: conditionally render patcher release stream for Windows platform 2026-03-05 21:24:37 +01:00
HorizonCode a6495ea353 fix: add schema.tauri.app as trusted schema url 2026-03-03 23:22:10 +01:00
HorizonCode 24802b5132 fix: read beatmapset count from osu!.db instead of iterating songs folder 2026-03-01 23:50:36 +01:00
HorizonCode f09405ed58 refactor: enhance audio fade-in logic for improved user experience 2026-03-01 23:50:06 +01:00
HorizonCode eeda85f48f style: enhance button animations in titlebar for improved visual feedback 2026-03-01 23:49:42 +01:00
HorizonCode 9770e81d6f refactor: improve presence handling and update disconnect logic for clarity 2026-02-28 16:56:46 +01:00
HorizonCode 31b7d17b09 feat: implement sorting functionality for skins using default windows sorting algotythm 2026-02-23 18:23:46 +01:00
HorizonCode 1fc0391113 style: format code for consistency and readability in sileo-toast component 2026-02-22 20:18:37 +01:00
HorizonCode 62f03cf7c0 fix: update ESLint configuration to disable no-empty rule and adjust unused vars handling 2026-02-22 20:18:21 +01:00
HorizonCode 94f6e850cf feat: update icons and launcher files 2026-02-22 20:14:38 +01:00
HorizonCode d6fdb2a152 chore: update Tauri dependencies to latest versions 2026-02-22 20:14:11 +01:00
HorizonCode 9cd990cf96 chore: update dependencies in Cargo.toml 2026-02-22 20:10:15 +01:00
HorizonCode 2e0ef81bcb feat: implement skin management 2026-02-22 17:05:52 +01:00
HorizonCode 96680ff8b5 fix: update button styles to use ring for better accessibility 2026-02-22 14:12:32 +01:00
HorizonCode 0b9d6373bf fix: add fade to bg and audio 2026-02-22 14:03:44 +01:00
HorizonCode 295c1424c9 chore: add audio 2026-02-22 12:35:08 +01:00
HorizonCode 0e12a801d2 chore: add secret :^) 2026-02-22 00:21:42 +01:00
HorizonCode cf86dba5c4 chore: rework titlebar buttons 2026-02-21 23:31:43 +01:00
HorizonCode 540c653399 fix: reconnect presence after pipe was closed 2026-02-21 23:18:01 +01:00
HorizonCode c9b8d9f599 fix: respect reducedAnimations 2026-02-21 22:59:19 +01:00
HorizonCode 49665d94bc fix: Setup WIzard styling 2026-02-19 19:38:21 +01:00
HorizonCode 1a19c19399 chore: bump version 2026-02-19 15:48:26 +01:00
HorizonCode cbfe5ec868 fix: Setup Wizard not working 2026-02-19 15:41:29 +01:00
HorizonCode c5b18dacbb chore: update lucide-svelte 2026-02-19 15:41:10 +01:00
HorizonCode ef01984875 fix: osu installation detection 2026-02-19 15:40:56 +01:00
HorizonCode 46ee36ce81 fix: disable all buttons and dropdowns on launch 2026-02-17 20:34:57 +01:00
HorizonCode c4658ae4cc fix: sort streaks by depth 2026-02-17 20:13:43 +01:00
HorizonCode ca15ff566f chore: bump version 2026-02-17 19:20:19 +01:00
HorizonCode a6fdda4487 fix: also use color-primary for pp stat 2026-02-17 19:19:09 +01:00
HorizonCode a30946eb60 chore: update animatedbg gradient background colors 2026-02-17 19:18:10 +01:00
HorizonCode 9106ad89b3 chore: replace all bg-pink and border-pink with bg-primary and border-primary 2026-02-17 19:15:38 +01:00
HorizonCode 194709df0c style: adjust primary color 2026-02-17 19:13:23 +01:00
HorizonCode 20f625be60 chore: improve line spreading alogrythm 2026-02-17 19:04:04 +01:00
HorizonCode 66a5fd84ae chore: set AnimatedBg globally 2026-02-17 19:02:37 +01:00
HorizonCode 44d7401947 chore: set AnimatedBg globally 2026-02-17 19:02:28 +01:00
HorizonCode f973e48561 chore: add border to view buttons 2026-02-17 18:58:16 +01:00
HorizonCode de186b7ff9 fix: change beatmapSets count icon 2026-02-17 18:55:45 +01:00
HorizonCode 244d526933 chore: make page switch transitions smoother 2026-02-17 18:52:41 +01:00
HorizonCode c7a990e3fe fix: show login result after redirect to home view 2026-02-17 18:46:12 +01:00
HorizonCode ed1f741a9e chore: remove all console.log 2026-02-17 17:43:05 +01:00
HorizonCode b6616a37f4 fix: isNet8Installed condition 2026-02-17 17:41:04 +01:00
HorizonCode 63433ac626 chore: update sileo 2026-02-17 17:40:46 +01:00
HorizonCode 149b4715d7 fix: updateUrl 2026-02-17 17:40:35 +01:00
HorizonCode ae3443d0b9 fix: osu running check 2026-02-16 21:53:53 +01:00
HorizonCode abc7b9d9d9 chore: replace sonner with sileo 2026-02-16 21:53:07 +01:00
HorizonCode d9dd21d3be chore: cleanup commands, fix osu folder check 2026-02-16 20:46:58 +01:00
HorizonCode 0df31178f4 fix: make count functions async to prevent frontend lock 2026-02-16 20:20:29 +01:00
HorizonCode 6825f9e99b fix: login box width, update releasestream and version 2026-02-15 19:08:04 +01:00
HorizonCode 08a8e34781 chore: add launch button states 2026-02-15 19:02:07 +01:00
HorizonCode 1aa973f6bb chore: bump version 2026-02-15 18:57:38 +01:00
HorizonCode 0999832985 fix: titlebar margin 2026-02-15 18:57:30 +01:00
HorizonCode 11798318f4 chore: only display user stats when logged in 2026-02-15 18:57:17 +01:00
HorizonCode f1b909e248 chore: make pp value better visible 2026-02-15 18:43:51 +01:00
HorizonCode 1f845f1282 chore: rework launcher design 2026-02-15 18:42:01 +01:00
HorizonCode f9b38ec9eb fix: improve error handling for network connection launch failures 2026-01-04 20:20:19 +01:00
HorizonCode 623559cf8e chore: update syn and other dependencies to latest versions 2026-01-04 20:08:26 +01:00
230 changed files with 50288 additions and 3322 deletions

No files matched your search

+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",
+11
View File
@@ -1,7 +1,18 @@
{
"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"
},
"json.schemaDownload.trustedDomains": {
"https://schemastore.azurewebsites.net/": true,
"https://raw.githubusercontent.com/": true,
"https://www.schemastore.org/": true,
"https://json.schemastore.org/": true,
"https://json-schema.org/": true,
"https://schema.tauri.app/": true
}
}
+175 -31
View File
@@ -1,7 +1,7 @@
# EZPPLauncher
**EZPPLauncher** is a custom launcher for the private osu! server [EZPPFarm](https://ez-pp.farm), built with **Rust** and **Svelte** using the [Tauri](https://tauri.app) framework.
It enhances the osu! experience with quality-of-life features and integration specifically designed for the EZPPFarm community.
**EZPPLauncher** is a modern, feature-rich custom launcher for the private osu! server [EZPPFarm](https://ez-pp.farm), built with **Rust** and **Svelte 5** using the [Tauri](https://tauri.app) framework.
It enhances the osu! experience with quality-of-life features, advanced launcher theming, customized Discord Rich Presence, and seamless server integration. Windows and macOS are supported, with partial support for Linux.
---
@@ -13,63 +13,207 @@ It enhances the osu! experience with quality-of-life features and integration sp
## 🚀 Features
- [x] Automatically updates osu! before launching
- [x] Automatic ingame login
- [x] Patches the osu! client to show misses in Relax and Autopilot
- [x] Displays your EZPPFarm stats in the launcher
- [x] Shows the number of imported beatmapsets
- [x] Shows the number of imported skins
- [x] Displays ping to the EZPPFarm server
- [x] Discord Rich Presence
- [x] Performance Display Overlay (currently in experimental patcher release stream)
### Game & Server Integration
- [x] **Direct Server Launch**: Launches osu! directly with `-devserver ez-pp.farm`, bypassing osu! updater loops and preserving launch parameters.
- [x] **Automatic In-Game Login**: Automatically signs into your EZPPFarm account upon game boot.
- [x] **In-Game Relax & Autopilot Miss Patcher**: Injects a hook into the osu! client to display misses in Relax and Autopilot modes (Windows, macOS via Wine, and partially on Linux via osu-winello).
- [x] **Live Player Stats**: Displays rank, PP, accuracy, playcount, and server latency directly on the launcher dashboard.
- [x] **Library Indexing**: Automatically counts imported beatmapsets and skins.
- [x] **OpenTabletDriver Integration**: Automatically starts and stops the OpenTabletDriver daemon on launch/exit (Windows only).
- [x] **Performance Display Overlay**: In-game HUD overlay (available in the experimental patcher stream).
### Visual Theme Engine v2 & In-App Studio
- [x] **Declarative Theme API 2.0**: Modern JSON schema supporting background videos, audio, Lottie animations, and layered widgets without custom compilation.
- [x] **Dedicated Visual Theme Editor**: Standalone resizable window with a full-featured visual canvas, live preview, and multi-element selection.
- [x] **Custom Svelte 5 Component Layers**: Embed custom reactive Svelte 5 components with an integrated **Monaco code editor** and live compiler diagnostics.
- [x] **Canvas Transform Tooling**: Multi-selection, snapping, freeform rotation, X/Y scale, skew, and group breadcrumb navigation.
- [x] **Logic & Scripting System**: Visual WHEN/IF/THEN rule builder and sandboxed rule scripting (expressions, branches, loops, visibility toggles) without `eval`.
- [x] **Dynamic Placeholders & Clock Expressions**: Real-time variables (`{username}`, `{rank}`, `{pp}`) and second-accurate clock variables (`{time}`, `{hour}`, `{minute}`).
- [x] **Typography & Icon Families**: Gradient fills, outlines, drop shadows, custom `@fontsource` webfonts, and bundled Lucide, Phosphor, and Tabler icon catalogs.
- [x] **Backup & Rollback System**: Automatic versioned theme snapshots with one-click restoration, plus security trust prompts for executable components.
### Discord Rich Presence Studio
- [x] **Dedicated Presence Editor Window**: In-app studio to customize your Discord presence.
- [x] **Interactive Discord Embed Preview**: Real-time visual mockup mirroring Discord's user profile popout card.
- [x] **Dynamic Placeholders**: Interpolate live stats (`{username}`, `{rank}`, `{pp}`, `{accuracy}`, `{playcount}`, `{version}`) into details, state, and asset tooltips.
- [x] **Custom Action Buttons & Presets**: Configurable buttons linking to player profiles, leaderboards, or custom URLs, plus quick presets (Default, Grinding, Competitive, Relax, AFK).
### Diagnostics, Logging & System Tools
- [x] **In-App Diagnostics Panel**: Validates osu! folder integrity, write permissions, Wine runtimes, .NET 8 desktop runtime, FFmpeg, and patcher status.
- [x] **Multi-Stream Log Viewer**: Dedicated rotating logs (`launcher.log`, `osu.log`, `patcher.log`) with session bundling, zip archive export, and an in-app viewer.
- [x] **macOS Wine Automation**: Automatic font registry sanitization (eliminating GDI+ `AccessViolationException` crashes), Wine prefix management, and automated in-place patcher bytecode patching for .NET 4.0 runtimes under Wine.
- [x] **Hardware-Accelerated Media Server**: Local HTTP range streaming server on macOS and Linux for smooth, low-memory WebM and MP4 video playback.
---
## 💻 Supported Platforms
| Platform | Status |
| -------- | ---------------------------------------- |
| Windows | ✅ Supported |
| macOS | ❌ Not supported |
| Linux | 🕧 Partially supported (via osu-winello) |
| Platform | Status | Runtime / Environment |
| -------- | ---------------------- | ---------------------------------------------------------- |
| Windows | ✅ Supported | Native Win32 |
| macOS | ✅ Supported | Wine wrapper (Wineskin, Whisky, CrossOver, or system Wine) |
| Linux | 🕧 Partially Supported | Wine / Proton (via osu-winello / wmctrl) |
> Currently, only **Windows** is fully supported. Support for other platforms may be considered in the future.
> **Windows** and **macOS** are fully supported. **Linux is partially supported**: osu! must be managed through external community tooling ([`osu-winello`](https://github.com/NelloKudo/osu-winello)), window tracking requires `wmctrl`, OpenTabletDriver automation is unavailable, and the launcher is restricted to the stable patcher stream.
---
### 🍎 macOS Integration Details
osu! stable is a 32-bit Windows application, so on macOS it runs inside a Wine-based wrapper. EZPPLauncher natively supports **Wineskin wrappers** (the standard `osu!.app` bundle), **Whisky bottles**, and **CrossOver bottles**.
- **Automatic Wrapper Detection**: The setup wizard automatically searches `/Applications` and `~/Applications` for existing Wineskin bundles and Wine prefixes. If choosing **Browse**, selecting the `osu!.app` bundle directly resolves the underlying prefix without needing to navigate inside "Show Package Contents".
- **Wine Prefix Font Fix**: macOS system font enumeration under Wine GDI+ can crash osu! on cold start with an `AccessViolationException` when reading Apple Braille fonts. EZPPLauncher automatically detects and cleans incompatible font entries from the prefix registry (`user.reg` / `system.reg`).
- **Updater Loop Prevention**: Abnormal game exits can leave behind `.require_update`, `help.txt`, or `_pending` folders that cause `osu!.exe` to boot into the Bancho updater and lose command-line parameters. The launcher purges these markers before launch to ensure the `-devserver ez-pp.farm` flag is always preserved.
- **Automated Patcher Bytecode Patching**: The Windows patcher (`osu!.patcher.exe`) targets .NET 4.5+ and embeds `ManagedInjector.Lib`. In Wine prefixes using the .NET Framework 4.0 runtime, generic `Marshal.SizeOf<T>()` and `Marshal.PtrToStructure<T>()` methods do not exist, and redirected standard I/O causes `Console.ReadKey()` crashes. EZPPLauncher automatically inspects and patches the patcher binary and its embedded Costura assemblies in-place, enabling seamless injection into the running game process under Wine.
- **Hardware-Accelerated Theme Media**: Large VP8/VP9/AV1 theme videos are automatically transcoded to hardware-decoded H.264/AAC. The launcher manages a sandboxed static FFmpeg build in `~/.ezpplauncher/bin` (with fallback to system FFmpeg). Theme assets are served over an internal loopback server supporting HTTP range requests to minimize memory consumption.
- **Native macOS Window Rounding**: Native Cocoa window clipping applies rounded corners and transparent borders to the Tauri window.
---
### 🐧 Linux (Partially Supported)
Linux support is functional but partial, relying on external community tools rather than built-in prefix management:
- **External Tooling Requirements**:
- **[`osu-winello`](https://github.com/NelloKudo/osu-winello)**: osu! must be installed and managed through osu-winello. EZPPLauncher detects the `osu-wine` binary in standard search locations (`/usr/bin`, `/usr/local/bin`, and `~/.local/bin`).
- **`wmctrl`**: Required for game process discovery and active window title detection under X11 and XWayland (`sudo pacman -S wmctrl` on Arch or `sudo apt install wmctrl` on Debian/Ubuntu).
- **Feature Limitations**:
- **OpenTabletDriver**: Automated daemon start/stop is Windows-only. On Linux, run OpenTabletDriver as a user systemd service (`systemctl --user enable --now opentabletdriver.service`).
- **Patcher Stream**: Linux is constrained to the **stable** patcher stream. The experimental stream (.NET 8 runtime) is not supported on Linux.
- **Theme Streaming via Loopback**: Tauri uses WebKit2GTK on Linux, which does not support byte-range streaming over custom application URI schemes. EZPPLauncher automatically spins up an internal loopback HTTP server to stream large video/audio theme assets with low memory overhead.
---
## 🎮 Discord Rich Presence Studio
The launcher features an interactive Discord Rich Presence editor accessible from **Settings → Discord Presence**:
- **Live Profile Mockup**: Visualizes your presence card exactly as it appears inside Discord.
- **Stat Placeholders**: Insert live interpolated values:
- `{username}`: Player display name
- `{rank}`: Current global rank
- `{pp}`: Total performance points
- `{accuracy}`: Overall hit accuracy percentage
- `{playcount}`: Account playcount
- `{version}`: Active launcher version
- **Custom Buttons**: Add interactive links (e.g. your EZPPFarm profile or twitch stream).
- **Presence Presets**: One-click presets for default launcher idle, grinding, competitive matches, and relax sessions.
---
## 🎨 Launcher Theming
The launcher supports two theme generations:
- **Theme API 2.0 (Recommended)**: A declarative format described in `theme.json` with assets, audio, Lottie animations, and layered elements.
- Can be built and edited visually with the **in-app Theme Editor**.
- Supports custom **Svelte 5 component layers** with Monaco editor integration for rich interactive widgets.
- Features visual WHEN/IF/THEN rule building or sandboxed JavaScript scripting.
- Exported as shareable `.ezpplauncher-theme` zip archives.
- **Theme API 1.0**: Standalone bundled Svelte themes compiled via the CLI.
For detailed documentation, schemas, and packaging guides, refer to [THEME_API_V2.md](THEME_API_V2.md).
---
## 🪵 Logs & Diagnostics
Output from the launcher, osu!, and the patcher is organized under `.ezpplauncher/logs` in your user home directory (`%USERPROFILE%\.ezpplauncher\logs` on Windows, `~/.ezpplauncher/logs` on macOS and Linux):
| File | Source |
| -------------- | ------------------------------------------------------------------------- |
| `launcher.log` | Launcher lifecycle, backend commands, webview console and uncaught errors |
| `osu.log` | osu! game client and updater stdout/stderr |
| `patcher.log` | Patcher stdout/stderr and injection diagnostic messages |
- **Session Archiving**: When a new session begins or when any active log file reaches 5 MiB, the active logs are bundled into a compressed `logs_<date>_<time>.zip` archive. The 5 most recent archives are retained.
- **Built-in Log Viewer**: Browse, search, and filter session logs in real time from **Settings → Logging**.
- **System Diagnostics**: Open **Settings → Diagnostics** to run an automated check on your osu! installation, folder permissions, Wine prefix configuration, .NET runtimes, FFmpeg transcoder, and patcher files, with one-click report copying for troubleshooting.
---
## 🛠 Build From Source
### Requirements
### Prerequisites
- **Rust** (installed via [rustup](https://rustup.rs/) – recommended for Windows)
- **Cargo** (comes with rustup)
- **[Rust & Cargo](https://rustup.rs/)** (Rust 2024 edition)
- **[Bun](https://bun.sh/)** (JavaScript runtime and package manager)
- **[CMake](https://cmake.org/download/)**
- **[Bun](https://bun.sh/)** (JavaScript runtime)
- Platform tooling:
- **Windows**: Visual Studio C++ Build Tools
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Linux**: WebKit2GTK, libsoup, and standard build-essential packages
### Steps
### Build Steps
```bash
# Install all dependencies
bun i
# 1. Clone the repository
git clone https://git.ez-pp.farm/EZPPFarm/EZPPLauncher.git
cd EZPPLauncher
# Build the project
# 2. Install dependencies
bun install
# 3. Type check and verify
bun run check
# 4. Build the release binaries
bun run build
```
After building, the launcher executable will be located at:
The compiled release packages will be generated at:
```
# Windows
src-tauri/target/release/ezpplauncher.exe
# macOS
src-tauri/target/release/bundle/macos/EZPPLauncher.app
src-tauri/target/release/bundle/dmg/EZPPLauncher_*.dmg
# Linux
src-tauri/target/release/bundle/deb/
src-tauri/target/release/bundle/appimage/
```
---
## 📁 Project Structure (Simplified)
## 📁 Project Structure (Overview)
```
├── src/ # Frontend (Svelte)
├── src-tauri/ # Backend (Rust)
│ ├── tauri.conf.json # Tauri configuration
├── package.json # Bun project config
EZPPLauncher/
├── src/ # Frontend application (Svelte 5, Tailwind CSS)
│ ├── lib/
│ │ ├── components/
│ │ │ ├── presence/ # Discord Rich Presence studio & preview
│ │ │ ├── theme/ # In-app Theme Editor & visual inspectors
│ │ │ └── ui/ # Reusable UI controls, cursor & dialogs
│ │ ├── themeV2.ts # Theme API 2.0 runtime & parsing
│ │ └── osuUtil.ts # Game folder detection & system utilities
│ ├── routes/ # SvelteKit routing & main app views
│ └── screens/ # Launcher dashboard, setup wizard & loading
├── src-tauri/ # Backend core (Rust, Tauri v2)
│ ├── src/
│ │ ├── commands.rs # Tauri IPC invoke commands
│ │ ├── logging.rs # Multi-stream rotating logger & zip bundler
│ │ ├── macos.rs # macOS Wine/Wineskin runtime & font fixes
│ │ ├── theme_server.rs # Internal HTTP range server for theme media
│ │ └── utils.rs # Patcher .NET 4.0 bytecode compatibility engine
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Main Tauri configuration
│ └── tauri.macos.conf.json # macOS bundle configuration
├── THEME_API_V2.md # Theme API 2.0 schema specification
├── package.json # Bun project dependencies & scripts
└── README.md
```
---
## 📄 License
This project is licensed under the [GNU General Public License v3.0](LICENSE.md).
+1413
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 157 KiB

+364 -363
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src\\app.css",
"css": "src/app.css",
"baseColor": "gray"
},
"aliases": {
@@ -12,5 +12,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/default"
"registry": "https://shadcn-svelte.com/registry"
}
+15 -2
View File
@@ -1,16 +1,20 @@
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));
export default ts.config(
includeIgnoreFile(gitignorePath),
{
// Tauri build output is ignored by src-tauri/.gitignore, not the root file.
ignores: ['src-tauri/target/**', 'src-tauri/gen/**'],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
@@ -36,5 +40,14 @@ export default ts.config(
svelteConfig,
},
},
},
{
rules: {
'no-empty': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^\\$\\$(Props|Events|Slots|Generic)$' },
],
},
}
);
+58 -45
View File
@@ -1,16 +1,17 @@
{
"name": "ezpplauncher",
"version": "3.0.10",
"version": "3.2.7",
"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",
"icons:build": "bun ./scripts/generate-theme-icons.ts",
"tauri": "tauri",
"tauri:update-logo": "bun run tauri icon ./static/logo.png",
"format": "prettier --write .",
@@ -18,59 +19,71 @@
},
"license": "GPL-3.0-only",
"dependencies": {
"@better-fetch/fetch": "^1.1.18",
"@fontsource/sora": "^5.2.6",
"@fontsource/space-mono": "^5.2.8",
"@number-flow/svelte": "^0.3.9",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-shell": "^2.3.3",
"@types/umami": "^2.10.1",
"animejs": "^4.1.3",
"@better-fetch/fetch": "^1.1.21",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/montserrat": "^5.3.0",
"@fontsource-variable/playfair-display": "^5.3.0",
"@fontsource-variable/sora": "^5.2.8",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/space-mono": "^5.2.9",
"@number-flow/svelte": "^0.4.0",
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"acorn": "^8.19.0",
"animejs": "^4.4.1",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"lucide-svelte": "^0.562.0",
"semver": "^7.7.2",
"jszip": "^3.10.1",
"lottie-web": "^5.13.0",
"monaco-editor": "^0.57.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"semver": "^7.8.1",
"sileo": "^0.1.5",
"svelte-confetti": "^2.3.2",
"tw-animate-css": "^1.3.8"
"torph": "^0.1.3",
"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": "^0.562.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.37.1",
"@sveltejs/vite-plugin-svelte": "^6.1.4",
"@tauri-apps/cli": "^2.9.6",
"@types/bun": "^1.2.21",
"@iconify-json/lucide": "^1.2.149",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/tabler": "^1.2.43",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@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",
"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",
"mode-watcher": "^1.1.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"sass-embedded": "^1.92.1",
"svelte": "^5.38.7",
"svelte-check": "^4.3.1",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.0",
"tailwindcss": "^4.1.7",
"tailwindcss-animate": "^1.0.7",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.0.1",
"sass-embedded": "^1.100.0",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.5",
"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']);
+142
View File
@@ -0,0 +1,142 @@
import { THEME_ICON_NAMES, type ThemeIconName, type ThemeIconSet } from '../src/lib/themeIcons';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
type IconifyCollection = {
width?: number;
height?: number;
icons: Record<string, { body: string; width?: number; height?: number }>;
aliases?: Record<string, { parent: string }>;
};
type IconBody = {
width: number;
height: number;
body: string;
};
/**
* Semantic name -> Iconify icon id per family. Keeping this explicit means a
* family swap can never pick a random similarly named icon from another set.
*/
const ICON_IDS: Record<ThemeIconName, Record<ThemeIconSet, string>> = {
home: { lucide: 'house', phosphor: 'house', tabler: 'home' },
themes: { lucide: 'palette', phosphor: 'palette', tabler: 'palette' },
settings: {
lucide: 'sliders-horizontal',
phosphor: 'sliders-horizontal',
tabler: 'adjustments-horizontal',
},
launch: { lucide: 'play', phosphor: 'play', tabler: 'player-play' },
user: { lucide: 'user', phosphor: 'user', tabler: 'user' },
music: { lucide: 'music', phosphor: 'music-notes', tabler: 'music' },
brush: { lucide: 'brush', phosphor: 'paint-brush', tabler: 'brush' },
wifi: { lucide: 'wifi', phosphor: 'wifi-high', tabler: 'wifi' },
'wifi-off': { lucide: 'wifi-off', phosphor: 'wifi-slash', tabler: 'wifi-off' },
gameplay: { lucide: 'gamepad-2', phosphor: 'game-controller', tabler: 'device-gamepad-2' },
integrations: { lucide: 'plug', phosphor: 'plugs', tabler: 'plug' },
installation: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
installed: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
diagnostics: { lucide: 'shield-check', phosphor: 'shield-check', tabler: 'shield-check' },
logs: { lucide: 'scroll-text', phosphor: 'scroll', tabler: 'file-text' },
'all-themes': { lucide: 'layout-grid', phosphor: 'squares-four', tabler: 'layout-grid' },
available: {
lucide: 'cloud-download',
phosphor: 'cloud-arrow-down',
tabler: 'cloud-download',
},
updates: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
search: { lucide: 'search', phosphor: 'magnifying-glass', tabler: 'search' },
folder: { lucide: 'folder-open', phosphor: 'folder-open', tabler: 'folder-open' },
plus: { lucide: 'plus', phosphor: 'plus', tabler: 'plus' },
'osu-standard': { lucide: 'circle', phosphor: 'circle', tabler: 'circle' },
'osu-taiko': { lucide: 'drum', phosphor: 'music-notes', tabler: 'music' },
'osu-catch': { lucide: 'cherry', phosphor: 'orange-slice', tabler: 'cherry' },
'osu-mania': { lucide: 'piano', phosphor: 'piano-keys', tabler: 'piano' },
login: { lucide: 'log-in', phosphor: 'sign-in', tabler: 'login' },
logout: { lucide: 'log-out', phosphor: 'sign-out', tabler: 'logout' },
warning: { lucide: 'triangle-alert', phosphor: 'warning', tabler: 'alert-triangle' },
info: { lucide: 'info', phosphor: 'info', tabler: 'info-circle' },
loader: { lucide: 'loader-circle', phosphor: 'circle-notch', tabler: 'loader' },
ellipsis: { lucide: 'ellipsis', phosphor: 'dots-three', tabler: 'dots' },
check: { lucide: 'check', phosphor: 'check', tabler: 'check' },
trash: { lucide: 'trash', phosphor: 'trash', tabler: 'trash' },
edit: { lucide: 'pencil', phosphor: 'pencil-simple', tabler: 'pencil' },
history: { lucide: 'history', phosphor: 'clock-counter-clockwise', tabler: 'history' },
refresh: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
download: { lucide: 'cloud-download', phosphor: 'cloud-arrow-down', tabler: 'cloud-download' },
import: { lucide: 'import', phosphor: 'upload-simple', tabler: 'upload' },
'arrow-right': { lucide: 'arrow-right', phosphor: 'arrow-right', tabler: 'arrow-right' },
eye: { lucide: 'eye', phosphor: 'eye', tabler: 'eye' },
'eye-off': { lucide: 'eye-off', phosphor: 'eye-slash', tabler: 'eye-off' },
wrench: { lucide: 'wrench', phosphor: 'wrench', tabler: 'tool' },
rotate: { lucide: 'rotate-cw', phosphor: 'arrows-clockwise', tabler: 'rotate-clockwise' },
volume: { lucide: 'volume-2', phosphor: 'speaker-high', tabler: 'volume' },
'volume-low': { lucide: 'volume-1', phosphor: 'speaker-low', tabler: 'volume-2' },
'volume-off': { lucide: 'volume-x', phosphor: 'speaker-slash', tabler: 'volume-off' },
};
const collectionPaths: Record<ThemeIconSet, string> = {
lucide: '../node_modules/@iconify-json/lucide/icons.json',
phosphor: '../node_modules/@iconify-json/ph/icons.json',
tabler: '../node_modules/@iconify-json/tabler/icons.json',
};
const loadCollection = (set: ThemeIconSet): IconifyCollection =>
JSON.parse(readFileSync(fileURLToPath(new URL(collectionPaths[set], import.meta.url)), 'utf8'));
const resolveIcon = (collection: IconifyCollection, id: string): IconBody | undefined => {
const direct = collection.icons[id];
if (direct) {
return {
width: direct.width ?? collection.width ?? 24,
height: direct.height ?? collection.height ?? 24,
body: direct.body,
};
}
const alias = collection.aliases?.[id];
if (!alias) return undefined;
const parent = collection.icons[alias.parent];
if (!parent) return undefined;
return {
width: parent.width ?? collection.width ?? 24,
height: parent.height ?? collection.height ?? 24,
body: parent.body,
};
};
const collections = Object.fromEntries(
(Object.keys(collectionPaths) as ThemeIconSet[]).map((set) => [set, loadCollection(set)])
) as Record<ThemeIconSet, IconifyCollection>;
const data = {} as Record<ThemeIconSet, Record<ThemeIconName, IconBody>>;
for (const set of Object.keys(collectionPaths) as ThemeIconSet[]) {
data[set] = {} as Record<ThemeIconName, IconBody>;
for (const name of THEME_ICON_NAMES) {
const id = ICON_IDS[name]?.[set];
if (!id) throw new Error(`No ${set} icon mapped for semantic name "${name}".`);
const icon = resolveIcon(collections[set], id);
if (!icon) throw new Error(`Icon "${id}" from ${set} was not found for "${name}".`);
data[set][name] = icon;
}
}
const output = `// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
export type ThemeIconBody = { width: number; height: number; body: string };
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = ${JSON.stringify(
data,
null,
2
)};
`;
const outputPath = fileURLToPath(new URL('../src/lib/themeIconData.ts', import.meta.url));
writeFileSync(outputPath, output);
console.log(
`Generated ${THEME_ICON_NAMES.length} semantic icons for ${Object.keys(collectionPaths).length} sets.`
);
+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"]
}
+1572 -575
View File
File diff suppressed because it is too large. Load diff
+54 -19
View File
@@ -1,12 +1,22 @@
[package]
name = "ezpplauncher"
version = "3.0.10"
version = "3.2.7"
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,33 +25,58 @@ name = "ezpplauncher_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
tauri = { version = "2.9.5", features = [] }
tauri-plugin-shell = "2.3.3"
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.148"
serde_json = "1.0.150"
serde_repr = "0.1.20"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.4"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
hardware-id = "0.3.0"
tauri-plugin-cors-fetch = "4.1.0"
sysinfo = "0.37.2"
reqwest = { version = "0.13.1", features = ["json", "stream", "query"] }
tauri-plugin-cors-fetch = "5.0.0"
sysinfo = "0.39.2"
reqwest = { version = "0.13.4", features = ["json", "stream", "query"] }
md5 = "0.8.0"
tokio = { version = "1.48.0", features = ["full"] }
open = "5.3.3"
windows-sys = "0.61.2"
discord-rich-presence = "1.0.0"
once_cell = "1.21.3"
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"
flate2 = "1.0"
tauri-plugin-log = "2"
log = "0.4"
time = { version = "0.3", features = ["local-offset"] }
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
http-body-util = "0.1.3"
http-range = "0.1.5"
hyper = { version = "1.8.1", features = ["server", "http1"] }
hyper-util = { version = "0.1.19", features = ["tokio"] }
percent-encoding = "2.3.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3"
[target.'cfg(windows)'.dependencies]
winreg = "0.55.0"
winapi = { version = "0.3", features = ["winuser", "wincrypt", "memoryapi", "winbase", "dpapi"] }
base64 = "0.22.1"
winreg = "0.56.0"
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.3.6"
tauri-plugin-single-instance = "2.4.2"
+98 -23
View File
@@ -1,8 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"description": "Capabilities for the main launcher and theme editor windows",
"windows": [
"main",
"theme-editor",
"presence-editor"
],
"permissions": [
"core:default",
"shell:allow-open",
@@ -12,6 +16,7 @@
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-destroy",
"cors-fetch:default",
"core:window:allow-hide",
"core:window:allow-show",
@@ -19,57 +24,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/**/*"
}
]
}
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 128 KiB

+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
+2352 -223
View File
File diff suppressed because it is too large. Load diff
+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()))
}
}
+180 -21
View File
@@ -1,45 +1,157 @@
// 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 config;
pub mod logging;
#[cfg(target_os = "macos")]
pub mod macos;
pub mod osudb;
pub mod presence;
pub mod state;
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub mod theme_server;
pub mod utils;
use crate::commands::{
check_for_corruption, download_ezpp_launcher_update, download_ezpp_launcher_update_files,
encrypt_string, exit, find_osu_installation, get_beatmapsets_count,
use crate::{
commands::{
backup_theme_folder, check_for_corruption, close_presence_editor, close_theme_editor, config_all, config_clear, config_delete,
config_exists, config_get, config_set, download_and_extract_theme,
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
has_wmctrl, install_ezpp_launcher_update, 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,
has_wmctrl, install_ezpp_launcher_update, installation_diagnostics,
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
presence_connect, presence_disconnect, presence_is_connected, presence_update_activity,
presence_update_button, presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
resolve_osu_folder, restore_theme_backup, reveal_window, run_open_tablet_driver, run_osu, run_osu_updater,
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
valid_osu_folder,
},
state::AppState,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
unsafe {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
let mut builder = tauri::Builder::default();
let mut builder = tauri::Builder::default().plugin(logging::plugin());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
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| {
log::info!(
target: "launcher",
"EZPPLauncher {} starting on {} ({}) — logs: {}",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
logging::logs_directory().display()
);
// ── Config ────────────────────────────────────────────────────────
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.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),
editor_theme_folder: Mutex::new(None),
});
// WebKit's custom-protocol media pipeline is unreliable (and
// loads large WebMs into memory), so Linux and macOS use a real
// loopback HTTP server with byte-range support instead.
#[cfg(any(target_os = "linux", target_os = "macos"))]
match commands::theme_root().and_then(|root| {
let media = theme_server::media_root()?;
theme_server::start(root, media)
}) {
Ok(_) => {}
Err(error) => log::warn!(
target: "launcher",
"Could not start the theme asset server: {error}"
),
}
#[cfg(target_os = "macos")]
if let Some(window) = app.get_webview_window("main") {
if let Err(error) = macos::round_window(&window, 12.0) {
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
}
if let Err(error) = macos::allow_media_autoplay(&window) {
log::warn!(target: "launcher", "{error}");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
opened_urls,
get_hwid,
find_osu_installation,
pick_osu_installation,
resolve_osu_folder,
valid_osu_folder,
get_beatmapsets_count,
get_skins_count,
get_osu_version,
get_osu_release_stream,
installation_diagnostics,
set_osu_config_values,
set_osu_user_config_values,
run_osu_updater,
@@ -53,18 +165,56 @@ pub fn run() {
get_launcher_version,
exit,
get_platform,
file_size,
check_for_corruption,
presence_connect,
presence_disconnect,
presence_update_status,
presence_update_user,
presence_update_button,
presence_update_activity,
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,
backup_theme_folder,
list_theme_backups,
restore_theme_backup,
extract_theme,
download_and_extract_theme,
save_theme_project,
export_theme,
open_theme_folder,
open_theme_editor,
focus_theme_editor,
close_theme_editor,
open_presence_editor,
focus_presence_editor,
close_presence_editor,
theme_editor_context,
theme_asset_base,
theme_preview_url,
optimize_theme_media,
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,
log_frontend,
set_logging_enabled,
open_logs_folder,
reveal_window,
logging::list_log_bundles,
logging::read_log_bundle
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
@@ -73,12 +223,21 @@ 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| {
// The content view can be replaced again while the webview is being
// built, so re-apply the corner mask once the app is ready.
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Ready = &event {
if let Some(window) = app.get_webview_window("main") {
if let Err(error) = macos::round_window(&window, 12.0) {
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
}
if let Err(error) = macos::allow_media_autoplay(&window) {
log::warn!(target: "launcher", "{error}");
}
}
}
presence::handle_run_event(app, &event);
});
}
+637
View File
@@ -0,0 +1,637 @@
//! Rotating file logs for the launcher, osu! and the patcher.
//!
//! Each stream writes to `~/.ezpplauncher/logs`:
//!
//! - `launcher.log` receives everything, including frontend console output.
//! - `osu.log` receives osu! and its updater, including stdout/stderr.
//! - `patcher.log` receives patcher output.
//!
//! A stream's file is only created when it actually has a line to write, so a
//! launch without osu!/patcher output never leaves empty files behind. When a
//! new session starts, or any stream reaches 5 MiB, all three active files are
//! bundled into one `logs_<date>_<time>.zip` and fresh files are started. The
//! five most recent bundles are kept, so a support report only needs one file.
//! `launcher.log` intentionally includes the osu!/patcher entries too. File
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use log::Level;
use serde::Serialize;
use tauri::Runtime;
use tauri::plugin::TauriPlugin;
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Child;
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
const KEPT_LOG_BUNDLES: usize = 5;
const MAX_LINE_CHARS: usize = 16_000;
static ENABLED: AtomicBool = AtomicBool::new(true);
pub fn set_logging_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
fn logging_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Read the persisted setting before the config database is initialized, so a
/// disabled logger does not write a startup file before the frontend loads.
fn initial_logging_enabled() -> bool {
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
return true;
};
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
if !db_path.exists() {
return true;
}
let Ok(connection) =
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
else {
return true;
};
let Ok(raw) = connection.query_row(
"SELECT value FROM config WHERE key = 'logging_enabled'",
[],
|row| row.get::<_, String>(0),
) else {
return true;
};
serde_json::from_str::<bool>(&raw).unwrap_or(true)
}
pub fn logs_directory() -> PathBuf {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".ezpplauncher").join("logs")
}
#[derive(Clone, Copy)]
enum Stream {
Launcher,
Osu,
Patcher,
}
impl Stream {
const ALL: [Stream; 3] = [Stream::Launcher, Stream::Osu, Stream::Patcher];
fn index(self) -> usize {
match self {
Stream::Launcher => 0,
Stream::Osu => 1,
Stream::Patcher => 2,
}
}
fn file_name(self) -> &'static str {
match self {
Stream::Launcher => "launcher.log",
Stream::Osu => "osu.log",
Stream::Patcher => "patcher.log",
}
}
}
/// Shared state for the three log files. Rotating any stream closes and
/// bundles all of them, so every bundle always holds the same session.
struct LogSet {
dir: PathBuf,
state: Mutex<LogState>,
}
struct LogState {
started: bool,
files: [Option<File>; 3],
sizes: [u64; 3],
}
impl LogSet {
fn new(dir: PathBuf) -> Self {
Self {
dir,
state: Mutex::new(LogState {
started: false,
files: [None, None, None],
sizes: [0; 3],
}),
}
}
fn write(&self, stream: Stream, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !state.started {
state.started = true;
bundle_active_logs(&self.dir)?;
prune_bundles(&self.dir)?;
}
let index = stream.index();
if state.files[index].is_none() {
self.open_stream(&mut state, stream)?;
}
if state.sizes[index] > 0
&& state.sizes[index].saturating_add(buf.len() as u64) > MAX_LOG_SIZE
{
self.rotate(&mut state, stream)?;
}
let file = state.files[index].as_mut().expect("stream log is open");
file.write_all(buf)?;
state.sizes[index] += buf.len() as u64;
Ok(buf.len())
}
fn flush(&self, stream: Stream) -> io::Result<()> {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
match state.files[stream.index()].as_mut() {
Some(file) => file.flush(),
None => Ok(()),
}
}
fn open_stream(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
fs::create_dir_all(&self.dir)?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(self.dir.join(stream.file_name()))?;
let index = stream.index();
state.sizes[index] = file.metadata()?.len();
state.files[index] = Some(file);
Ok(())
}
fn rotate(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
for file in state.files.iter_mut() {
if let Some(mut file) = file.take() {
let _ = file.flush();
}
}
bundle_active_logs(&self.dir)?;
prune_bundles(&self.dir)?;
let index = stream.index();
state.sizes[index] = 0;
self.open_stream(state, stream)
}
}
struct SessionWriter {
logs: Arc<LogSet>,
stream: Stream,
}
impl Write for SessionWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if !logging_enabled() {
return Ok(buf.len());
}
self.logs.write(self.stream, buf)
}
fn flush(&mut self) -> io::Result<()> {
if !logging_enabled() {
return Ok(());
}
self.logs.flush(self.stream)
}
}
/// Zip the active logs that have content and remove them afterwards.
fn bundle_active_logs(dir: &Path) -> io::Result<()> {
let mut entries = Vec::new();
for stream in Stream::ALL {
let path = dir.join(stream.file_name());
match path.metadata() {
Ok(metadata) if metadata.len() > 0 => {
entries.push((path, stream.file_name().to_string()));
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
if entries.is_empty() {
return Ok(());
}
fs::create_dir_all(dir)?;
let bundle_path = unique_bundle_path(dir);
let result = (|| -> io::Result<()> {
let file = File::create(&bundle_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, name) in &entries {
zip.start_file(name.clone(), options).map_err(|error| {
io::Error::other(format!("Could not add {name} to the log bundle: {error}"))
})?;
let mut source = File::open(path)?;
io::copy(&mut source, &mut zip)?;
}
zip.finish().map_err(|error| {
io::Error::other(format!("Could not finish the log bundle: {error}"))
})?;
Ok(())
})();
if let Err(error) = result {
let _ = fs::remove_file(&bundle_path);
return Err(error);
}
for (path, _) in entries {
fs::remove_file(path)?;
}
Ok(())
}
fn unique_bundle_path(dir: &Path) -> PathBuf {
let stamp = archive_timestamp();
let mut path = dir.join(format!("logs_{stamp}.zip"));
let mut suffix = 1;
while path.exists() {
path = dir.join(format!("logs_{stamp}-{suffix}.zip"));
suffix += 1;
}
path
}
fn archive_timestamp() -> String {
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
format!(
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second()
)
}
fn prune_bundles(dir: &Path) -> io::Result<()> {
let mut bundles = fs::read_dir(dir)?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
let file_name = path.file_name()?.to_str()?;
if !file_name.starts_with("logs_") || !file_name.ends_with(".zip") {
return None;
}
let modified = entry.metadata().ok()?.modified().ok()?;
Some((path, modified))
})
.collect::<Vec<_>>();
bundles.sort_by_key(|(_, modified)| *modified);
if bundles.len() > KEPT_LOG_BUNDLES {
for (path, _) in bundles.iter().take(bundles.len() - KEPT_LOG_BUNDLES) {
fs::remove_file(path)?;
}
}
Ok(())
}
fn file_target(set: &Arc<LogSet>, stream: Stream) -> Target {
let writer = SessionWriter {
logs: Arc::clone(set),
stream,
};
let dispatch = tauri_plugin_log::fern::Dispatch::new().chain(
tauri_plugin_log::fern::Output::writer(Box::new(writer), "\n"),
);
Target::new(TargetKind::Dispatch(dispatch))
}
/// Build the log plugin. File targets are filtered by logging target so osu!
/// and patcher output stay separate while still being copied to the main log.
pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
set_logging_enabled(initial_logging_enabled());
let logs_dir = logs_directory();
let set = Arc::new(LogSet::new(logs_dir));
let launcher = file_target(&set, Stream::Launcher);
let osu =
file_target(&set, Stream::Osu).filter(|metadata| metadata.target().starts_with("osu"));
let patcher = file_target(&set, Stream::Patcher)
.filter(|metadata| metadata.target().starts_with("patcher"));
tauri_plugin_log::Builder::new()
.targets([Target::new(TargetKind::Stdout), launcher, osu, patcher])
.level(if cfg!(debug_assertions) {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
})
.timezone_strategy(TimezoneStrategy::UseLocal)
.build()
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogBundleInfo {
pub path: String,
pub name: String,
pub size: u64,
pub modified: u64,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogBundleEntry {
pub name: String,
pub content: String,
}
/// List the rotating ZIP bundles, newest first, for the in-app log viewer.
#[tauri::command]
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
let dir = logs_directory();
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut bundles = fs::read_dir(&dir)
.map_err(|error| format!("Could not read the logs folder: {error}"))?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?.to_string();
if !name.starts_with("logs_") || !name.ends_with(".zip") {
return None;
}
let metadata = entry.metadata().ok()?;
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
Some(LogBundleInfo {
path: path.to_string_lossy().into_owned(),
name,
size: metadata.len(),
modified,
})
})
.collect::<Vec<_>>();
bundles.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(bundles)
}
/// Read the launcher/osu/patcher logs out of one bundle.
#[tauri::command]
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
let dir = logs_directory();
let requested = fs::canonicalize(&path)
.map_err(|error| format!("Could not open the log bundle: {error}"))?;
let root = fs::canonicalize(&dir)
.map_err(|error| format!("Could not resolve the logs folder: {error}"))?;
if !requested.starts_with(&root) {
return Err("The log bundle is outside the logs folder".to_string());
}
let file = File::open(&requested)
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|error| format!("Invalid log bundle: {error}"))?;
let mut entries = Vec::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
if !entry.name().ends_with(".log") {
continue;
}
let mut bytes = Vec::new();
entry
.by_ref()
.take(2 * 1024 * 1024)
.read_to_end(&mut bytes)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
let mut content = String::from_utf8_lossy(&bytes).into_owned();
if entry.size() > bytes.len() as u64 {
content.push_str("\n… log truncated in the viewer …\n");
}
entries.push(LogBundleEntry {
name: entry.name().to_string(),
content,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
/// Start reading a child's stdout/stderr and write every line to `target`.
pub fn capture_osu_output(child: &mut Child) {
capture_output(child, "osu");
}
/// Start reading a child's stdout/stderr and write every line to `target`.
pub fn capture_patcher_output(child: &mut Child) {
capture_output(child, "patcher");
}
fn capture_output(child: &mut Child, target: &'static str) {
if let Some(stdout) = child.stdout.take() {
stream_lines(stdout, "stdout", target);
}
if let Some(stderr) = child.stderr.take() {
stream_lines(stderr, "stderr", target);
}
}
fn stream_lines<R>(reader: R, stream: &'static str, target: &'static str)
where
R: AsyncRead + Unpin + Send + 'static,
{
tauri::async_runtime::spawn(async move {
let mut lines = BufReader::new(reader).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
let line = truncate(line.trim_end_matches(['\r', '\n']));
if !line.is_empty() {
log::log!(target: target, Level::Info, "[{stream}] {line}");
}
}
Ok(None) => break,
Err(error) => {
log::log!(
target: target,
Level::Warn,
"Could not read {stream} output: {error}"
);
break;
}
}
}
});
}
fn truncate(mut value: &str) -> String {
if value.chars().count() <= MAX_LINE_CHARS {
return value.to_string();
}
value = &value[..value
.char_indices()
.nth(MAX_LINE_CHARS)
.map(|(index, _)| index)
.unwrap_or(value.len())];
format!("{value}…")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn temp_dir(name: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ezpp-logging-{}-{name}-{id}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_line(logs: &Arc<LogSet>, stream: Stream, line: &str) {
let mut writer = SessionWriter {
logs: Arc::clone(logs),
stream,
};
writer.write_all(line.as_bytes()).unwrap();
writer.flush().unwrap();
}
#[test]
fn creates_file_only_after_first_line() {
let dir = temp_dir("lazy");
let logs = Arc::new(LogSet::new(dir.clone()));
assert!(!dir.join("osu.log").exists());
write_line(&logs, Stream::Osu, "[stdout] first\n");
assert_eq!(
fs::read_to_string(dir.join("osu.log")).unwrap(),
"[stdout] first\n"
);
assert!(!dir.join("launcher.log").exists());
assert!(!dir.join("patcher.log").exists());
drop(logs);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bundles_all_streams_when_a_new_session_starts() {
let dir = temp_dir("bundle");
let previous = Arc::new(LogSet::new(dir.clone()));
write_line(&previous, Stream::Launcher, "launcher previous\n");
write_line(&previous, Stream::Osu, "osu previous\n");
write_line(&previous, Stream::Patcher, "patcher previous\n");
drop(previous);
let current = Arc::new(LogSet::new(dir.clone()));
write_line(&current, Stream::Launcher, "launcher current\n");
// Active files only contain the new session.
assert_eq!(
fs::read_to_string(dir.join("launcher.log")).unwrap(),
"launcher current\n"
);
assert!(!dir.join("osu.log").exists());
assert!(!dir.join("patcher.log").exists());
let bundles = fs::read_dir(&dir)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zip"))
.collect::<Vec<_>>();
assert_eq!(bundles.len(), 1);
let file = File::open(&bundles[0]).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
assert_eq!(zip.len(), 3);
let mut names = (0..zip.len())
.map(|index| zip.by_index(index).unwrap().name().to_string())
.collect::<Vec<_>>();
names.sort();
assert_eq!(names, ["launcher.log", "osu.log", "patcher.log"]);
let mut launcher = String::new();
zip.by_name("launcher.log")
.unwrap()
.read_to_string(&mut launcher)
.unwrap();
assert_eq!(launcher, "launcher previous\n");
drop(zip);
drop(current);
fs::remove_dir_all(dir).unwrap();
}
}
File diff suppressed because it is too large. Load diff
+370
View File
@@ -0,0 +1,370 @@
use std::io::{Cursor, Read};
use anyhow::{Result, anyhow};
use byteorder::{LittleEndian, ReadBytesExt};
use std::collections::HashMap;
pub struct OsuReader {
cursor: Cursor<Vec<u8>>,
}
impl OsuReader {
pub fn new(data: Vec<u8>) -> Self {
Self {
cursor: Cursor::new(data),
}
}
fn read_i16(&mut self) -> Result<i16> {
Ok(self.cursor.read_i16::<LittleEndian>()?)
}
fn read_i32(&mut self) -> Result<i32> {
Ok(self.cursor.read_i32::<LittleEndian>()?)
}
fn read_i64(&mut self) -> Result<i64> {
Ok(self.cursor.read_i64::<LittleEndian>()?)
}
fn read_u8(&mut self) -> Result<u8> {
Ok(self.cursor.read_u8()?)
}
fn read_f32(&mut self) -> Result<f32> {
Ok(self.cursor.read_f32::<LittleEndian>()?)
}
fn read_f64(&mut self) -> Result<f64> {
Ok(self.cursor.read_f64::<LittleEndian>()?)
}
fn read_bool(&mut self) -> Result<bool> {
Ok(self.read_u8()? != 0)
}
fn read_string(&mut self) -> Result<Option<String>> {
let indicator = self.read_u8()?;
if indicator == 0 {
return Ok(None);
}
if indicator != 0x0B {
return Err(anyhow!("Invalid osu string indicator"));
}
let len = self.read_uleb128()? as usize;
let mut buf = vec![0; len];
self.cursor.read_exact(&mut buf)?;
Ok(Some(String::from_utf8(buf)?))
}
fn read_uleb128(&mut self) -> Result<u64> {
let mut result = 0;
let mut shift = 0;
loop {
let byte = self.read_u8()?;
result |= ((byte & 0x7F) as u64) << shift;
if (byte & 0x80) == 0 {
break;
}
shift += 7;
}
Ok(result)
}
}
#[derive(Debug)]
pub struct TimingPoint {
pub bpm: f64,
pub offset: f64,
pub inherited: bool,
}
#[derive(Debug)]
pub struct Beatmap {
pub artist_name: Option<String>,
pub artist_unicode: Option<String>,
pub song_title: Option<String>,
pub song_title_unicode: Option<String>,
pub creator: Option<String>,
pub difficulty: Option<String>,
pub audio: Option<String>,
pub md5: Option<String>,
pub osu_file_name: Option<String>,
pub ranked_status: u8,
pub hitcircles: i16,
pub slider_count: i16,
pub spinner_count: i16,
pub last_modification: i64,
pub approach_rate: f32,
pub circle_size: f32,
pub hp_drain: f32,
pub overall_difficulty: f32,
pub slider_velocity: f64,
pub difficulties: HashMap<i32, f64>,
pub drain_time: i32,
pub total_time: i32,
pub preview_offset: i32,
pub beatmap_id: i32,
pub beatmapset_id: i32,
pub thread_id: i32,
pub grade_standard: u8,
pub grade_taiko: u8,
pub grade_catch: u8,
pub grade_mania: u8,
pub local_beatmap_offset: i16,
pub stack_leniency: f32,
pub mode: u8,
pub song_source: Option<String>,
pub song_tags: Option<String>,
pub online_beatmap_offset: i16,
pub title_font: Option<String>,
pub unplayed: bool,
pub last_played: i64,
pub osz2: bool,
pub folder_name: Option<String>,
pub last_checked_against_repository: i64,
pub ignore_sound: bool,
pub ignore_skin: bool,
pub disable_storyboard: bool,
pub disable_video: bool,
pub visual_override: bool,
pub last_modification_time: i32,
pub mania_scroll_speed: u8,
pub timing_points: Vec<TimingPoint>,
}
#[derive(Debug)]
pub struct OsuDbData {
pub osuver: i32,
pub folder_count: i32,
pub is_unlocked: bool,
pub date_unlock_ticks: i64,
pub username: Option<String>,
pub beatmaps: Vec<Beatmap>,
pub userperms: i32,
}
//
// =========================
// PARSER
// =========================
//
pub fn parse_osudb(bytes: Vec<u8>) -> Result<OsuDbData> {
let mut reader = OsuReader::new(bytes);
let osuver = reader.read_i32()?;
let folder_count = reader.read_i32()?;
let is_unlocked = reader.read_bool()?;
let date_unlock_ticks = reader.read_i64()?;
let username = reader.read_string()?;
let beatmaps_count = reader.read_i32()?;
let mut beatmaps = Vec::new();
for _ in 0..beatmaps_count {
if osuver < 20191107 {
reader.read_i32()?; // entry size
}
let artist_name = reader.read_string()?;
let artist_unicode = reader.read_string()?;
let song_title = reader.read_string()?;
let song_title_unicode = reader.read_string()?;
let creator = reader.read_string()?;
let difficulty = reader.read_string()?;
let audio = reader.read_string()?;
let md5 = reader.read_string()?;
let osu_file_name = reader.read_string()?;
let ranked_status = reader.read_u8()?;
let hitcircles = reader.read_i16()?;
let slider_count = reader.read_i16()?;
let spinner_count = reader.read_i16()?;
let last_modification = reader.read_i64()?;
let approach_rate = if osuver < 20140609 {
reader.read_u8()? as f32
} else {
reader.read_f32()?
};
let circle_size = if osuver < 20140609 {
reader.read_u8()? as f32
} else {
reader.read_f32()?
};
let hp_drain = if osuver < 20140609 {
reader.read_u8()? as f32
} else {
reader.read_f32()?
};
let overall_difficulty = if osuver < 20140609 {
reader.read_u8()? as f32
} else {
reader.read_f32()?
};
let slider_velocity = reader.read_f64()?;
let mut difficulties: HashMap<i32, f64> = HashMap::new();
if osuver >= 20140609 {
for _ in 0..4 {
let len = reader.read_i32()?;
for _ in 0..len {
reader.read_u8()?;
let mode = reader.read_i32()?;
reader.read_u8()?;
let diff = if osuver > 20250107 {
reader.read_f32()? as f64
} else {
reader.read_f64()?
};
difficulties.insert(mode, diff);
}
}
}
let drain_time = reader.read_i32()?;
let total_time = reader.read_i32()?;
let preview_offset = reader.read_i32()?;
let timing_len = reader.read_i32()?;
let mut timing_points = Vec::new();
for _ in 0..timing_len {
timing_points.push(TimingPoint {
bpm: reader.read_f64()?,
offset: reader.read_f64()?,
inherited: reader.read_bool()?,
});
}
let beatmap_id = reader.read_i32()?;
let beatmapset_id = reader.read_i32()?;
let thread_id = reader.read_i32()?;
let grade_standard = reader.read_u8()?;
let grade_taiko = reader.read_u8()?;
let grade_catch = reader.read_u8()?;
let grade_mania = reader.read_u8()?;
let local_beatmap_offset = reader.read_i16()?;
let stack_leniency = reader.read_f32()?;
let mode = reader.read_u8()?;
let song_source = reader.read_string()?;
let song_tags = reader.read_string()?;
let online_beatmap_offset = reader.read_i16()?;
let title_font = reader.read_string()?;
let unplayed = reader.read_bool()?;
let last_played = reader.read_i64()?;
let osz2 = reader.read_bool()?;
let folder_name = reader.read_string()?;
let last_checked_against_repository = reader.read_i64()?;
let ignore_sound = reader.read_bool()?;
let ignore_skin = reader.read_bool()?;
let disable_storyboard = reader.read_bool()?;
let disable_video = reader.read_bool()?;
let visual_override = reader.read_bool()?;
if osuver < 20140609 {
reader.read_i16()?;
}
let last_modification_time = reader.read_i32()?;
let mania_scroll_speed = reader.read_u8()?;
beatmaps.push(Beatmap {
artist_name,
artist_unicode,
song_title,
song_title_unicode,
creator,
difficulty,
audio,
md5,
osu_file_name,
ranked_status,
hitcircles,
slider_count,
spinner_count,
last_modification,
approach_rate,
circle_size,
hp_drain,
overall_difficulty,
slider_velocity,
difficulties,
drain_time,
total_time,
preview_offset,
beatmap_id,
beatmapset_id,
thread_id,
grade_standard,
grade_taiko,
grade_catch,
grade_mania,
local_beatmap_offset,
stack_leniency,
mode,
song_source,
song_tags,
online_beatmap_offset,
title_font,
unplayed,
last_played,
osz2,
folder_name,
last_checked_against_repository,
ignore_sound,
ignore_skin,
disable_storyboard,
disable_video,
visual_override,
last_modification_time,
mania_scroll_speed,
timing_points,
});
}
let userperms = reader.read_i32()?;
Ok(OsuDbData {
osuver,
folder_count,
is_unlocked,
date_unlock_ticks,
username,
beatmaps,
userperms,
})
}
+285 -96
View File
@@ -1,14 +1,20 @@
use discord_rich_presence::{
DiscordIpc, DiscordIpcClient,
activity::{Activity, Assets, Button, Timestamps},
};
use once_cell::sync::Lazy;
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)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PresenceButton {
pub label: String,
pub url: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PresenceData {
pub state: String,
pub details: String,
@@ -16,6 +22,31 @@ 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>,
pub secondary_button: Option<PresenceButton>,
pub show_timestamp: bool,
}
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: Some(PresenceButton {
label: "Download the Launcher".to_string(),
url: "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest".to_string(),
}),
secondary_button: Some(PresenceButton {
label: "Join EZPPFarm".to_string(),
url: "https://ez-pp.farm/discord".to_string(),
}),
show_timestamp: true,
}
}
}
#[derive(Debug)]
@@ -26,11 +57,19 @@ enum PresenceCommand {
IsConnected(oneshot::Sender<bool>),
}
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 {
@@ -40,137 +79,197 @@ 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_millis(2500));
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::IsConnected(resp) => {
let _ = resp.send(self.client.is_some());
}
}
}
_ = update_interval.tick() => {
if self.client.is_some() {
self.handle_update().await;
Some(result) = self.reconnect_rx.recv() => {
self.reconnecting = false;
match result {
ReconnectResult::Connected(client) => {
log::info!(target: "launcher", "Presence: reconnect succeeded");
self.client = Some(client);
self.push_presence().await;
}
ReconnectResult::Failed => {
log::warn!(target: "launcher", "Presence: all reconnect attempts exhausted");
}
}
}
_ = heartbeat.tick() => {
self.push_presence().await;
}
}
}
}
async fn handle_connect(&mut self, responder: oneshot::Sender<bool>) {
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 create Discord client: {:?}", e);
let _ = responder.send(false);
None => {
log::warn!(
target: "launcher",
"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...");
let _ = client.clear_activity().map_err(|e| e.to_string());
let _ = client.close().map_err(|e| e.to_string());
println!("Actor: Disconnected successfully.");
let _ = client.clear_activity().await;
log::info!(target: "launcher", "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)
.large_image(&self.data.large_image_key)
.large_text(&self.data.large_image_text);
if self.data.show_timestamp {
builder = builder.start_timestamp(self.start_timestamp as u64);
}
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(
"Download the Launcher",
"https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest",
),
Button::new("Join EZPPFarm", "https://ez-pp.farm/discord"),
]);
if let Some(btn) = &self.data.dynamic_button {
if !btn.label.trim().is_empty() && !btn.url.trim().is_empty() {
builder = builder.button(&btn.label, &btn.url);
}
}
if let Some(btn2) = &self.data.secondary_button {
if !btn2.label.trim().is_empty() && !btn2.url.trim().is_empty() {
builder = builder.button(&btn2.label, &btn2.url);
}
}
if let Err(e) = client.set_activity(activity).map_err(|e| e.to_string()) {
eprintln!("Failed to set activity, disconnecting: {:?}", e);
if let Some(mut client) = self.client.take() {
let _ = client.clear_activity();
let _ = client.close();
let activity = builder.build();
if let Err(e) = client.set_activity(&activity).await {
log::warn!(
target: "launcher",
"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);
log::info!(
target: "launcher",
"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(_) => {
log::info!(target: "launcher", "Presence: IPC connected");
Some(client)
}
Err(e) => {
log::warn!(target: "launcher", "Presence: IPC connect error: {:?}", e);
None
}
},
Err(e) => {
log::warn!(target: "launcher", "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();
@@ -187,9 +286,7 @@ pub async fn disconnect() {
.await
.is_ok()
{
let _ = rx.await;
} else {
println!("Could not send disconnect command; actor may not be running.");
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
}
}
@@ -205,31 +302,123 @@ pub async fn has_presence() -> bool {
false
}
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
let mut data = PRESENCE_DATA.lock().unwrap();
pub fn update_activity(
state: Option<&str>,
details: Option<&str>,
large_image_key: Option<&str>,
large_image_text: Option<&str>,
small_image_key: Option<Option<String>>,
small_image_text: Option<Option<String>>,
button1: Option<Option<PresenceButton>>,
button2: Option<Option<PresenceButton>>,
show_timestamp: Option<bool>,
) {
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();
if let Some(txt) = large_image_text {
guard.large_image_text = txt.to_string();
}
if let Some(key) = small_image_key {
guard.small_image_key = key;
}
if let Some(text) = small_image_text {
guard.small_image_text = text;
}
if let Some(b1) = button1 {
guard.dynamic_button = b1;
}
if let Some(b2) = button2 {
guard.secondary_button = b2;
}
if let Some(ts) = show_timestamp {
guard.show_timestamp = ts;
}
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_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
if let Some(s) = state {
guard.state = s.to_string();
}
if let Some(d) = details {
guard.details = d.to_string();
}
if let Some(img) = large_image_key {
guard.large_image_key = img.to_string();
}
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
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;
});
}
}
+11
View File
@@ -0,0 +1,11 @@
// 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>>,
/// Theme folder requested by the main window when it opens the editor.
pub editor_theme_folder: Mutex<Option<String>>,
}
+502
View File
@@ -0,0 +1,502 @@
//! Loopback HTTP server for theme assets on Linux and macOS.
//!
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
//! protocol, and WKWebView's range handling makes large media stall there.
//! A real loopback HTTP server gives WebKit normal status codes, range
//! responses and MIME types, so v1 and v2 theme assets load the same way they
//! do in a browser without loading a whole video into a blob first.
use http_body_util::Full;
use http_range::HttpRange;
use hyper::body::{Bytes, Incoming};
use hyper::header::{
ACCEPT_RANGES, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ORIGIN, RANGE, VARY,
};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
use tokio::net::TcpListener;
type Body = Full<Bytes>;
/// Keep individual responses small enough that a media seek cannot allocate
/// an unbounded buffer. WebKit asks for the next range as it plays.
const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
struct ThemeServer {
port: u16,
root: PathBuf,
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
}
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
/// Bind the server on a random loopback port. The root is the themes folder.
pub fn start(root: PathBuf, media_root: PathBuf) -> Result<u16, String> {
if let Some(server) = SERVER.get() {
return Ok(server.port);
}
let root = std::fs::canonicalize(&root)
.map_err(|error| format!("Could not resolve the themes folder: {error}"))?;
let media_root = std::fs::canonicalize(&media_root)
.map_err(|error| format!("Could not resolve the theme media cache: {error}"))?;
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("Could not configure the theme asset server: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("Could not read the theme asset server address: {error}"))?
.port();
let previews = Arc::new(Mutex::new(HashMap::new()));
let server = ThemeServer {
port,
root: root.clone(),
previews: previews.clone(),
};
let _ = SERVER.set(server);
let root = Arc::new(root);
let media_root = Arc::new(media_root);
tauri::async_runtime::spawn(async move {
let listener = match TcpListener::from_std(listener) {
Ok(listener) => listener,
Err(error) => {
log::error!(target: "launcher", "Theme asset server failed: {error}");
return;
}
};
log::info!(
target: "launcher",
"Theme asset server listening on http://127.0.0.1:{port}/themes"
);
loop {
let (stream, _) = match listener.accept().await {
Ok(connection) => connection,
Err(error) => {
log::debug!(target: "launcher", "Theme asset server accept error: {error}");
continue;
}
};
let root = root.clone();
let media_root = media_root.clone();
let previews = previews.clone();
tokio::spawn(async move {
let service = service_fn(move |request| {
handle(request, root.clone(), media_root.clone(), previews.clone())
});
if let Err(error) = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await
{
log::debug!(target: "launcher", "Theme asset connection ended: {error}");
}
});
}
});
Ok(port)
}
pub fn base_url() -> Option<String> {
SERVER
.get()
.map(|server| format!("http://127.0.0.1:{}/themes", server.port))
}
/// The cache folder for optimized theme media, next to the themes folder.
pub fn media_root() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Could not locate the home directory".to_string())?;
let root = PathBuf::from(home)
.join(".ezpplauncher")
.join("media-cache");
std::fs::create_dir_all(&root)
.map_err(|error| format!("Failed to create the theme media cache: {error}"))?;
std::fs::canonicalize(&root)
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
}
/// Map a loopback theme URL back to the file it serves. Installed theme files
/// are restricted to the themes folder; editor preview URLs must match a token
/// registered through `preview_url`.
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
let server = SERVER.get()?;
let url = url::Url::parse(value).ok()?;
if url.scheme() != "http"
|| url.host_str() != Some("127.0.0.1")
|| url.port() != Some(server.port)
{
return None;
}
let path = percent_encoding::percent_decode_str(url.path())
.decode_utf8_lossy()
.to_string();
if let Some(relative) = path.strip_prefix("/themes/") {
return safe_path(&server.root, relative);
}
let token = path.strip_prefix("/preview/")?;
server
.previews
.lock()
.ok()
.and_then(|previews| previews.get(token).cloned())
}
/// Public URL for a file in the optimized media cache.
pub fn media_url(file_name: &str) -> Option<String> {
let server = SERVER.get()?;
Some(format!(
"http://127.0.0.1:{}/media/{}",
server.port, file_name
))
}
/// Register a file picked in the theme editor so its preview can be served
/// over HTTP too. The token is only a lookup key; unregistered paths are never
/// readable through the server.
pub fn preview_url(path: &Path) -> Result<String, String> {
let server = SERVER
.get()
.ok_or_else(|| "The theme asset server is not running".to_string())?;
let path = std::fs::canonicalize(path)
.map_err(|error| format!("Could not resolve the preview asset: {error}"))?;
if !path.is_file() {
return Err("The preview asset is not a file".to_string());
}
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
let token = hex::encode(&hasher.finalize()[..16]);
server
.previews
.lock()
.map_err(|_| "Could not register the preview asset".to_string())?
.insert(token.clone(), path);
Ok(format!("http://127.0.0.1:{}/preview/{token}", server.port))
}
async fn handle(
request: Request<Incoming>,
root: Arc<PathBuf>,
media_root: Arc<PathBuf>,
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
) -> Result<Response<Body>, Infallible> {
let origin = request
.headers()
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.and_then(allowed_origin);
if request.method() == Method::OPTIONS {
return Ok(preflight_response(origin.as_deref()));
}
if request.method() != Method::GET && request.method() != Method::HEAD {
return Ok(text_response(
StatusCode::METHOD_NOT_ALLOWED,
"Method not allowed",
origin.as_deref(),
));
}
let range = request
.headers()
.get(RANGE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let path = percent_encoding::percent_decode_str(request.uri().path())
.decode_utf8_lossy()
.to_string();
log::debug!(
target: "launcher",
"Theme asset request: {path} range={range:?}"
);
let file = if let Some(relative) = path.strip_prefix("/themes/") {
safe_path(&root, relative)
} else if let Some(relative) = path.strip_prefix("/media/") {
safe_path(&media_root, relative)
} else if let Some(token) = path.strip_prefix("/preview/") {
previews
.lock()
.ok()
.and_then(|previews| previews.get(token).cloned())
} else {
None
};
let response = match file {
Some(file) => {
serve_file(request.method(), &file, range.as_deref(), origin.as_deref()).await
}
None => Ok(text_response(
StatusCode::NOT_FOUND,
"Not found",
origin.as_deref(),
)),
};
Ok(response.unwrap_or_else(|message| {
log::warn!(target: "launcher", "Theme asset request failed: {message}");
text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&message,
origin.as_deref(),
)
}))
}
fn safe_path(root: &Path, relative: &str) -> Option<PathBuf> {
let relative = relative.trim_start_matches('/');
if relative.is_empty()
|| relative
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return None;
}
let candidate = root.join(relative);
let canonical = std::fs::canonicalize(candidate).ok()?;
canonical.starts_with(root).then_some(canonical)
}
async fn serve_file(
method: &Method,
path: &Path,
range_header: Option<&str>,
origin: Option<&str>,
) -> Result<Response<Body>, String> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|error| format!("Could not read theme asset: {error}"))?;
if !metadata.is_file() {
return Ok(text_response(StatusCode::NOT_FOUND, "Not found", origin));
}
let length = metadata.len();
let range = match range_header {
Some(header) => match parse_range(header, length) {
Ok(range) => range,
Err(()) => return Ok(range_not_satisfiable(length, origin)),
},
None => None,
};
let (status, start, end) = match range {
Some((start, end)) => (StatusCode::PARTIAL_CONTENT, start, end),
None => (StatusCode::OK, 0, length.saturating_sub(1)),
};
let body_length = if length == 0 {
0
} else {
end.saturating_sub(start) + 1
};
let body = if method == Method::HEAD || body_length == 0 {
Vec::new()
} else {
let mut file = tokio::fs::File::open(path)
.await
.map_err(|error| format!("Could not open theme asset: {error}"))?;
if start > 0 {
file.seek(SeekFrom::Start(start))
.await
.map_err(|error| format!("Could not seek theme asset: {error}"))?;
}
let mut buffer = vec![0_u8; body_length as usize];
file.read_exact(&mut buffer)
.await
.map_err(|error| format!("Could not read theme asset: {error}"))?;
buffer
};
let mut builder = Response::builder()
.status(status)
.header(CONTENT_TYPE, content_type(path))
.header(CONTENT_LENGTH, body_length.to_string())
.header(ACCEPT_RANGES, "bytes")
.header("Cache-Control", "no-store")
.header("X-Content-Type-Options", "nosniff")
.header("Cross-Origin-Resource-Policy", "cross-origin");
if status == StatusCode::PARTIAL_CONTENT {
builder = builder.header(CONTENT_RANGE, format!("bytes {start}-{end}/{length}"));
}
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin")
.header(
ACCESS_CONTROL_EXPOSE_HEADERS,
"Content-Range, Content-Length, Accept-Ranges",
);
}
builder
.body(Full::new(body.into()))
.map_err(|error| format!("Could not build theme asset response: {error}"))
}
fn parse_range(header: &str, length: u64) -> Result<Option<(u64, u64)>, ()> {
if length == 0 {
return Ok(None);
}
let ranges = HttpRange::parse(header, length).map_err(|_| ())?;
if ranges.len() != 1 {
return Err(());
}
let range = &ranges[0];
let start = range.start;
let end = (start + range.length - 1).min(length - 1);
if start >= length || end < start {
return Err(());
}
// Don't allocate the entire file for a single open-ended range request.
Ok(Some((start, end.min(start + MAX_RANGE_BYTES - 1))))
}
fn range_not_satisfiable(length: u64, origin: Option<&str>) -> Response<Body> {
let mut builder = Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(CONTENT_RANGE, format!("bytes */{length}"))
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(CONTENT_LENGTH, "0");
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin");
}
builder
.body(Full::new(Bytes::new()))
.expect("range response is valid")
}
fn preflight_response(origin: Option<&str>) -> Response<Body> {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
.header(
ACCESS_CONTROL_EXPOSE_HEADERS,
"Content-Range, Content-Length, Accept-Ranges",
)
.header(ACCESS_CONTROL_MAX_AGE, "600");
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin");
}
builder
.body(Full::new(Bytes::new()))
.expect("preflight response is valid")
}
fn text_response(status: StatusCode, message: &str, origin: Option<&str>) -> Response<Body> {
let mut builder = Response::builder()
.status(status)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(CONTENT_LENGTH, message.len().to_string())
.header("X-Content-Type-Options", "nosniff");
if let Some(origin) = origin {
builder = builder
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
.header(VARY, "Origin");
}
builder
.body(Full::new(Bytes::copy_from_slice(message.as_bytes())))
.expect("text response is valid")
}
fn allowed_origin(origin: &str) -> Option<String> {
if origin == "tauri://localhost"
|| origin == "http://tauri.localhost"
|| origin == "https://tauri.localhost"
|| origin == "null"
|| origin.starts_with("http://localhost:")
|| origin.starts_with("http://127.0.0.1:")
{
Some(origin.to_string())
} else {
None
}
}
fn content_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"webm" => "video/webm",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"ogv" => "video/ogg",
"mp3" => "audio/mpeg",
"ogg" | "oga" => "audio/ogg",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"flac" => "audio/flac",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
+558 -36
View File
@@ -1,7 +1,112 @@
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use sysinfo::Pid;
pub const OSU_FOLDER_FILES: &[&str] = &[
"avcodec-51.dll",
"avformat-52.dll",
"avutil-49.dll",
"bass.dll",
"bass_fx.dll",
"collection.db",
"d3dcompiler_47.dll",
"libEGL.dll",
"libGLESv2.dll",
"Microsoft.Ink.dll",
"OpenTK.dll",
"osu!.cfg",
"osu!.db",
"osu!.exe",
"osu!auth.dll",
"osu!gameplay.dll",
"osu!seasonal.dll",
"osu!ui.dll",
"presence.db",
"pthreadGC2.dll",
"scores.db",
];
pub fn is_valid_osu_folder<P: AsRef<Path>>(folder_path: P) -> bool {
let folder_path = folder_path.as_ref();
folder_path.join("osu!.exe").is_file()
&& check_folder_completeness(folder_path, OSU_FOLDER_FILES) >= 70.0
}
/// True if the process is osu! (either running natively or through Wine/Proton).
pub fn is_osu_process(process: &sysinfo::Process) -> bool {
let name = process.name().to_string_lossy().to_ascii_lowercase();
if name.ends_with("osu!.exe") {
return true;
}
if let Some(exe) = process.exe() {
if exe
.file_name()
.map(|file_name| file_name.to_string_lossy().to_ascii_lowercase() == "osu!.exe")
.unwrap_or(false)
{
return true;
}
}
process.cmd().iter().any(|arg| {
arg.to_string_lossy()
.to_ascii_lowercase()
.contains("osu!.exe")
})
}
/// Like [`is_osu_process`], but excludes the osu! updater started with `-repair`.
pub fn is_osu_game_process(process: &sysinfo::Process) -> bool {
is_osu_process(process)
&& !process.cmd().iter().any(|arg| {
arg.to_string_lossy()
.to_ascii_lowercase()
.contains("-repair")
})
}
fn find_osu_user_config_path<P: AsRef<Path>>(folder_path: P) -> Option<PathBuf> {
let folder_path = folder_path.as_ref();
if !folder_path.is_dir() {
return None;
}
if let Ok(current_user) = std::env::var("USERNAME").or_else(|_| std::env::var("USER")) {
let config_path = folder_path.join(format!("osu!.{}.cfg", current_user));
if config_path.is_file() {
return Some(config_path);
}
}
// The Windows username inside a Wine prefix does not always match the host
// username (especially on macOS), so fall back to the most recent osu!.*.cfg.
let mut candidates = fs::read_dir(folder_path)
.ok()?
.flatten()
.filter_map(|entry| {
let path = entry.path();
let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
if !file_name.starts_with("osu!.") || !file_name.ends_with(".cfg") {
return None;
}
if file_name == "osu!.cfg" {
return None;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
Some((modified, path))
})
.collect::<Vec<_>>();
candidates.sort_by(|a, b| b.0.cmp(&a.0));
candidates.into_iter().next().map(|(_, path)| path)
}
pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files: &[&str]) -> f32 {
let mut found = 0;
for file in required_files {
@@ -20,17 +125,7 @@ pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files:
pub fn get_osu_user_config<P: AsRef<Path>>(
osu_folder_path: P,
) -> Option<std::collections::HashMap<String, String>> {
if !osu_folder_path.as_ref().exists() {
return None;
}
let current_user = std::env::var("USERNAME").unwrap_or_else(|_| "Admin".to_string());
let osu_config_path = osu_folder_path
.as_ref()
.join(format!("osu!.{}.cfg", current_user));
if !osu_config_path.exists() {
return None;
}
let osu_config_path = find_osu_user_config_path(osu_folder_path)?;
let mut config_map = std::collections::HashMap::new();
if let Ok(contents) = std::fs::read_to_string(osu_config_path) {
@@ -41,19 +136,15 @@ pub fn get_osu_user_config<P: AsRef<Path>>(
}
}
return Some(config_map);
Some(config_map)
}
pub fn set_osu_user_config_vals(
osu_folder_path: &str,
key_values: &[(&str, Option<&str>)],
) -> Result<bool, String> {
let current_user = std::env::var("USERNAME").unwrap_or_else(|_| "Admin".to_string());
let osu_config_path = Path::new(osu_folder_path).join(format!("osu!.{}.cfg", current_user));
if !osu_config_path.exists() {
return Err("osu! user config file does not exist".to_string());
}
let osu_config_path = find_osu_user_config_path(osu_folder_path)
.ok_or_else(|| "osu! user config file does not exist".to_string())?;
let mut lines = fs::read_to_string(&osu_config_path)
.map_err(|e| e.to_string())?
@@ -173,23 +264,47 @@ pub fn get_osu_config<P: AsRef<Path>>(
return Some(config_map);
}
#[cfg(not(windows))]
pub fn is_osuwinello_available() -> bool {
use std::process::Command;
Command::new("osu-wine")
.arg("--info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
#[cfg(target_os = "linux")]
pub fn get_osuwinello_path() -> Option<PathBuf> {
let mut search_paths =
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect::<Vec<_>>();
for path in ["/usr/local/bin", "/usr/bin", "/bin"] {
let path = PathBuf::from(path);
if !search_paths.contains(&path) {
search_paths.push(path);
}
}
#[cfg(windows)]
if let Some(home) = std::env::var_os("HOME") {
let path = Path::new(&home).join(".local/bin");
if !search_paths.contains(&path) {
search_paths.push(path);
}
}
search_paths
.into_iter()
.map(|path| path.join("osu-wine"))
.find(|path| path.is_file())
}
#[cfg(not(target_os = "linux"))]
pub fn get_osuwinello_path() -> Option<PathBuf> {
None
}
#[cfg(target_os = "linux")]
pub fn is_osuwinello_available() -> bool {
get_osuwinello_path().is_some()
}
#[cfg(not(target_os = "linux"))]
pub fn is_osuwinello_available() -> bool {
false
}
#[cfg(not(windows))]
#[cfg(target_os = "linux")]
pub fn is_wmctrl_available() -> bool {
use std::process::Command;
Command::new("wmctrl")
@@ -200,12 +315,12 @@ pub fn is_wmctrl_available() -> bool {
.is_ok()
}
#[cfg(windows)]
#[cfg(not(target_os = "linux"))]
pub fn is_wmctrl_available() -> bool {
false
}
#[cfg(not(windows))]
#[cfg(target_os = "linux")]
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
use std::process::Command;
let find_title = || -> Option<String> {
@@ -237,6 +352,200 @@ pub fn get_window_title_by_pid(target_pid: Pid) -> String {
find_title().unwrap_or_default()
}
#[cfg(target_os = "macos")]
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
use objc2::msg_send;
use objc2::runtime::AnyObject;
use std::ffi::CStr;
#[repr(C)]
struct CGPoint {
x: f64,
y: f64,
}
#[repr(C)]
struct CGSize {
width: f64,
height: f64,
}
#[repr(C)]
struct CGRect {
origin: CGPoint,
size: CGSize,
}
unsafe extern "C" {
fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> *mut AnyObject;
fn CGRectMakeWithDictionaryRepresentation(
dict: *const AnyObject,
rect: *mut CGRect,
) -> bool;
}
unsafe {
// kCGWindowListOptionAll = 0, kCGNullWindowID = 0
let window_list: *mut AnyObject = CGWindowListCopyWindowInfo(0, 0);
if window_list.is_null() {
return String::new();
}
let count: usize = msg_send![window_list, count];
let pid_key: *mut AnyObject = msg_send![
objc2::class!(NSString),
stringWithUTF8String: c"kCGWindowOwnerPID".as_ptr()
];
let name_key: *mut AnyObject = msg_send![
objc2::class!(NSString),
stringWithUTF8String: c"kCGWindowName".as_ptr()
];
let owner_key: *mut AnyObject = msg_send![
objc2::class!(NSString),
stringWithUTF8String: c"kCGWindowOwnerName".as_ptr()
];
let bounds_key: *mut AnyObject = msg_send![
objc2::class!(NSString),
stringWithUTF8String: c"kCGWindowBounds".as_ptr()
];
let layer_key: *mut AnyObject = msg_send![
objc2::class!(NSString),
stringWithUTF8String: c"kCGWindowLayer".as_ptr()
];
let target_u32 = target_pid.as_u32();
// Pass 1: Try exact target_pid match.
// Pass 2: If target_pid produced no onscreen windows, check Wine/osu! owner windows
// (handles Wine 32-on-64 architecture where wine32on64 delegates window creation to wine64-preloader).
for fallback_pass in [false, true] {
let mut found_game_title = String::new();
let mut found_updater_title = String::new();
let mut has_game_window = false;
let mut has_updater_window = false;
for i in 0..count {
let dict: *mut AnyObject = msg_send![window_list, objectAtIndex: i];
if dict.is_null() {
continue;
}
let pid_num: *mut AnyObject = msg_send![dict, objectForKey: pid_key];
if pid_num.is_null() {
continue;
}
let pid_val: u32 = msg_send![pid_num, unsignedIntValue];
let is_match = if !fallback_pass {
target_u32 != 0 && pid_val == target_u32
} else {
let owner_str: *mut AnyObject = msg_send![dict, objectForKey: owner_key];
if !owner_str.is_null() {
let utf8: *const std::ffi::c_char = msg_send![owner_str, UTF8String];
if !utf8.is_null() {
if let Ok(owner) = CStr::from_ptr(utf8).to_str() {
let lower = owner.to_lowercase();
lower.contains("wine") || lower.contains("osu")
} else {
false
}
} else {
false
}
} else {
false
}
};
if !is_match {
continue;
}
// Check layer: valid application window layers are 0..=50
// (e.g. 0 for normal windows, 3 for floating/focused, 27 for Wine fullscreen/borderless shields)
let layer_num: *mut AnyObject = msg_send![dict, objectForKey: layer_key];
let layer: i32 = if !layer_num.is_null() {
msg_send![layer_num, intValue]
} else {
0
};
if layer < 0 || layer > 50 {
continue;
}
// 1. Check kCGWindowName if available
let name_str: *mut AnyObject = msg_send![dict, objectForKey: name_key];
if !name_str.is_null() {
let utf8: *const std::ffi::c_char = msg_send![name_str, UTF8String];
if !utf8.is_null() {
if let Ok(s) = CStr::from_ptr(utf8).to_str() {
let trimmed = s.trim();
if !trimmed.is_empty() {
if trimmed.to_lowercase().contains("updater") {
found_updater_title = trimmed.to_string();
} else {
found_game_title = trimmed.to_string();
break;
}
}
}
}
}
// 2. Check window bounds
let bounds_dict: *mut AnyObject = msg_send![dict, objectForKey: bounds_key];
if !bounds_dict.is_null() {
let mut rect = CGRect {
origin: CGPoint { x: 0.0, y: 0.0 },
size: CGSize {
width: 0.0,
height: 0.0,
},
};
if CGRectMakeWithDictionaryRepresentation(bounds_dict, &mut rect) {
// Ignore menu bar overlays (height <= 50) and tiny dummy windows (<= 100x100)
if rect.size.height > 50.0 && rect.size.width > 100.0 {
// Ignore Wine's default 500x500 dummy desktop surface
let is_wine_default_desktop = (rect.size.width - 500.0).abs() < 2.0
&& (rect.size.height - 500.0).abs() < 2.0;
if !is_wine_default_desktop {
if rect.size.width >= 640.0 && rect.size.height >= 480.0 {
has_game_window = true;
break;
} else if rect.size.width >= 200.0 && rect.size.height >= 80.0 {
has_updater_window = true;
}
}
}
}
}
}
let has_result = !found_game_title.is_empty()
|| has_game_window
|| !found_updater_title.is_empty()
|| has_updater_window;
if has_result {
let _: () = msg_send![window_list, release];
if !found_game_title.is_empty() {
return found_game_title;
} else if has_game_window {
return "osu!".to_string();
} else if !found_updater_title.is_empty() {
return found_updater_title;
} else {
return "osu! updater".to_string();
}
}
}
let _: () = msg_send![window_list, release];
String::new()
}
}
#[cfg(windows)]
pub fn get_window_title_by_pid(pid: Pid) -> String {
use std::ffi::OsString;
@@ -301,16 +610,23 @@ pub async fn is_net8_installed() -> bool {
match output_result {
Ok(output) => {
if !output.status.success() {
eprintln!(
"Error: `dotnet --list-runtimes` failed with status: {}",
log::warn!(
target: "launcher",
"`dotnet --list-runtimes` failed with status: {}",
output.status
);
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
log::warn!(
target: "launcher",
"dotnet stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
return false;
}
let stdout_str = String::from_utf8_lossy(&output.stdout);
stdout_str.lines().any(|line| line.starts_with("Microsoft.WindowsDesktop.App 8."))
stdout_str
.lines()
.any(|line| line.starts_with("Microsoft.WindowsDesktop.App 8."))
}
Err(_) => false,
}
@@ -386,3 +702,209 @@ pub fn encrypt_password(password: &str, entropy: &str) -> Result<String, String>
Ok(base64_string)
}
/// Check if the patcher binary has already been patched for .NET 4.0 compatibility under Wine.
pub fn is_compatible_patcher(data: &[u8]) -> bool {
let has_pbi_patch = data
.windows(6)
.any(|w| w == [0x1f, 0x18, 0x00, 0x00, 0x00, 0x00]);
let has_devserver_patch = data
.windows(7)
.any(|w| w == [0x26, 0x26, 0x2b, 0x0e, 0x00, 0x00, 0x00]);
has_pbi_patch && has_devserver_patch
}
/// Ensure `osu!.patcher.exe` is compatible with .NET Framework 4.0 runtime (used by Wine).
///
/// In .NET 4.0 (the runtime provided by Wine prefixes for osu! stable), generic `Marshal.SizeOf<T>()`
/// does not exist (added in .NET 4.5.1), causing a `MissingMethodException` when `GetCommandLine()`
/// queries `PROCESS_BASIC_INFORMATION`. Additionally:
/// 1. `Console.ReadKey()` crashes with `InvalidOperationException` when stdio is redirected without a console.
/// 2. `GetOsuPid()` checks for `" -devserver "` via PEB reading, which fails or is unreliable under Wine.
/// 3. Costura embeds `ManagedInjector.Lib` which also calls generic `Marshal.SizeOf<Native.ModuleEntry32>()`
/// and parses remote PE export tables through generic marshal APIs.
/// This function patches the binary in-place so it runs reliably under Wine.
pub fn ensure_patcher_compatibility(patcher_exe: &Path) -> Result<bool, String> {
if !patcher_exe.is_file() {
return Ok(false);
}
// Clean up loose ManagedInjector.Lib.dll if present, as CLR loads loose assemblies before Costura resources.
if let Some(parent) = patcher_exe.parent() {
let loose = parent.join("ManagedInjector.Lib.dll");
if loose.is_file() {
let _ = fs::remove_file(&loose);
}
}
let mut data = fs::read(patcher_exe)
.map_err(|e| format!("Failed to read {}: {e}", patcher_exe.display()))?;
let mut modified = false;
// Pattern 1 in GetCommandLine: ldloc.0, ldc.i4.0, ldloca.s 1, ldloc.1, call Marshal.SizeOf<PBI>
// [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b]
let sizeof_pattern = [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b];
if let Some(pos) = data
.windows(sizeof_pattern.len())
.position(|w| w == sizeof_pattern)
{
// Replace ldloc.1 (0x07) and call Marshal.SizeOf<PBI> (0x28, 0x01, 0x00, 0x00, 0x2b) with ldc.i4.s 24 (0x1f, 0x18) + 4 nops
let target = pos + 4;
data[target..target + 6].copy_from_slice(&[0x1f, 0x18, 0x00, 0x00, 0x00, 0x00]);
modified = true;
}
// Pattern 2 in Main catch handler: call Console.Write, call Console.ReadKey, pop, leave.s
// [0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde]
let readkey_pattern = [
0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde,
];
if let Some(pos) = data
.windows(readkey_pattern.len())
.position(|w| w == readkey_pattern)
{
// Replace call Console.ReadKey() (5 bytes) + pop (1 byte) with 6 nops
let target = pos + 5;
data[target..target + 6].copy_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
modified = true;
}
// Pattern 3 in GetOsuPid: devserver check bypass
// [0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b]
let dev_pattern = [
0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b,
];
if let Some(pos) = data
.windows(dev_pattern.len())
.position(|w| w == dev_pattern)
{
// Replace callvirt Contains + brtrue.s with pop, pop, br.s + 3 nops
let target = pos + 5;
data[target..target + 7].copy_from_slice(&[0x26, 0x26, 0x2b, 0x0e, 0x00, 0x00, 0x00]);
modified = true;
}
// Pattern 4: Costura embedded ManagedInjector.Lib resource at offset 0x1f00 (length 10020)
if data.len() >= 0x1f00 + 10020 && data.get(0x1efc..0x1f00) == Some(&[0x24, 0x27, 0x00, 0x00]) {
use flate2::read::DeflateDecoder;
use flate2::write::DeflateEncoder;
use flate2::Compression;
use std::io::{Read, Write};
let stream = &data[0x1f00..0x1f00 + 10020];
let mut decoder = DeflateDecoder::new(stream);
let mut decomp = Vec::new();
if decoder.read_to_end(&mut decomp).is_ok() {
let mut inner_modified = false;
// 4a. NativeHelper.GetModules: Marshal.SizeOf<ModuleEntry32>() -> ldc.i4 548
let module_pattern = [0x12, 0x04, 0x28, 0x08, 0x00, 0x00, 0x2b];
if let Some(pos) = decomp.windows(module_pattern.len()).position(|w| w == module_pattern) {
decomp[pos + 2..pos + 7].copy_from_slice(&[0x20, 0x24, 0x02, 0x00, 0x00]);
inner_modified = true;
}
// 4b. InjectableProcess.GetArchitecture: return NetFrameworkV4 (2) immediately
let arch_pattern = [
0x02, 0x7b, 0x0a, 0x00, 0x00, 0x04, 0x2c, 0x07, 0x02, 0x7b, 0x0a, 0x00, 0x00,
0x04, 0x2a,
];
if let Some(pos) = decomp.windows(arch_pattern.len()).position(|w| w == arch_pattern) {
decomp[pos..pos + 2].copy_from_slice(&[0x18, 0x2a]); // ldc.i4.2, ret
decomp[pos + 2..pos + 15].fill(0x00); // 13 nops
inner_modified = true;
}
// 4c. FrameworkInjectorBase.GetCorBindToRuntimeExAddress: return 0x17b55 directly without remote PE parsing
let corbind_pattern = [
0x03, 0x06, 0x7b, 0x65, 0x00, 0x00, 0x0a, 0x72, 0x3d, 0x03, 0x00, 0x70, 0x04,
0x28, 0x08, 0x00, 0x00, 0x06, 0x0b,
];
if let Some(pos) = decomp.windows(corbind_pattern.len()).position(|w| w == corbind_pattern) {
decomp[pos..pos + 6].copy_from_slice(&[0x20, 0x55, 0x7b, 0x01, 0x00, 0x0b]); // ldc.i4 0x17b55, stloc.1
decomp[pos + 6..pos + 19].fill(0x00); // 13 nops
inner_modified = true;
}
if inner_modified {
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best());
if encoder.write_all(&decomp).is_ok() {
if let Ok(mut compressed) = encoder.finish() {
if compressed.len() <= 10020 - 5 {
let remaining = 10020 - compressed.len();
let length = (remaining - 5) as u16;
let nlen = !length;
compressed.push(0x01); // BFINAL = 1, BTYPE = 00
compressed.push((length & 0xff) as u8);
compressed.push((length >> 8) as u8);
compressed.push((nlen & 0xff) as u8);
compressed.push((nlen >> 8) as u8);
compressed.extend(std::iter::repeat(0x00).take(length as usize));
if compressed.len() == 10020 {
data[0x1f00..0x1f00 + 10020].copy_from_slice(&compressed);
modified = true;
}
}
}
}
}
}
}
if modified {
fs::write(patcher_exe, &data)
.map_err(|e| format!("Failed to write {}: {e}", patcher_exe.display()))?;
log::info!(
target: "patcher",
"Applied .NET 4.0 compatibility patch to {}",
patcher_exe.display()
);
}
Ok(modified)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_patcher_compatibility_patch() {
let mut fake_binary = vec![0x90; 0x3000];
let pbi = [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b];
fake_binary[10..10 + pbi.len()].copy_from_slice(&pbi);
let readkey = [
0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde,
];
fake_binary[30..30 + readkey.len()].copy_from_slice(&readkey);
let devserver = [
0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b,
];
fake_binary[50..50 + devserver.len()].copy_from_slice(&devserver);
let temp_file = std::env::temp_dir().join("test_patcher_compat.exe");
std::fs::write(&temp_file, &fake_binary).unwrap();
let modified = ensure_patcher_compatibility(&temp_file).unwrap();
assert!(modified);
let patched_data = std::fs::read(&temp_file).unwrap();
assert!(is_compatible_patcher(&patched_data));
let _ = std::fs::remove_file(temp_file);
}
#[test]
#[cfg(target_os = "macos")]
fn test_get_window_title_macos() {
let _ = get_window_title_by_pid(Pid::from_u32(0));
}
#[test]
fn test_patcher_compatibility_real_patcher() {
let real_patcher = Path::new("/Applications/osu!(stable).app/Contents/Resources/drive_c/osu!/EZPPLauncher/patcher/osu!.patcher.exe");
if real_patcher.is_file() {
let res = ensure_patcher_compatibility(real_patcher);
assert!(res.is_ok());
let data = std::fs::read(real_patcher).unwrap();
assert!(is_compatible_patcher(&data));
}
}
}
+46 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EZPPLauncher",
"version": "3.0.10",
"version": "3.2.7",
"identifier": "farm.EZPPFarm.Launcher",
"mainBinaryName": "ezpplauncher",
"build": {
@@ -15,20 +15,50 @@
{
"title": "EZPPLauncher",
"width": 1000,
"height": 737,
"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' 'unsafe-eval' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
"worker-src": ["'self'", "blob:"],
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:*",
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http://127.0.0.1:* https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
"connect-src": "'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh https://api.fontsource.org https://cdn.jsdelivr.net",
"media-src": "'self' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* blob:",
"font-src": [
"'self'",
"asset:",
"blob:",
"data:",
"http://asset.localhost",
"https://asset.localhost",
"http://127.0.0.1:*",
"https://cdn.jsdelivr.net"
]
},
"assetProtocol": {
"enable": true,
"scope": [
"$HOME/.ezpplauncher/themes/**",
"$HOME/.ezpplauncher/font-cache/**"
]
}
}
},
"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 +66,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 +85,7 @@
"nsis": {
"installMode": "currentUser",
"compression": "lzma",
"installerIcon": "icons/icon.ico",
"installerHooks": "./nsis/nsis-hooks.nsh"
}
},
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}
+31 -23
View File
@@ -4,6 +4,13 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "@fontsource-variable/sora";
@import "@fontsource/space-mono";
@import "@fontsource/bebas-neue";
@import "@fontsource-variable/montserrat";
@import "@fontsource-variable/playfair-display";
@import "@fontsource-variable/jetbrains-mono";
:root {
--radius: 0.625rem;
--background: oklch(95.36% 0.017 256.57);
@@ -18,8 +25,8 @@
--secondary-foreground: oklch(40.06% 0.038 266.38);
--muted: oklch(89.49% 0.023 256.59);
--muted-foreground: oklch(51.01% 0.028 265.36);
--accent: oklch(54.41% 0.186 230.5);
--accent-foreground: oklch(100% 0 0);
--accent: var(--primary);
--accent-foreground: var(--primary-foreground);
--destructive: oklch(51.68% 0.202 15.84);
--destructive-foreground: oklch(100% 0 0);
--border: oklch(78.52% 0.026 260.1);
@@ -41,7 +48,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",
@@ -55,20 +62,20 @@
--card-foreground: oklch(89.24% 0.079 263.24);
--popover: oklch(20.3% 0.05 282.86);
--popover-foreground: oklch(89.24% 0.079 263.24);
--primary: oklch(81.45% 0.159 300.53);
--primary: oklch(72.08% 0.196 356.34);
--primary-foreground: oklch(20.22% 0.048 282.86);
--secondary: oklch(42.99% 0.031 270.49);
--secondary-foreground: oklch(89.24% 0.079 263.24);
--muted: oklch(24.41% 0.048 271.21);
--muted-foreground: oklch(73.83% 0.057 265.65);
--accent: oklch(86.2% 0.131 300.42);
--accent-foreground: oklch(20.22% 0.048 282.86);
--accent: var(--primary);
--accent-foreground: var(--primary-foreground);
--destructive: oklch(77.23% 0.146 11.66);
--destructive-foreground: oklch(20.22% 0.048 282.86);
--border: oklch(27.7% 0.041 276.87);
--input: oklch(27.7% 0.041 276.87);
--ring: oklch(81.45% 0.159 300.53);
--chart-1: oklch(81.45% 0.159 300.53);
--ring: oklch(72.08% 0.196 356.34);
--chart-1: oklch(72.08% 0.196 356.34);
--chart-2: oklch(77.19% 0.121 223.01);
--chart-3: oklch(79.1% 0.119 150.21);
--chart-4: oklch(79.74% 0.167 57.97);
@@ -76,12 +83,12 @@
--sidebar: oklch(13.29% 0.036 282.86);
--sidebar-background: oklch(13.29% 0.036 282.86);
--sidebar-foreground: oklch(89.24% 0.079 263.24);
--sidebar-primary: oklch(81.45% 0.159 300.53);
--sidebar-primary: oklch(72.08% 0.196 356.34);
--sidebar-primary-foreground: oklch(20.22% 0.048 282.86);
--sidebar-accent: oklch(75.21% 0.121 293.01);
--sidebar-accent-foreground: oklch(20.22% 0.048 282.86);
--sidebar-border: oklch(35.13% 0.03 273.31);
--sidebar-ring: oklch(81.45% 0.159 300.53);
--sidebar-ring: oklch(72.08% 0.196 356.34);
}
@theme inline {
@@ -108,20 +115,18 @@
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-50: #fefdff;
--color-primary-100: #faf6fe;
--color-primary-200: #f2e9fc;
--color-primary-300: #e9dbfa;
--color-primary-400: #e1cef9;
--color-primary-500: #d9c0f7;
--color-primary-600: #c6a0f3;
--color-primary-700: #b381ef;
--color-primary-800: #a061eb;
--color-primary-900: #8d41e7;
--color-primary-950: #8331e5;
--color-primary-50: #ffe5f1;
--color-primary-100: #ffcce2;
--color-primary-200: #ff99c5;
--color-primary-300: #ff66a8;
--color-primary-400: #ff338b;
--color-primary-500: #ff006e;
--color-primary-600: #cc0058;
--color-primary-700: #990042;
--color-primary-800: #66002c;
--color-primary-900: #330016;
--color-primary-950: #24000f;
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: #fefdff;
--color-primary-100: #faf6fe;
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -157,6 +162,9 @@
-ms-user-select: none;
user-select: none;
}
html {
overscroll-behavior: none;
}
body {
@apply bg-background text-foreground;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+30 -1
View File
@@ -3,9 +3,11 @@ import type {
EZPPUserInfoResponse,
EZPPUserResponse,
EZPPUSerStatusResponse,
} from '@/types';
StreamsResult,
} from '$lib/types';
import { betterFetch } from '@better-fetch/fetch';
const VERSION_ENDPOINT = 'https://version.ez-pp.farm/';
const BANCHO_ENDPOINT = 'https://c.ez-pp.farm/';
const API_ENDPOINT = 'https://api.ez-pp.farm/';
const ENDPOINT = 'https://ez-pp.farm/';
@@ -89,4 +91,31 @@ export const ezppfarm = {
);
return request.error ? undefined : request.data;
},
latestBuildVersion: async (releaseStream: string): Promise<string | undefined> => {
const request = await betterFetch<StreamsResult>(`${VERSION_ENDPOINT}api/changelog`, {
timeout,
query: {
stream: 'none',
},
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'EZPPLauncher',
},
});
if (request.error) {
return undefined;
}
const releaseData = request.data;
if (!releaseData || !releaseData.streams) {
return undefined;
}
if (releaseData.streams.length === 0) return undefined;
const selectedRelease = releaseData.streams.find(
(releaseBuild) =>
releaseBuild.name.toLowerCase() === releaseStream.replaceAll(' ', '').toLowerCase()
);
if (!selectedRelease) return undefined;
return selectedRelease.latest_build.display_version;
},
};
+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';
-35
View File
@@ -1,35 +0,0 @@
import type { StreamsResult } from '@/types';
import { betterFetch } from '@better-fetch/fetch';
const API_ENDPOINT = 'https://osu.ppy.sh/api/';
const timeout = 5000; // 5 seconds;
export const osuapi = {
latestBuildVersion: async (releaseStream: string): Promise<string | undefined> => {
const request = await betterFetch<StreamsResult>(`${API_ENDPOINT}v2/changelog`, {
timeout,
query: {
stream: 'none',
},
headers: {
'Content-Type': 'application/json',
'User-Agent': 'EZPPLauncher',
},
});
if (request.error) {
return undefined;
}
const releaseData = request.data;
if (!releaseData || !releaseData.streams) {
return undefined;
}
if (releaseData.streams.length === 0) return undefined;
const selectedRelease = releaseData.streams.find(
(releaseBuild) =>
releaseBuild.name.toLowerCase() === releaseStream.replaceAll(' ', '').toLowerCase()
);
if (!selectedRelease) return undefined;
return selectedRelease.latest_build.display_version;
},
};
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
type ConfettiCannonProps = {
colors?: string[];
count?: number;
};
let { colors = ['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24'], count = 820 }: ConfettiCannonProps =
$props();
let canvas = $state<HTMLCanvasElement>();
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
width: number;
height: number;
rotation: number;
spin: number;
flutterPhase: number;
flutterSpeed: number;
swayPhase: number;
swaySpeed: number;
swayAmount: number;
terminalVelocity: number;
wind: number;
color: string;
delay: number;
age: number;
lifetime: number;
};
onMount(() => {
const element = canvas;
if (!element) return;
const context = element.getContext('2d');
if (!context) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const random = (min: number, max: number) => Math.random() * (max - min) + min;
let width = 0;
let height = 0;
let dpr = 1;
let frame = 0;
let lastTime = 0;
let elapsed = 0;
let particles: Particle[] = [];
const spawn = () => {
const rect = element.getBoundingClientRect();
width = rect.width || window.innerWidth;
height = rect.height || window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
element.width = Math.max(1, Math.round(width * dpr));
element.height = Math.max(1, Math.round(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
particles = Array.from({ length: count }, (_, index) => {
const fromLeft = index % 2 === 0;
const direction = fromLeft ? 1 : -1;
const streamer = Math.random() < 0.35;
const pieceWidth = streamer ? random(3, 5) : random(5, 8);
const pieceHeight = streamer ? random(12, 24) : random(7, 14);
return {
x: fromLeft ? random(0, 28) : width - random(0, 28),
y: height - random(0, 16),
// Aim inward and up from each bottom corner. Horizontal drag and
// gravity turn the straight launch into a natural arc.
vx: direction * random(160, 900),
vy: -random(1050, 1450),
width: pieceWidth,
height: pieceHeight,
rotation: random(0, Math.PI * 2),
spin: random(-4, 4),
flutterPhase: random(0, Math.PI * 2),
flutterSpeed: random(2.5, 6.5),
swayPhase: random(0, Math.PI * 2),
swaySpeed: random(2.2, 4.2),
swayAmount: random(4, 14),
terminalVelocity: random(130, 230),
wind: random(-8, 8),
color: colors[Math.floor(Math.random() * colors.length)],
delay: random(0, 0.25),
age: 0,
lifetime: random(7, 9.5),
};
});
};
const paint = (time: number) => {
if (lastTime === 0) lastTime = time;
const delta = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
elapsed += delta;
context.clearRect(0, 0, width, height);
let alive = false;
for (const particle of particles) {
if (elapsed < particle.delay) {
alive = true;
continue;
}
if (particle.age >= particle.lifetime) continue;
particle.age += delta;
if (particle.age >= particle.lifetime) continue;
alive = true;
particle.vx *= Math.exp(-0.5 * delta);
if (particle.vy < 0) {
// The upward burst is slowed by gravity...
particle.vy += 1400 * delta;
} else {
// ...then the paper drifts down at its own slow terminal speed
// instead of accelerating like a solid object.
particle.vy += (particle.terminalVelocity - particle.vy) * (1 - Math.exp(-2.6 * delta));
}
particle.x += (particle.vx + particle.wind) * delta;
particle.y += particle.vy * delta;
particle.rotation += particle.spin * delta;
if (particle.x < -80 || particle.x > width + 80 || particle.y > height + 120) {
continue;
}
const fadeStart = particle.lifetime - 1.8;
const alpha =
particle.age > fadeStart ? Math.max(0, 1 - (particle.age - fadeStart) / 1.8) : 1;
if (alpha <= 0) continue;
const flutter = Math.cos(particle.flutterPhase + particle.age * particle.flutterSpeed);
const sway = Math.sin(particle.age * particle.swaySpeed + particle.swayPhase);
const scaleX = 0.15 + Math.abs(flutter) * 0.85;
context.save();
context.translate(particle.x + sway * particle.swayAmount, particle.y + sway * 3.5);
context.rotate(particle.rotation + flutter * 0.5);
context.scale(scaleX, 1);
context.globalAlpha = alpha;
context.fillStyle = particle.color;
context.fillRect(
-particle.width / 2,
-particle.height / 2,
particle.width,
particle.height
);
context.restore();
}
if (alive) {
frame = requestAnimationFrame(paint);
} else {
context.clearRect(0, 0, width, height);
}
};
spawn();
frame = requestAnimationFrame(paint);
return () => {
cancelAnimationFrame(frame);
};
});
</script>
<canvas
bind:this={canvas}
aria-hidden="true"
class="pointer-events-none absolute inset-0 z-20 h-full w-full"
></canvas>
@@ -0,0 +1,416 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { launcherStream } from '$lib/global';
import { getReleaseStream, getVersion, isOsuCorrupted } from '$lib/osuUtil';
import { osuInstallationPath, patch } from '$lib/userSettings';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
CheckCircle2,
ClipboardCopy,
Info,
LoaderCircle,
RefreshCw,
Wrench,
XCircle,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
type InstallationDiagnostics = {
platform: string;
osVersion?: string;
osArch: string;
launcherVersion: string;
osuFolder: string;
resolvedFolder?: string;
osuWritable?: boolean;
patcherPath?: string;
patcherInstalled: boolean;
patcherFileCount: number;
patcherTotalSize: number;
hostNet8: boolean;
osuwinello: boolean;
wmctrl: boolean;
winePrefix?: string;
wineBinary?: string;
wineAppBundle?: string;
wineVersion?: string;
winePrefixArch?: string;
wineNet8?: boolean;
wineError?: string;
ffmpegStatus?: string;
logsDirectory: string;
};
type Check = {
label: string;
detail: string;
status: 'ok' | 'warning' | 'error' | 'info';
};
let { onRepair }: { onRepair?: () => void } = $props();
let checking = $state(true);
let checks = $state<Check[]>([]);
let lastChecked = $state<Date | undefined>(undefined);
let platformName = $state('');
let launcherVersionName = $state('');
let osVersion = $state('');
let osArch = $state('');
let logsDirectory = $state('');
const icons = {
ok: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
info: Info,
};
const platformLabel = (platform: string) => {
switch (platform) {
case 'macos':
return 'macOS';
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
default:
return platform || 'unknown';
}
};
async function runChecks() {
checking = true;
checks = [];
try {
const folder = $osuInstallationPath;
const diagnostics = await invoke<InstallationDiagnostics>('installation_diagnostics', {
folder,
});
platformName = diagnostics.platform;
launcherVersionName = diagnostics.launcherVersion;
osVersion = diagnostics.osVersion ?? '';
osArch = diagnostics.osArch;
logsDirectory = diagnostics.logsDirectory;
const resolved = diagnostics.resolvedFolder;
// Only the experimental patcher build targets .NET 8.
const needsNet8 = $patch && $launcherStream === 'experimental';
const result: Check[] = [];
result.push({
label: 'Launcher',
detail: `${diagnostics.launcherVersion || 'unknown version'} · ${
diagnostics.osVersion || platformLabel(diagnostics.platform)
}${diagnostics.osArch ? ` (${diagnostics.osArch})` : ''}`,
status: 'ok',
});
if (!folder.trim()) {
result.push({
label: 'osu! installation',
detail: 'No installation path is configured. Run the setup wizard to pick osu!.',
status: 'error',
});
} else if (!resolved) {
result.push({
label: 'osu! installation',
detail: `The configured path could not be resolved: ${folder}`,
status: 'error',
});
} else {
result.push({
label: 'osu! installation',
detail: resolved,
status: 'ok',
});
try {
const [version, stream] = await Promise.all([
getVersion(resolved),
getReleaseStream(resolved),
]);
result.push({
label: 'osu! version',
detail: `${stream ?? 'unknown stream'} · ${version || 'version unavailable'}`,
status: version ? 'ok' : 'warning',
});
} catch (error) {
result.push({
label: 'osu! version',
detail: error instanceof Error ? error.message : String(error),
status: 'warning',
});
}
try {
const corrupted = await isOsuCorrupted(resolved);
result.push({
label: 'Game files',
detail: corrupted
? 'osu!.exe or a required file looks corrupted. Re-run the updater or setup.'
: 'The osu! executable passed the integrity check.',
status: corrupted ? 'error' : 'ok',
});
} catch (error) {
result.push({
label: 'Game files',
detail: error instanceof Error ? error.message : String(error),
status: 'warning',
});
}
}
if (typeof diagnostics.osuWritable === 'boolean') {
result.push({
label: 'osu! folder access',
detail: diagnostics.osuWritable
? 'The launcher can write to this folder, so updates and patcher downloads can be installed.'
: 'The launcher cannot write to this folder. Updates and patcher downloads will fail; check folder permissions.',
status: diagnostics.osuWritable ? 'ok' : 'error',
});
}
const selectedStream = $launcherStream;
const effectiveStream = diagnostics.platform === 'windows' ? selectedStream : 'stable';
result.push({
label: 'Patcher stream',
detail:
diagnostics.platform === 'windows'
? `${selectedStream} selected`
: `${effectiveStream} (selected: ${selectedStream}; macOS/Linux always use stable)`,
status: 'ok',
});
result.push({
label: 'Patcher',
detail: diagnostics.patcherInstalled
? `${diagnostics.patcherFileCount} file${
diagnostics.patcherFileCount === 1 ? '' : 's'
}, ${formatBytes(diagnostics.patcherTotalSize)} · ${diagnostics.patcherPath}`
: 'Patcher files are not installed yet. They will be downloaded automatically the next time you launch osu!.',
status: diagnostics.patcherInstalled ? 'ok' : $patch ? 'warning' : 'info',
});
result.push({
label: 'Logs',
detail: diagnostics.logsDirectory || 'The logs folder is unavailable.',
status: diagnostics.logsDirectory ? 'ok' : 'warning',
});
if (diagnostics.platform === 'macos') {
if (diagnostics.wineError) {
result.push({
label: 'Wine wrapper',
detail: diagnostics.wineError,
status: 'error',
});
} else {
result.push({
label: 'Wine prefix',
detail: diagnostics.winePrefix || 'Wine prefix not detected.',
status: diagnostics.winePrefix ? 'ok' : 'error',
});
result.push({
label: 'Wine runtime',
detail: diagnostics.wineBinary || 'No Wine loader found inside the wrapper.',
status: diagnostics.wineBinary ? 'ok' : 'error',
});
result.push({
label: 'Wine version',
detail: diagnostics.wineVersion || 'Could not determine the Wine version.',
status: diagnostics.wineVersion ? 'ok' : 'warning',
});
if (diagnostics.winePrefixArch) {
result.push({
label: 'Wine prefix architecture',
detail:
diagnostics.winePrefixArch === 'win64'
? '64-bit (win64)'
: diagnostics.winePrefixArch === 'win32'
? '32-bit (win32)'
: diagnostics.winePrefixArch,
status: 'ok',
});
}
if (diagnostics.wineAppBundle) {
result.push({
label: 'App wrapper',
detail: diagnostics.wineAppBundle,
status: 'ok',
});
}
result.push({
label: '.NET 8 in prefix (experimental)',
detail:
diagnostics.wineNet8 === true
? 'The Microsoft.WindowsDesktop.App 8 runtime was found.'
: 'The Windows .NET 8 Desktop Runtime was not found in this prefix. Only the experimental patcher stream needs it.',
status: diagnostics.wineNet8 === true ? 'ok' : needsNet8 ? 'error' : 'warning',
});
result.push({
label: 'FFmpeg (theme video)',
detail: diagnostics.ffmpegStatus
? diagnostics.ffmpegStatus
: 'FFmpeg was not found. Large theme videos will stay on WebKit’s software decoder and may stutter.',
status: diagnostics.ffmpegStatus ? 'ok' : 'warning',
});
}
} else if (diagnostics.platform === 'linux') {
result.push({
label: 'osu-winello',
detail: diagnostics.osuwinello
? 'osu-winello was found.'
: 'osu-winello was not found in PATH. Launching osu! may fail.',
status: diagnostics.osuwinello ? 'ok' : 'error',
});
result.push({
label: 'wmctrl',
detail: diagnostics.wmctrl
? 'wmctrl is available for window detection.'
: 'wmctrl is missing. The patcher may not detect the osu! window.',
status: diagnostics.wmctrl ? 'ok' : $patch ? 'warning' : 'info',
});
} else {
result.push({
label: '.NET 8 Desktop Runtime (experimental)',
detail: diagnostics.hostNet8
? 'Microsoft.WindowsDesktop.App 8 was found.'
: 'The .NET 8 Desktop Runtime was not found. Only the experimental patcher stream needs it.',
status: diagnostics.hostNet8 ? 'ok' : needsNet8 ? 'error' : 'warning',
});
}
checks = result;
lastChecked = new Date();
} catch (error) {
checks = [
{
label: 'Diagnostics',
detail: error instanceof Error ? error.message : String(error),
status: 'error',
},
];
} finally {
checking = false;
}
}
async function copyReport() {
const body = checks
.map((check) => `[${check.status.toUpperCase()}] ${check.label}: ${check.detail}`)
.join('\n');
const report = [
'EZPPLauncher diagnostics',
`Generated: ${new Date().toISOString()}`,
`Launcher: ${launcherVersionName || 'unknown'}`,
`Platform: ${osVersion || platformLabel(platformName)}${osArch ? ` (${osArch})` : ''}`,
`Logs: ${logsDirectory || 'unknown'}`,
'',
body,
].join('\n');
try {
await navigator.clipboard.writeText(report);
sileo.success({
title: 'Diagnostics copied',
description: 'Paste it into your support report.',
});
} catch {
sileo.error({ title: 'Could not copy diagnostics' });
}
}
onMount(runChecks);
</script>
<div class="divide-y divide-theme-800/50 border-t border-theme-800/50">
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
<div class="min-w-0">
<p class="text-sm font-medium">System checks</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Inspect the selected osu! install, folder permissions, Wine wrapper, patcher, FFmpeg and
runtime dependencies.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-1.5">
{#if lastChecked}
<span class="mr-1 text-[10px] text-muted-foreground">
{lastChecked.toLocaleTimeString()}
</span>
{/if}
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
onclick={copyReport}
>
<ClipboardCopy class="size-4" />
Copy report
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
onclick={runChecks}
disabled={checking}
>
<RefreshCw class={checking ? 'size-4 animate-spin' : 'size-4'} />
Refresh
</Button>
{#if onRepair}
<Button variant="secondary" size="sm" onclick={onRepair}>
<Wrench class="size-4" />
Repair setup
</Button>
{/if}
</div>
</div>
<div class="space-y-2 py-3">
{#if checking}
<div class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin" />
Checking your installation…
</div>
{:else}
{#each checks as check (check.label)}
{@const Icon = icons[check.status]}
<div
class="flex items-start gap-2.5 rounded-lg border border-theme-800/70 bg-theme-950/60 px-3 py-2"
>
<Icon
class="mt-0.5 size-4 shrink-0 {check.status === 'ok'
? 'text-emerald-400'
: check.status === 'warning'
? 'text-amber-300'
: check.status === 'error'
? 'text-destructive'
: 'text-muted-foreground'}"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-xs font-medium text-theme-100">{check.label}</p>
<Badge
variant={check.status === 'error'
? 'destructive'
: check.status === 'warning'
? 'secondary'
: 'outline'}
class="px-1.5 py-0 text-[9px] capitalize"
>
{check.status}
</Badge>
</div>
<p class="mt-0.5 break-words text-[10px] leading-relaxed text-muted-foreground">
{check.detail}
</p>
</div>
</div>
{/each}
{/if}
</div>
</div>
+337
View File
@@ -0,0 +1,337 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import { openLogsFolder } from '$lib/osuUtil';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
Copy,
FileText,
FolderOpen,
LoaderCircle,
RefreshCw,
Search,
Terminal,
X,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
type LogBundleInfo = {
path: string;
name: string;
size: number;
modified: number;
};
type LogBundleEntry = {
name: string;
content: string;
};
let { onclose }: { onclose: () => void } = $props();
let bundles = $state<LogBundleInfo[]>([]);
let selectedPath = $state<string | undefined>(undefined);
let entries = $state<LogBundleEntry[]>([]);
let selectedLog = $state('launcher.log');
let query = $state('');
let errorsOnly = $state(false);
let loadingBundles = $state(true);
let loadingEntries = $state(false);
let errorMessage = $state('');
const selectedBundle = $derived(bundles.find((bundle) => bundle.path === selectedPath));
const activeEntry = $derived(entries.find((entry) => entry.name === selectedLog));
const allLines = $derived(activeEntry?.content.split(/\r?\n/) ?? []);
const filteredLines = $derived.by(() => {
const text = query.trim().toLowerCase();
return allLines.filter((line) => {
if (errorsOnly && !/(error|warn|panic|fatal|failed)/i.test(line)) return false;
return !text || line.toLowerCase().includes(text);
});
});
const visibleLines = $derived(filteredLines.slice(0, 5000));
async function loadBundles() {
loadingBundles = true;
errorMessage = '';
try {
bundles = await invoke<LogBundleInfo[]>('list_log_bundles');
if (bundles.length === 0) {
selectedPath = undefined;
entries = [];
return;
}
const stillThere = bundles.some((bundle) => bundle.path === selectedPath);
const next = stillThere ? selectedPath : bundles[0].path;
selectedPath = next;
await loadBundle(next!);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
loadingBundles = false;
}
}
async function loadBundle(path: string) {
selectedPath = path;
loadingEntries = true;
errorMessage = '';
try {
entries = await invoke<LogBundleEntry[]>('read_log_bundle', { path });
if (!entries.some((entry) => entry.name === selectedLog)) {
selectedLog = entries[0]?.name ?? 'launcher.log';
}
} catch (error) {
entries = [];
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
loadingEntries = false;
}
}
async function refresh() {
await loadBundles();
}
async function copyVisible() {
const text = filteredLines.join('\n');
try {
await navigator.clipboard.writeText(text);
sileo.success({
title: 'Log lines copied',
description: `${filteredLines.length} lines from ${selectedLog}.`,
});
} catch {
sileo.error({ title: 'Could not copy the log' });
}
}
async function openFolder() {
try {
await openLogsFolder();
} catch (error) {
sileo.error({
title: 'Could not open the logs folder',
description: error instanceof Error ? error.message : String(error),
});
}
}
function lineClass(line: string) {
if (/(error|panic|fatal|failed)/i.test(line)) return 'text-red-300';
if (/(warn|deprecated|missing)/i.test(line)) return 'text-amber-200';
if (/(info|started|loaded|saved)/i.test(line)) return 'text-theme-200';
return 'text-theme-300';
}
onMount(loadBundles);
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') onclose();
}}
/>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-5 backdrop-blur-sm"
role="presentation"
>
<div
class="flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="log-viewer-title"
>
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-theme-800 p-3">
<div class="flex min-w-0 items-center gap-2">
<div class="flex size-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Terminal class="size-4" />
</div>
<div class="min-w-0">
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2>
<p class="truncate text-[10px] text-muted-foreground">
Launcher, osu! and patcher output, grouped by session
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={openFolder}
>
<FolderOpen class="size-3.5" />
Folder
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={refresh}
disabled={loadingBundles}
>
<RefreshCw class={loadingBundles ? 'size-3.5 animate-spin' : 'size-3.5'} />
Refresh
</Button>
<Button variant="ghost" size="icon" class="size-8" title="Close logs" onclick={onclose}>
<X class="size-4" />
</Button>
</div>
</header>
<div class="grid min-h-0 flex-1 grid-cols-[230px_minmax(0,1fr)]">
<aside class="flex min-h-0 flex-col border-r border-theme-800 bg-black/20">
<div
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
>
<span>Bundles</span>
<span>{bundles.length}</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
{#if loadingBundles}
<div class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin" />
Loading…
</div>
{:else if bundles.length === 0}
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5
MiB.
</div>
{:else}
{#each bundles as bundle (bundle.path)}
<button
type="button"
class="mb-1 w-full rounded-md border px-2 py-2 text-left transition {bundle.path ===
selectedPath
? 'border-primary/40 bg-primary/10'
: 'border-transparent hover:border-theme-800 hover:bg-theme-900/60'}"
onclick={() => loadBundle(bundle.path)}
>
<span class="flex items-center gap-1.5">
<FileText class="size-3.5 shrink-0 text-primary" />
<span class="truncate text-[11px] font-medium text-theme-100">
{bundle.name}
</span>
</span>
<span class="mt-1 block pl-5 text-[9px] text-muted-foreground">
{new Date(bundle.modified).toLocaleString()} · {formatBytes(bundle.size)}
</span>
</button>
{/each}
{/if}
</div>
</aside>
<main class="flex min-h-0 min-w-0 flex-col">
{#if errorMessage}
<div
class="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
>
<AlertTriangle class="size-4" />
{errorMessage}
</div>
{/if}
<div class="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-theme-800 p-2">
{#if entries.length > 0}
{#each entries as entry (entry.name)}
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] {selectedLog === entry.name
? 'bg-primary/15 text-primary'
: 'text-muted-foreground'}"
onclick={() => (selectedLog = entry.name)}
>
{entry.name}
</Button>
{/each}
{/if}
<div class="relative ml-auto w-52">
<Search
class="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-7 border-theme-800 bg-theme-950 pl-7 text-[10px]"
placeholder="Filter lines…"
bind:value={query}
/>
</div>
<div
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1"
>
<Checkbox id="log-errors-only" bind:checked={errorsOnly} />
<label for="log-errors-only" class="text-[10px] text-muted-foreground">
Errors only
</label>
</div>
<Button
variant="outline"
size="sm"
class="h-7 border-theme-800 bg-theme-950 text-[10px]"
onclick={copyVisible}
disabled={filteredLines.length === 0}
>
<Copy class="size-3" />
Copy
</Button>
</div>
<div class="min-h-0 flex-1 overflow-auto bg-black/25">
{#if loadingEntries}
<div
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
>
<LoaderCircle class="size-4 animate-spin" />
Reading bundle…
</div>
{:else if !selectedBundle}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
>
<Terminal class="size-8 opacity-40" />
<p class="text-xs">Choose a log bundle to inspect it.</p>
</div>
{:else if filteredLines.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
>
<Search class="size-6 opacity-40" />
<p class="text-xs">No lines match this filter.</p>
</div>
{:else}
<div class="p-3 font-mono text-[10px] leading-relaxed">
{#each visibleLines as line, index (index)}
<div class="whitespace-pre-wrap break-words {lineClass(line)}">{line || ' '}</div>
{/each}
{#if filteredLines.length > visibleLines.length}
<p class="mt-2 text-amber-200">
Showing the first {visibleLines.length} matching lines. Narrow the filter to see more.
</p>
{/if}
</div>
{/if}
</div>
<footer
class="flex shrink-0 items-center justify-between border-t border-theme-800 px-3 py-1.5 text-[9px] text-muted-foreground"
>
<span>{filteredLines.length} matching lines</span>
<span>
{selectedBundle
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
: 'No bundle selected'}
</span>
</footer>
</main>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,92 @@
<script module lang="ts">
let previewCount = 0;
</script>
<script lang="ts">
import { onMount } from 'svelte';
type Props = {
family: string;
src: string;
weight?: string;
style?: 'normal' | 'italic';
text?: string;
class?: string;
};
let {
family,
src,
weight = '400',
style = 'normal',
text = family,
class: className = '',
}: Props = $props();
let host = $state<HTMLSpanElement>();
let visible = $state(false);
let renderedFamily = $state<string>();
let face: FontFace | undefined;
onMount(() => {
if (!host) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
visible = true;
observer.disconnect();
},
{ rootMargin: '180px 0px' }
);
observer.observe(host);
return () => observer.disconnect();
});
$effect(() => {
if (!visible) return;
if (!src) {
renderedFamily = family;
return;
}
let cancelled = false;
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
void (async () => {
try {
const candidate = new FontFace(previewFamily, `url("${src}")`, {
weight,
style,
display: 'swap',
});
const loaded = await candidate.load();
if (cancelled) return;
document.fonts.add(loaded);
face = loaded;
renderedFamily = previewFamily;
} catch {
// A preview failure should leave the row readable in the fallback font.
}
})();
return () => {
cancelled = true;
if (face && face.family === previewFamily) {
document.fonts.delete(face);
face = undefined;
}
if (renderedFamily === previewFamily) renderedFamily = undefined;
};
});
</script>
<span
bind:this={host}
class={className}
style:font-family={renderedFamily
? `"${renderedFamily}", ui-sans-serif, system-ui, sans-serif`
: undefined}
style:font-weight={weight}
style:font-style={style}
>
{text}
</span>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,764 @@
<script lang="ts">
import {
analyzeThemeSvelteSource,
type ThemeSvelteAnalysis,
type ThemeSvelteDiagnostic,
} from '$lib/themeSvelte';
import {
THEME_SVELTE_COMPONENT_PLACEHOLDER,
THEME_SVELTE_COMPONENT_SKELETON,
} from '$lib/themeSvelteSnippets';
import { Component, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
import type * as Monaco from 'monaco-editor';
import { onMount } from 'svelte';
type MonacoApi = typeof import('monaco-editor');
let {
value = '',
onchange,
variables = [],
height = 320,
}: {
value?: string;
onchange?: (value: string) => void;
variables?: string[];
height?: number;
} = $props();
const LANGUAGE_ID = 'ezpp-theme-svelte';
const THEME_ID = 'ezpp-theme-svelte-dark';
const MARKER_OWNER = 'ezpp-theme-svelte';
// Keep these split so the Svelte parser cannot mistake a string literal for
// the end of this component's own script block.
const SCRIPT_OPEN = '<scr' + 'ipt>';
const SCRIPT_CLOSE = '</scr' + 'ipt>';
const STYLE_OPEN = '<sty' + 'le>';
const STYLE_CLOSE = '</sty' + 'le>';
const propDocs: { label: string; documentation: string; insert: string }[] = [
{
label: '$props',
insert:
'let { variables = {}, layer = {}, theme = {}, asset = (path) => path, screen = "home" } = $props();',
documentation:
'Receive live theme props: `variables`, `layer`, `theme`, `screen` and the `asset(path)` helper.',
},
{
label: 'variables',
insert: 'variables.${1:name}',
documentation: 'Live rule variables. They update when a Logic rule changes a variable.',
},
{
label: 'layer',
insert: 'layer.${1:width}',
documentation:
'The layer bounds: `layer.id`, `layer.name`, `layer.width` and `layer.height`.',
},
{
label: 'theme',
insert: 'theme.${1:name}',
documentation: 'Theme metadata: `theme.name`, `theme.author` and `theme.accentColor`.',
},
{
label: 'asset',
insert: 'asset("${1:image.png}")',
documentation:
'Resolve a file inside the theme `assets/` folder. Works in the launcher and editor preview.',
},
{
label: 'screen',
insert: 'screen',
documentation: 'The current launcher screen. Custom layers only render on Home today.',
},
{
label: '$state',
insert: 'let ${1:value} = $state(${2:0});',
documentation: 'Create reactive component state.',
},
{
label: '$derived',
insert: 'const ${1:value} = $derived(${2:expression});',
documentation: 'Create a value derived from other reactive state.',
},
{
label: '$effect',
insert: '$effect(() => {\n\t$0\n});',
documentation: 'Run a side effect when reactive values change.',
},
{
label: 'onMount',
insert: 'import { onMount } from "svelte";\n\nonMount(() => {\n\t$0\n});',
documentation: 'Run code after the component mounts. Import it from `svelte`.',
},
{
label: 'onDestroy',
insert: 'import { onDestroy } from "svelte";\n\nonDestroy(() => {\n\t$0\n});',
documentation: 'Clean up timers, listeners and observers before the layer unmounts.',
},
{
label: 'tick',
insert: 'import { tick } from "svelte";\n\nawait tick();',
documentation: 'Wait for pending DOM updates.',
},
{
label: 'import transition',
insert: 'import { ${1:fade} } from "svelte/transition";',
documentation:
'Import transitions such as `fade`, `fly`, `slide`, `scale`, `blur` and `draw` from `svelte/transition`.',
},
{
label: 'import store',
insert: 'import { ${1:writable} } from "svelte/store";',
documentation: 'Svelte stores are available from `svelte/store`.',
},
{
label: 'SvelteMap',
insert: 'import { SvelteMap } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteMap();',
documentation: 'Reactive Map from `svelte/reactivity`.',
},
{
label: 'SvelteSet',
insert: 'import { SvelteSet } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteSet();',
documentation: 'Reactive Set from `svelte/reactivity`.',
},
{
label: '{#if}',
insert: '{#if ${1:condition}}\n\t$0\n{/if}',
documentation: 'Conditionally render markup.',
},
{
label: '{#each}',
insert: '{#each ${1:items} as ${2:item}}\n\t$0\n{/each}',
documentation: 'Render a list. Add `(${3:item.id})` for a keyed each block.',
},
{
label: '{#snippet}',
insert: '{#snippet ${1:name}(${2:argument})}\n\t$0\n{/snippet}',
documentation: 'Declare a reusable snippet and render it with `{@render name(argument)}`.',
},
{
label: '{@render}',
insert: '{@render ${1:name}(${2:argument})}',
documentation: 'Render a snippet declared in this component.',
},
{
label: SCRIPT_OPEN,
insert: `${SCRIPT_OPEN}\n\t$0\n${SCRIPT_CLOSE}`,
documentation: 'Plain JavaScript only. TypeScript is not preprocessed in theme layers.',
},
{
label: STYLE_OPEN,
insert: `${STYLE_OPEN}\n\t$0\n${STYLE_CLOSE}`,
documentation: 'Scoped CSS for this component.',
},
];
let host = $state<HTMLDivElement>();
let monaco: MonacoApi | undefined;
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
let model: Monaco.editor.ITextModel | undefined;
let ready = $state(false);
let loadError = $state('');
let expanded = $state(false);
let lineCount = $state(1);
let cursorLine = $state(1);
let cursorColumn = $state(1);
let errorCount = $state(0);
let warningCount = $state(0);
let checking = $state(false);
let diagnostics = $state<ThemeSvelteDiagnostic[]>([]);
let currentValue = '';
let validateTimer: ReturnType<typeof setTimeout> | undefined;
let validationToken = 0;
const ensureLanguage = (api: MonacoApi) => {
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
api.languages.register({ id: LANGUAGE_ID });
}
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
comments: { blockComment: ['<!--', '-->'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
indentationRules: {
increaseIndentPattern: /<[^/!][^>]*>\s*$|\{[#:@][^}]*$/,
decreaseIndentPattern: /^\s*<\/|\{\/|\{:else/,
},
});
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
tokenizer: {
root: [
[/<!--/, { token: 'comment', next: '@comment' }],
[/<scr(?:ipt)\b[^>]*>/, { token: 'tag', next: '@script' }],
[/<sty(?:le)\b[^>]*>/, { token: 'tag', next: '@style' }],
[/<\/?[\w:-]+/, 'tag'],
[/[a-zA-Z_][\w-]*(?=\s*=)/, 'attribute.name'],
[/"[^"]*"|'[^']*'/, 'string'],
[/\{[#:/@][^}]*\}/, 'keyword'],
[/\{[^}]*\}/, 'expression'],
[/[<>/=]/, 'delimiter'],
[/[^<{]+/, 'text'],
[/[<{]/, 'delimiter'],
],
comment: [
[/[^-]+/, 'comment'],
[/-->/, { token: 'comment', next: '@pop' }],
[/./, 'comment'],
],
script: [
[/<\/script\s*>/, { token: 'tag', next: '@pop' }],
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@blockComment'],
[
/\b(?:import|from|let|const|var|if|else|for|while|return|function|async|await|new|typeof|export|class|extends|try|catch|finally|throw|break|continue|switch|case|default|of|in)\b/,
'keyword',
],
[/\$(?:state|derived|effect|props|bindable|inspect)(?:\.\w+)?/, 'predefined'],
[
/\b(?:onMount|onDestroy|tick|mount|unmount|fade|fly|slide|scale|blur|draw|writable|readable|derived|SvelteMap|SvelteSet|Math|Number|String|Boolean|JSON)\b/,
'predefined',
],
[/\b\d+(?:\.\d+)?\b/, 'number'],
[/[A-Za-z_$][\w$]*/, 'identifier'],
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|[=!<>+\-*/%?:]/, 'operator'],
[/[{}()[\].,;]/, 'delimiter'],
[/[^}\s]/, 'text'],
],
blockComment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment', next: '@pop' }],
[/[*/]/, 'comment'],
],
style: [
[/<\/style\s*>/, { token: 'tag', next: '@pop' }],
[/\/\*/, 'comment', '@blockComment'],
[/[.#][\w-]+/, 'attribute.name'],
[
/\b(?:var|calc|rgb|rgba|linear-gradient|radial-gradient|url|blur|clamp|min|max)\b/,
'predefined',
],
[/[a-zA-Z-]+(?=\s*:)/, 'type'],
[/#[0-9a-fA-F]{3,8}\b/, 'number.hex'],
[/\b\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|deg|s|ms)?\b/, 'number'],
[/"[^"]*"|'[^']*'/, 'string'],
[/[{}();:,]/, 'delimiter'],
[/[^}\s]/, 'text'],
],
},
});
api.editor.defineTheme(THEME_ID, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
{ token: 'predefined', foreground: '8be9fd' },
{ token: 'tag', foreground: 'ff79c6' },
{ token: 'attribute.name', foreground: '50fa7b' },
{ token: 'identifier', foreground: 'f8f8f2' },
{ token: 'expression', foreground: 'f8f8f2' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'number.hex', foreground: 'bd93f9' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'operator', foreground: 'ff79c6' },
{ token: 'type', foreground: '8be9fd' },
{ token: 'text', foreground: 'f8f8f2' },
{ token: 'delimiter', foreground: 'a1a1aa' },
],
colors: {
'editor.background': '#08080c',
'editor.foreground': '#f4f4f5',
'editorLineNumber.foreground': '#52525b',
'editorLineNumber.activeForeground': '#e4e4e7',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ff006e33',
'editor.inactiveSelectionBackground': '#ff006e22',
'editorCursor.foreground': '#ff006e',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#52525b',
'editorBracketHighlight.foreground1': '#ff79c6',
'editorBracketHighlight.foreground2': '#8be9fd',
'editorBracketHighlight.foreground3': '#f1fa8c',
'editorGutter.background': '#08080c',
'editorWidget.background': '#121218',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#121218',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#ff006e26',
'editorSuggestWidget.highlightForeground': '#ff79c6',
'editorHoverWidget.background': '#121218',
'editorHoverWidget.border': '#3f3f46',
'editorError.foreground': '#f87171',
'editorWarning.foreground': '#fbbf24',
'editorOverviewRuler.errorForeground': '#f87171',
'editorOverviewRuler.warningForeground': '#fbbf24',
'scrollbarSlider.background': '#52525b55',
'scrollbarSlider.hoverBackground': '#71717a88',
'scrollbarSlider.activeBackground': '#a1a1aa99',
},
});
};
const completionRange = (
api: MonacoApi,
currentModel: Monaco.editor.ITextModel,
position: Monaco.Position
) => {
const word = currentModel.getWordUntilPosition(position);
return new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
);
};
const completionPrefixes = ['variables.', 'layer.', 'theme.'];
// When the user has already typed `variables.`, Monaco replaces only the
// word after the dot. Strip the prefix from the insertion so the completion
// does not produce `variables.variables.score`.
const stripTypedPrefix = (
insertText: string,
currentModel: Monaco.editor.ITextModel,
range: Monaco.Range
) => {
const typed = currentModel
.getLineContent(range.startLineNumber)
.slice(0, range.startColumn - 1);
for (const prefix of completionPrefixes) {
if (typed.endsWith(prefix) && insertText.startsWith(prefix)) {
return insertText.slice(prefix.length);
}
}
return insertText;
};
const registerIntellisense = (api: MonacoApi) => {
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['.', '{', '#', '"', "'", ':', '<'],
provideCompletionItems(currentModel, position) {
const range = completionRange(api, currentModel, position);
const suggestions: Monaco.languages.CompletionItem[] = [];
for (const entry of propDocs) {
suggestions.push({
label: entry.label,
kind:
entry.label.charCodeAt(0) === 36 || entry.label.startsWith('{')
? api.languages.CompletionItemKind.Keyword
: entry.label.startsWith('import')
? api.languages.CompletionItemKind.Module
: api.languages.CompletionItemKind.Property,
insertText: stripTypedPrefix(entry.insert, currentModel, range),
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.label}`,
});
}
suggestions.push({
label: 'Svelte component skeleton',
kind: api.languages.CompletionItemKind.Snippet,
insertText: THEME_SVELTE_COMPONENT_SKELETON,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: 'A ready-to-edit layer with props, markup and scoped styles.',
sortText: '0_skeleton',
});
for (const variable of variables) {
suggestions.push({
label: `variables.${variable}`,
kind: api.languages.CompletionItemKind.Variable,
insertText: stripTypedPrefix(`variables.${variable}`, currentModel, range),
range,
documentation: `Live rule variable \`${variable}\`. Change it from a Logic rule.`,
sortText: `0_${variable}`,
});
}
return { suggestions, incomplete: false };
},
});
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(currentModel, position) {
const word = currentModel.getWordAtPosition(position);
if (!word) return null;
const entry = propDocs.find(
(candidate) => candidate.label === word.word || candidate.label === `${word.word}`
);
if (!entry) return null;
return {
range: new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
),
contents: [{ value: `**${entry.label}**\n\n${entry.documentation}` }],
};
},
});
return () => {
completion.dispose();
hover.dispose();
};
};
const syncDiagnostics = (analysis: ThemeSvelteAnalysis) => {
if (!monaco || !model) return;
const all: ThemeSvelteDiagnostic[] = analysis.ok
? analysis.warnings
: [...analysis.errors, ...analysis.warnings];
const markers: Monaco.editor.IMarkerData[] = all.map((diagnostic) => {
const lineNumber = Math.min(Math.max(1, diagnostic.line), model!.getLineCount());
const maxColumn = model!.getLineMaxColumn(lineNumber);
const startColumn = Math.min(Math.max(1, diagnostic.column), maxColumn);
const endLine = Math.min(
Math.max(lineNumber, diagnostic.endLine ?? lineNumber),
model!.getLineCount()
);
const endMaxColumn = model!.getLineMaxColumn(endLine);
const endColumn = Math.min(
Math.max(startColumn, diagnostic.endColumn ?? startColumn + 1),
endMaxColumn
);
return {
severity:
diagnostic.severity === 'error'
? monaco!.MarkerSeverity.Error
: monaco!.MarkerSeverity.Warning,
message: diagnostic.message,
source: 'EZPP Svelte layer',
startLineNumber: lineNumber,
startColumn,
endLineNumber: endLine,
endColumn: Math.max(startColumn + 1, endColumn),
};
});
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
errorCount = all.filter((diagnostic) => diagnostic.severity === 'error').length;
warningCount = all.filter((diagnostic) => diagnostic.severity === 'warning').length;
diagnostics = all;
};
const validate = async (source: string) => {
const token = ++validationToken;
checking = true;
const analysis = await analyzeThemeSvelteSource(source, 'ThemeLayer.svelte');
if (token !== validationToken) return;
checking = false;
syncDiagnostics(analysis);
};
const scheduleValidation = (source: string, immediate = false) => {
if (validateTimer) clearTimeout(validateTimer);
checking = true;
validateTimer = setTimeout(
() => {
void validate(source);
},
immediate ? 0 : 350
);
};
const updateStats = (source: string) => {
lineCount = Math.max(1, source.split('\n').length);
};
$effect(() => {
const next = value;
if (!model || !monaco || next === currentValue) return;
currentValue = next;
model.setValue(next);
updateStats(next);
scheduleValidation(next);
});
$effect(() => {
if (!expanded) return;
requestAnimationFrame(() => editor?.layout());
});
onMount(() => {
let disposed = false;
let disposeIntellisense: (() => void) | undefined;
void (async () => {
try {
const { default: EditorWorker } =
await import('monaco-editor/editor/editor.worker.js?worker');
if (disposed) return;
type WorkerHost = typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
};
(globalThis as WorkerHost).MonacoEnvironment ??= {
getWorker: () => new EditorWorker(),
};
const api = await import('monaco-editor');
if (disposed) return;
monaco = api;
ensureLanguage(api);
if (!host) return;
currentValue = value;
updateStats(currentValue);
model = api.editor.createModel(currentValue, LANGUAGE_ID);
editor = api.editor.create(host, {
model,
theme: THEME_ID,
ariaLabel: 'Custom Svelte component source',
automaticLayout: true,
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
fontSize: 11,
lineHeight: 18,
fontLigatures: true,
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: true,
folding: true,
minimap: { enabled: false },
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
insertSpaces: true,
scrollBeyondLastLine: false,
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'line',
fixedOverflowWidgets: true,
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
suggest: { showWords: false, preview: true },
wordBasedSuggestions: 'off',
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
});
editor.onDidChangeModelContent(() => {
const next = model?.getValue() ?? '';
currentValue = next;
updateStats(next);
scheduleValidation(next);
onchange?.(next);
});
editor.onDidChangeCursorPosition((event) => {
cursorLine = event.position.lineNumber;
cursorColumn = event.position.column;
});
disposeIntellisense = registerIntellisense(api);
ready = true;
scheduleValidation(currentValue, true);
editor.focus();
} catch (error) {
console.error('Could not load the Monaco Svelte editor', error);
loadError = error instanceof Error ? error.message : String(error);
scheduleValidation(value, true);
}
})();
return () => {
disposed = true;
if (validateTimer) clearTimeout(validateTimer);
disposeIntellisense?.();
editor?.dispose();
model?.dispose();
editor = undefined;
model = undefined;
monaco = undefined;
};
});
function toggleExpanded() {
expanded = !expanded;
requestAnimationFrame(() => {
editor?.layout();
editor?.focus();
});
}
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape' && expanded) {
event.preventDefault();
expanded = false;
}
}}
/>
{#if expanded}
<button
type="button"
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close expanded component editor"
onclick={() => (expanded = false)}
></button>
{/if}
<div
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
: 'relative rounded-md'}"
style={expanded ? '' : `height:${height}px`}
>
<div
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
>
<Component class="size-3 shrink-0 text-primary" />
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
Svelte component
</span>
<span class="ml-auto flex items-center gap-1.5">
{#if loadError}
<span class="text-[8px] text-destructive">Editor unavailable</span>
{:else if checking}
<span class="text-[8px] text-muted-foreground">Checking…</span>
{:else if errorCount > 0}
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
<CircleAlert class="size-2.5" />
{errorCount}
</span>
{:else}
<span class="text-[8px] text-emerald-400">Ready</span>
{/if}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
onclick={toggleExpanded}
>
{#if expanded}
<Minimize2 class="size-3" />
{:else}
<Maximize2 class="size-3" />
{/if}
</button>
{#if expanded}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Close editor"
aria-label="Close expanded editor"
onclick={() => (expanded = false)}
>
<X class="size-3" />
</button>
{/if}
</span>
</div>
{#if loadError}
<textarea
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
spellcheck={false}
{value}
placeholder={THEME_SVELTE_COMPONENT_PLACEHOLDER}
oninput={(event) => {
const next = (event.currentTarget as HTMLTextAreaElement).value;
onchange?.(next);
scheduleValidation(next);
}}
></textarea>
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
Monaco could not load: {loadError}
</p>
{:else}
<div class="relative min-h-0 flex-1">
<div bind:this={host} class="absolute inset-0"></div>
{#if !ready}
<div
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
>
Loading editor…
</div>
{/if}
</div>
{/if}
{#if diagnostics.length > 0}
<div class="max-h-24 shrink-0 overflow-y-auto border-t border-theme-800 bg-theme-950/70">
{#each diagnostics.slice(0, 8) as diagnostic, index (index)}
<div
class="flex items-start gap-1.5 border-b border-theme-800/60 px-2 py-1 last:border-b-0"
>
<CircleAlert
class="mt-px size-2.5 shrink-0 {diagnostic.severity === 'error'
? 'text-destructive'
: 'text-amber-300'}"
/>
<p class="min-w-0 flex-1 text-[8px] leading-relaxed text-theme-100">
{diagnostic.message}
</p>
<button
type="button"
class="shrink-0 font-mono text-[8px] text-muted-foreground hover:text-primary"
title="Jump to this line"
onclick={() => {
editor?.setPosition({ lineNumber: diagnostic.line, column: diagnostic.column });
editor?.revealLineInCenter(diagnostic.line);
editor?.focus();
}}
>
{diagnostic.line}:{diagnostic.column}
</button>
</div>
{/each}
</div>
{/if}
<div
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
>
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
<span>Ln {cursorLine}, Col {cursorColumn}</span>
<span class="ml-auto">
{#if checking}
Checking…
{:else if errorCount > 0}
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
{#if warningCount > 0}
<span class="text-amber-300">
· {warningCount} warning{warningCount === 1 ? '' : 's'}</span
>
{/if}
{:else if warningCount > 0}
<span class="text-amber-300">{warningCount} warning{warningCount === 1 ? '' : 's'}</span>
{:else}
No errors
{/if}
</span>
</div>
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { themeClassTokens } from '$lib/themeV2';
import { Code2, RotateCcw } from '@lucide/svelte';
let {
idPrefix,
value,
onchange,
}: {
idPrefix: string;
value?: string;
onchange: (value: string | undefined) => void;
} = $props();
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const invalidTokens = $derived(
typeof value === 'string'
? value
.trim()
.split(/\s+/)
.filter(Boolean)
.filter((token) => themeClassTokens(token).length === 0)
: []
);
function setValue(raw: string) {
onchange(raw.trim() ? raw : undefined);
}
</script>
{#snippet resetAction()}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground"
disabled={!value}
onclick={() => onchange(undefined)}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="CSS classes"
icon={Code2}
open={false}
actions={resetAction}
contentClass="space-y-2 pt-2.5"
>
<div class="space-y-1">
<Label for={`${idPrefix}-css-classes`} class={label}>Classes</Label>
<Input
id={`${idPrefix}-css-classes`}
class="h-8 border-theme-800 bg-theme-950 font-mono text-xs text-theme-50"
value={value ?? ''}
placeholder="glow-card hero-title"
spellcheck="false"
autocomplete="off"
oninput={(event) => setValue(event.currentTarget.value)}
/>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Separate multiple classes with spaces and style them from the theme's Custom CSS, for example
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">.glow-card</code>.
</p>
{#if invalidTokens.length > 0}
<p class="text-[10px] leading-relaxed text-amber-400">
Ignored: <span class="font-mono">{invalidTokens.join(' ')}</span>. Use letters, numbers,
dashes and underscores.
</p>
{/if}
</ThemeSection>
@@ -0,0 +1,132 @@
<script lang="ts">
import { compileThemeSvelte } from '$lib/themeSvelte';
import { themeAssetUrl, type ThemeV2ScriptValue } from '$lib/themeV2';
import { TriangleAlert } from '@lucide/svelte';
import { mount, unmount } from 'svelte';
let {
source,
assets = '',
assetOverrides = {},
variables = {},
screen = 'home',
layerId,
layerName,
width,
height,
themeName = '',
themeAuthor = '',
accentColor = '#ff006e',
interactive = false,
}: {
source: string;
assets?: string;
assetOverrides?: Record<string, string>;
variables?: Record<string, ThemeV2ScriptValue>;
screen?: string;
layerId: string;
layerName?: string;
width: number;
height: number;
themeName?: string;
themeAuthor?: string;
accentColor?: string;
interactive?: boolean;
} = $props();
type ComponentInstance = ReturnType<typeof mount>;
let host = $state<HTMLDivElement>();
let error = $state('');
let loading = $state(true);
let instance: ComponentInstance | undefined;
let generation = 0;
// A $state proxy can be passed to mount() so $props() in the custom
// component remains reactive when a logic rule changes a variable.
const componentProps = $state<Record<string, unknown>>({
variables: {},
screen: 'home',
layer: { id: '', name: undefined, width: 0, height: 0 },
theme: { name: '', author: '', accentColor: '#ff006e' },
asset: (path: string) => path,
});
const errorMessage = (value: unknown) =>
value instanceof Error ? `${value.name}: ${value.message}` : String(value);
$effect(() => {
componentProps.variables = { ...variables };
componentProps.screen = screen;
componentProps.layer = { id: layerId, name: layerName, width, height };
componentProps.theme = { name: themeName, author: themeAuthor, accentColor };
componentProps.asset = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
});
$effect(() => {
const current = ++generation;
const currentSource = source;
let mounted: ComponentInstance | undefined;
loading = true;
void (async () => {
const result = await compileThemeSvelte(currentSource, `layer-${layerId}.svelte`);
if (current !== generation || !host) return;
if (!result.component) {
error = result.errors[0]?.message ?? 'The custom component could not be compiled.';
loading = false;
return;
}
try {
mounted = mount(result.component, { target: host, props: componentProps });
instance = mounted;
error = '';
} catch (mountError) {
error = errorMessage(mountError);
} finally {
loading = false;
}
})();
return () => {
// Invalidate a compile that is still in flight so it cannot mount into
// a host that has already been removed with this layer.
generation += 1;
loading = false;
if (mounted) {
void unmount(mounted, { outro: false });
if (instance === mounted) instance = undefined;
}
};
});
$effect(() => {
if (!interactive || !error) return;
console.error(`[theme] Svelte layer ${layerId} failed`, error);
});
</script>
<div class="relative h-full w-full">
<div bind:this={host} class="h-full w-full"></div>
{#if interactive && loading}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-theme-700 bg-theme-950/65 text-[10px] text-muted-foreground"
>
Compiling Svelte…
</div>
{/if}
{#if interactive && error}
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-destructive/50 bg-destructive/12 p-3 text-center"
>
<TriangleAlert class="size-4 shrink-0 text-destructive" />
<p class="text-[10px] font-semibold text-destructive">Svelte layer failed</p>
<p class="line-clamp-4 font-mono text-[9px] leading-relaxed text-destructive/90">{error}</p>
<p class="text-[8px] text-muted-foreground">Fix the error in the layer inspector.</p>
</div>
{/if}
</div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Slider from '$lib/components/ui/slider/slider.svelte';
import type { ThemeV2Filters } from '$lib/themeV2';
let {
filters,
onchange,
}: {
filters?: ThemeV2Filters;
onchange: (filters: ThemeV2Filters) => void;
} = $props();
type Control = {
key: keyof ThemeV2Filters;
label: string;
min: number;
max: number;
step: number;
fallback: number;
unit: string;
};
const controls: Control[] = [
{ key: 'blur', label: 'Blur', min: 0, max: 40, step: 1, fallback: 0, unit: 'px' },
{ key: 'brightness', label: 'Brightness', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
{ key: 'contrast', label: 'Contrast', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
{ key: 'saturate', label: 'Saturation', min: 0, max: 4, step: 0.05, fallback: 1, unit: '' },
{
key: 'hueRotate',
label: 'Hue rotate',
min: -360,
max: 360,
step: 1,
fallback: 0,
unit: 'deg',
},
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
{ key: 'sepia', label: 'Sepia', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
{ key: 'invert', label: 'Invert', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
];
const valueOf = (control: Control) => filters?.[control.key] ?? control.fallback;
const display = (control: Control) => {
const value = valueOf(control);
return `${control.step < 1 ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)}${control.unit}`;
};
const isDefault = $derived(controls.every((control) => valueOf(control) === control.fallback));
function setValue(key: keyof ThemeV2Filters, value: number) {
onchange({ ...(filters ?? {}), [key]: value });
}
</script>
{#snippet filterActions()}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground"
disabled={isDefault}
onclick={() => onchange({})}
>
Reset
</Button>
{/snippet}
<ThemeSection
title="Filters"
open={false}
actions={filterActions}
contentClass="space-y-2.5 pt-2.5"
>
<div class="space-y-2.5">
{#each controls as control (control.key)}
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="text-[10px] text-muted-foreground">{control.label}</span>
<span class="font-mono text-[9px] text-theme-100">{display(control)}</span>
</div>
<Slider
min={control.min}
max={control.max}
step={control.step}
value={valueOf(control)}
onValueChange={(value) => setValue(control.key, value)}
/>
</div>
{/each}
</div>
</ThemeSection>
@@ -0,0 +1,270 @@
<script lang="ts">
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import ThemeTransformControls from '$lib/components/theme/ThemeTransformControls.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import Slider from '$lib/components/ui/slider/slider.svelte';
import {
type ThemeV2Behavior,
type ThemeV2BehaviorTarget,
type ThemeV2Element,
type ThemeV2Group,
type ThemeV2MaskShape,
} from '$lib/themeV2';
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
let {
group = $bindable(),
behaviors,
elements,
groups,
variables = [],
focusToken = 0,
onungroup,
onpickmask,
onaddbehavior,
ondeleterule,
}: {
group: ThemeV2Group;
behaviors: ThemeV2Behavior[];
elements: ThemeV2Element[];
groups: ThemeV2Group[];
variables?: string[];
focusToken?: number;
onungroup: () => void;
onpickmask: () => void;
onaddbehavior: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
ondeleterule: (id: string) => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
</script>
<div class="space-y-3 p-3">
<div class="flex items-center gap-2 border-b border-theme-800 pb-2.5">
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
<Boxes class="size-4" />
</div>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-theme-50">Group</p>
<p class="truncate font-mono text-[10px] text-muted-foreground">{group.id}</p>
</div>
</div>
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
<div class="space-y-1">
<Label for="group-name" class={label}>Group name</Label>
<Input
id="group-name"
class={field}
value={group.name ?? ''}
placeholder="Group name"
oninput={(event) => (group.name = (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Opacity</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round((group.opacity ?? 1) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={group.opacity ?? 1}
onValueChange={(value) => (group.opacity = value)}
/>
</div>
</ThemeSection>
<ThemeTransformControls
transform={group}
idPrefix="group"
includeTranslate
onchange={(patch) => Object.assign(group, patch)}
/>
<ThemeSection
title="Group state"
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
>
<div class="flex items-center gap-1.5">
<Checkbox
id="group-hidden"
checked={group.hidden === true}
onCheckedChange={(checked) => (group.hidden = checked === true)}
/>
<Label for="group-hidden" class="text-[10px] font-normal text-muted-foreground">Hidden</Label>
</div>
<div class="flex items-center gap-1.5">
<Checkbox
id="group-locked"
checked={group.locked === true}
onCheckedChange={(checked) => (group.locked = checked === true)}
/>
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
</div>
<div
class="flex items-center gap-1.5"
title="Smoothly morph text and number changes across layers in this group using Torph."
>
<Checkbox
id="group-morph"
checked={group.morph === true}
onCheckedChange={(checked) => (group.morph = checked === true)}
/>
<Label for="group-morph" class="text-[10px] font-normal text-muted-foreground">
Morph text (Torph)
</Label>
</div>
{#if group.morph}
<div class="w-full space-y-1 pt-1">
<div class="flex items-center justify-between">
<Label for="group-morph-duration" class={label}>Morph duration</Label>
<span class="text-[10px] text-muted-foreground">{group.morphDuration ?? 400}ms</span>
</div>
<Input
id="group-morph-duration"
class={field}
type="number"
min="50"
max="3000"
step="50"
value={group.morphDuration ?? 400}
oninput={(event) => {
const val = Number((event.currentTarget as HTMLInputElement).value);
group.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
}}
/>
</div>
{/if}
</ThemeSection>
<ThemeCssClasses
idPrefix="group"
value={group.cssClasses}
onchange={(value) => (group.cssClasses = value)}
/>
<ThemeLinkedLogic
target={{ type: 'group', id: group.id }}
{behaviors}
{elements}
{groups}
{variables}
{focusToken}
onadd={onaddbehavior}
ondelete={ondeleterule}
/>
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="group-mask-shape" class={label}>Clip shape</Label>
<Select.Root
type="single"
value={group.mask?.shape ?? 'none'}
onValueChange={(value) => {
if (!value) return;
if (value === 'none') {
group.mask = undefined;
return;
}
group.mask = {
shape: value as ThemeV2MaskShape,
radius: group.mask?.radius ?? 24,
sides: group.mask?.sides ?? 6,
innerRadius: group.mask?.innerRadius ?? 0.5,
src: group.mask?.src,
fit: group.mask?.fit ?? 'cover',
};
}}
>
<Select.Trigger
id="group-mask-shape"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{group.mask?.shape ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="none" label="None" />
<Select.Item value="rectangle" label="Rectangle" />
<Select.Item value="ellipse" label="Ellipse" />
<Select.Item value="polygon" label="Polygon" />
<Select.Item value="star" label="Star" />
<Select.Item value="image" label="Image alpha" />
</Select.Content>
</Select.Root>
</div>
{#if group.mask?.shape === 'polygon' || group.mask?.shape === 'star'}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="group-mask-sides" class={label}>Points</Label>
<Input
id="group-mask-sides"
class={field}
type="number"
min="3"
max="24"
value={group.mask.sides ?? 6}
oninput={(event) => {
if (group.mask) {
group.mask.sides = Number((event.currentTarget as HTMLInputElement).value) || 3;
}
}}
/>
</div>
{#if group.mask.shape === 'star'}
<div class="space-y-1">
<Label for="group-mask-inner" class={label}>Inner radius</Label>
<Input
id="group-mask-inner"
class={field}
type="number"
min="0.1"
max="0.95"
step="0.05"
value={group.mask.innerRadius ?? 0.5}
oninput={(event) => {
if (group.mask) {
group.mask.innerRadius =
Number((event.currentTarget as HTMLInputElement).value) || 0.5;
}
}}
/>
</div>
{/if}
</div>
{/if}
{#if group.mask?.shape === 'image'}
<div class="space-y-2 rounded-md border border-theme-800 bg-black/20 p-2">
<p class="truncate font-mono text-[10px] text-theme-100">
{group.mask.src ?? 'No mask image yet'}
</p>
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onpickmask}>
<RefreshCw class="size-3.5" />
Choose mask image
</Button>
</div>
{/if}
<p class="text-[9px] leading-relaxed text-muted-foreground">
The mask clips every layer in this group. Image masks use the picture's alpha channel.
</p>
</ThemeSection>
<ThemeFilterControls filters={group.filters} onchange={(filters) => (group.filters = filters)} />
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onungroup}>
<Ungroup class="size-3.5" />
Ungroup group
</Button>
</div>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { getThemeIconBody } from '$lib/themeIconCatalog';
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
let {
name,
set = DEFAULT_THEME_ICON_SET,
size = '1em',
class: className = '',
style = '',
}: {
name: ThemeIconName;
set?: ThemeIconSet;
size?: number | string;
class?: string;
style?: string;
} = $props();
const icon = $derived(getThemeIconBody(name, set));
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${icon.width} ${icon.height}`}
width={size}
height={size}
class={className}
{style}
aria-hidden="true"
focusable="false"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html icon.body}
</svg>
@@ -0,0 +1,393 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import * as Popover from '$lib/components/ui/popover';
import {
getFullIconCatalog,
type IconPackKey,
type ThemeIconCatalogItem,
} from '$lib/themeIconCatalog';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import ThemeIcon from './ThemeIcon.svelte';
import { Check, ChevronsUpDown, LayoutGrid, List, Search, X } from '@lucide/svelte';
let {
value = $bindable(undefined),
iconSet = DEFAULT_THEME_ICON_SET,
placeholder = 'Select icon...',
disabled = false,
allowNone = false,
onchange,
}: {
value?: string;
iconSet?: ThemeIconSet;
placeholder?: string;
disabled?: boolean;
allowNone?: boolean;
onchange?: (val: string | undefined) => void;
} = $props();
let open = $state(false);
let searchQuery = $state('');
let selectedPack = $state<IconPackKey | 'all'>('all');
let viewMode = $state<'grid' | 'list'>('grid');
let scrollContainer = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
const containerHeight = 280;
const gridColumns = 5;
const gridRowHeight = 54;
const listRowHeight = 34;
const overscan = 4;
const rowHeight = $derived(viewMode === 'grid' ? gridRowHeight : listRowHeight);
const catalog = getFullIconCatalog();
const packTabs: { key: IconPackKey | 'all'; label: string; count: number }[] = [
{ key: 'all', label: 'All', count: catalog.length },
{ key: 'launcher', label: 'Launcher', count: 47 },
{ key: 'lucide', label: 'Lucide', count: 2078 },
{ key: 'phosphor', label: 'Phosphor', count: 9161 },
{ key: 'tabler', label: 'Tabler', count: 6305 },
];
const filteredItems = $derived.by(() => {
let items = catalog;
if (selectedPack !== 'all') {
items = items.filter((it) => it.pack === selectedPack);
}
const q = searchQuery.trim().toLowerCase();
if (q) {
items = items.filter(
(it) => it.name.toLowerCase().includes(q) || it.id.toLowerCase().includes(q)
);
}
return items;
});
const numRows = $derived(
viewMode === 'grid' ? Math.ceil(filteredItems.length / gridColumns) : filteredItems.length
);
const totalHeight = $derived(numRows * rowHeight);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan));
const endIndex = $derived(
Math.min(numRows, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan)
);
type VirtualRow = {
index: number;
items?: ThemeIconCatalogItem[];
item?: ThemeIconCatalogItem;
};
const visibleRows = $derived.by(() => {
const rows: VirtualRow[] = [];
if (viewMode === 'grid') {
for (let i = startIndex; i < endIndex; i++) {
const rowItems = filteredItems.slice(i * gridColumns, (i + 1) * gridColumns);
rows.push({ index: i, items: rowItems });
}
} else {
for (let i = startIndex; i < endIndex; i++) {
const item = filteredItems[i];
if (item) {
rows.push({ index: i, item });
}
}
}
return rows;
});
$effect(() => {
// When search, pack, or view mode changes, reset scroll to top
void searchQuery;
void selectedPack;
void viewMode;
if (scrollContainer) {
scrollContainer.scrollTop = 0;
scrollTop = 0;
}
});
function selectIcon(id: string | undefined) {
if (id === 'none' || !id) {
value = undefined;
onchange?.(undefined);
} else {
value = id;
onchange?.(id);
}
open = false;
}
function displayName(val: string | undefined): string {
if (!val || val === 'none') return 'None';
if (val.includes(':')) {
return val.slice(val.indexOf(':') + 1);
}
return val;
}
function displayPack(val: string | undefined): string | null {
if (!val || val === 'none') return null;
if (val.startsWith('lucide:')) return 'Lucide';
if (val.startsWith('ph:')) return 'Phosphor';
if (val.startsWith('tabler:')) return 'Tabler';
return 'Launcher';
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-800 bg-theme-950 px-2.5 text-xs text-theme-50 transition-colors hover:bg-theme-900/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{#if !value || value === 'none'}
<span class="flex items-center gap-2 text-muted-foreground truncate">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>{allowNone ? 'None' : placeholder}</span>
</span>
{:else}
<span class="flex items-center gap-2 min-w-0 truncate">
<span
class="flex size-5 shrink-0 items-center justify-center rounded bg-theme-900 border border-theme-800 text-theme-200"
>
<ThemeIcon name={value} set={iconSet} size={15} />
</span>
<span class="truncate font-mono text-xs text-theme-100">{displayName(value)}</span>
{#if displayPack(value)}
<span
class="rounded bg-theme-800/80 px-1 py-0.5 text-[8px] uppercase tracking-wide text-muted-foreground shrink-0"
>
{displayPack(value)}
</span>
{/if}
</span>
{/if}
<ChevronsUpDown class="size-3.5 shrink-0 text-muted-foreground opacity-60" />
</Popover.Trigger>
<Popover.Content
align="start"
side="bottom"
sideOffset={4}
class="w-[360px] max-w-[95vw] rounded-lg border border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
>
<!-- Search Bar -->
<div class="border-b border-theme-800/80 p-2">
<div class="relative flex items-center">
<Search class="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none" />
<Input
bind:value={searchQuery}
placeholder="Search 17,000+ icons..."
class="h-8 pl-8 pr-7 text-xs border-theme-800 bg-theme-900/60 placeholder:text-muted-foreground"
autofocus
/>
{#if searchQuery}
<button
type="button"
class="absolute right-2 text-muted-foreground hover:text-theme-100 transition-colors"
onclick={() => (searchQuery = '')}
>
<X class="size-3.5" />
</button>
{/if}
</div>
</div>
<!-- Pack Filter Tabs & View Toggle -->
<div
class="flex items-center justify-between gap-1 border-b border-theme-800/60 bg-theme-900/30 px-2 py-1.5"
>
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar py-0.5">
{#each packTabs as tab (tab.key)}
<button
type="button"
class="shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {selectedPack ===
tab.key
? 'bg-primary/20 text-primary border border-primary/40 font-semibold'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-200 border border-transparent'}"
onclick={() => (selectedPack = tab.key)}
>
{tab.label}
</button>
{/each}
</div>
<div class="flex items-center gap-0.5 shrink-0 pl-1 border-l border-theme-800/60">
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'grid'
? 'bg-theme-800 text-theme-100'
: ''}"
title="Grid view"
onclick={() => (viewMode = 'grid')}
>
<LayoutGrid class="size-3.5" />
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'list'
? 'bg-theme-800 text-theme-100'
: ''}"
title="List view"
onclick={() => (viewMode = 'list')}
>
<List class="size-3.5" />
</button>
</div>
</div>
<!-- Allow "None" option if requested -->
{#if allowNone}
<div class="border-b border-theme-800/50 px-2 py-1 bg-theme-900/20">
<button
type="button"
class="flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors {value ===
'none' || !value
? 'bg-primary/20 text-primary font-medium'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-100'}"
onclick={() => selectIcon('none')}
>
<span class="flex items-center gap-2">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>None (No icon)</span>
</span>
{#if value === 'none' || !value}
<Check class="size-3 text-primary" />
{/if}
</button>
</div>
{/if}
<!-- Virtual Scrolling List/Grid -->
<div
bind:this={scrollContainer}
class="overflow-y-auto px-2 py-1 select-none"
style="height: {containerHeight}px;"
onscroll={(e) => {
scrollTop = (e.currentTarget as HTMLElement).scrollTop;
}}
>
{#if filteredItems.length === 0}
<div
class="flex flex-col items-center justify-center py-12 text-center text-xs text-muted-foreground"
>
<p>No icons found</p>
{#if searchQuery}
<p class="text-[10px] text-muted-foreground/80 mt-0.5 font-mono">"{searchQuery}"</p>
{/if}
<Button
variant="ghost"
size="sm"
class="mt-3 h-7 text-[10px]"
onclick={() => {
searchQuery = '';
selectedPack = 'all';
}}
>
Clear filters
</Button>
</div>
{:else}
<div style="height: {totalHeight}px; position: relative;">
<div
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startIndex *
rowHeight}px);"
>
{#if viewMode === 'grid'}
{#each visibleRows as row (row.index)}
<div class="grid grid-cols-5 gap-1.5" style="height: {rowHeight}px;">
{#each row.items ?? [] as item (item.id)}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<button
type="button"
class="group relative flex flex-col items-center justify-center rounded-md p-1 border transition-all text-center
{isSelected
? 'bg-primary/20 border-primary text-primary shadow-xs'
: 'border-transparent bg-theme-900/40 hover:bg-theme-800/80 hover:border-theme-700 text-theme-200 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex size-5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={18} />
</div>
<span
class="mt-0.5 w-full truncate text-[9px] font-mono leading-tight opacity-75 group-hover:opacity-100"
>
{item.name}
</span>
{#if isSelected}
<div
class="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
></div>
{/if}
</button>
{/each}
</div>
{/each}
{:else}
{#each visibleRows as row (row.index)}
{#if row.item}
{@const item = row.item}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<div style="height: {rowHeight}px;" class="flex items-center">
<button
type="button"
class="flex w-full h-7 items-center justify-between rounded px-2 text-xs transition-colors
{isSelected
? 'bg-primary/20 text-primary font-medium'
: 'text-theme-200 hover:bg-theme-800/70 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex size-4.5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={16} />
</div>
<span class="truncate font-mono text-[11px]">{item.name}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<span
class="text-[9px] uppercase px-1 py-0.2 rounded bg-theme-900/80 border border-theme-800/80 text-muted-foreground font-mono"
>
{item.packLabel}
</span>
{#if isSelected}
<Check class="size-3 text-primary" />
{/if}
</div>
</button>
</div>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer Stats Bar -->
<div
class="flex items-center justify-between border-t border-theme-800/70 bg-theme-900/40 px-3 py-1.5 text-[10px] text-muted-foreground"
>
<span>{filteredItems.length.toLocaleString()} icons</span>
<span class="text-[9px]"
>Theme pack: <span class="capitalize text-theme-200">{iconSet}</span></span
>
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,195 @@
<script lang="ts">
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
import type {
ThemeV2Behavior,
ThemeV2BehaviorTarget,
ThemeV2Element,
ThemeV2Group,
} from '$lib/themeV2';
import { AlertTriangle, ChevronDown, Plus, Zap } from '@lucide/svelte';
let {
target,
behaviors,
elements,
groups,
variables = [],
focusToken = 0,
onadd,
ondelete,
}: {
target: ThemeV2BehaviorTarget;
behaviors: ThemeV2Behavior[];
elements: ThemeV2Element[];
groups: ThemeV2Group[];
variables?: string[];
focusToken?: number;
onadd: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
ondelete: (id: string) => void;
} = $props();
let open = $state(false);
let selectedId = $state<string | undefined>(undefined);
let section = $state<HTMLElement>();
let lastFocusToken = 0;
const linkedBehaviors = $derived(
behaviors.filter(
(behavior) => behavior.target?.type === target.type && behavior.target?.id === target.id
)
);
const pointerRuleCount = $derived(
linkedBehaviors.filter(
(behavior) =>
behavior.trigger.type === 'click' ||
behavior.trigger.type === 'hover' ||
behavior.trigger.type === 'leave'
).length
);
const interactiveTargets = $derived(
target.type === 'group'
? themeGroupInteractiveCount(groups, elements, target.id)
: elements.find((element) => element.id === target.id)?.interactive
? 1
: 0
);
const needsInteractive = $derived(pointerRuleCount > 0 && interactiveTargets === 0);
$effect(() => {
if (!focusToken || focusToken === lastFocusToken) return;
lastFocusToken = focusToken;
open = true;
if (linkedBehaviors.length === 1) selectedId = linkedBehaviors[0].id;
requestAnimationFrame(() => section?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
});
function addRule() {
const behavior = onadd(target);
open = true;
if (behavior) selectedId = behavior.id;
}
function deleteRule(id: string) {
ondelete(id);
if (selectedId === id) selectedId = undefined;
}
</script>
<section bind:this={section} class="border-t border-theme-800 pt-2">
<div class="flex items-center gap-1.5">
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-theme-900/60"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Zap class="size-3.5 shrink-0 text-primary" />
<span class="text-[11px] font-semibold text-theme-100">Logic</span>
{#if linkedBehaviors.length > 0}
<span class="rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] text-muted-foreground">
{linkedBehaviors.length}
</span>
{/if}
{#if needsInteractive}
<span title="A linked pointer rule needs an interactive layer">
<AlertTriangle class="size-3 text-amber-300" />
</span>
{/if}
<ChevronDown
class="ml-auto size-3 shrink-0 text-muted-foreground transition-transform {open
? 'rotate-180'
: ''}"
/>
</button>
<Button
variant="secondary"
size="sm"
class="h-6 shrink-0 gap-1 px-1.5 text-[9px]"
title="Add a rule linked to this layer or group"
onclick={addRule}
>
<Plus class="size-3" />
Add
</Button>
</div>
{#if open}
<div class="mt-1.5 space-y-1.5">
{#if linkedBehaviors.length === 0}
<p
class="rounded-md border border-dashed border-theme-800 px-2 py-1.5 text-[9px] leading-relaxed text-muted-foreground"
>
No rules linked yet. Add one to react to clicks, hovers, timers or screen changes.
</p>
{:else}
{#each linkedBehaviors as behavior (behavior.id)}
<div class="overflow-hidden rounded-md border border-theme-800 bg-black/20">
<button
type="button"
class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left transition-colors hover:bg-theme-900/60"
aria-expanded={selectedId === behavior.id}
onclick={() => (selectedId = selectedId === behavior.id ? undefined : behavior.id)}
>
<span
class="size-1.5 shrink-0 rounded-full {behavior.enabled === false
? 'bg-theme-600'
: 'bg-emerald-400'}"
></span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5">
<span class="truncate text-[10px] font-medium text-theme-100">
{behavior.name?.trim() || 'Rule'}
</span>
{#if behavior.once}
<span class="rounded bg-theme-900 px-1 py-0.5 text-[8px] text-muted-foreground">
once
</span>
{/if}
</span>
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
{themeBehaviorSummary(behavior, elements, groups)}
</span>
</span>
<ChevronDown
class="size-3 shrink-0 text-muted-foreground transition-transform {selectedId ===
behavior.id
? 'rotate-180'
: ''}"
/>
</button>
{#if selectedId === behavior.id}
<div class="border-t border-theme-800 bg-theme-950/60">
<ThemeBehaviorInspector
{behavior}
{elements}
{groups}
{variables}
embedded
ondelete={() => deleteRule(behavior.id)}
/>
</div>
{/if}
</div>
{/each}
{/if}
{#if needsInteractive}
<div
class="flex items-start gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-1.5"
>
<AlertTriangle class="mt-0.5 size-3 shrink-0 text-amber-300" />
<p class="text-[9px] leading-relaxed text-amber-100/80">
{#if target.type === 'group'}
Mark at least one layer in this group as <strong>Interactive</strong>.
{:else}
Enable <strong>Interactive</strong> for this layer.
{/if}
</p>
</div>
{/if}
</div>
{/if}
</section>
@@ -0,0 +1,47 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import { AlertTriangle, Zap } from '@lucide/svelte';
let {
count,
warning = false,
onopen,
}: {
count: number;
warning?: boolean;
onopen: () => void;
} = $props();
const title = $derived(
warning
? `${count} linked logic rule${count === 1 ? '' : 's'} · needs an interactive layer · Open in inspector`
: `${count} linked logic rule${count === 1 ? '' : 's'} · Open in inspector`
);
</script>
{#if count > 0}
<button
type="button"
class="-m-0.5 shrink-0 cursor-pointer rounded-full p-0.5 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
{title}
aria-label={title}
onclick={(event) => {
event.stopPropagation();
onopen();
}}
>
<Badge
variant="outline"
class="pointer-events-none h-4 gap-0.5 border px-1 py-0 text-[8px] font-semibold leading-none {warning
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
: 'border-primary/25 bg-primary/10 text-primary'}"
>
{#if warning}
<AlertTriangle class="size-2.5" />
{:else}
<Zap class="size-2.5" />
{/if}
{count}
</Badge>
</button>
{/if}
@@ -0,0 +1,72 @@
<script lang="ts">
import lottie, { type AnimationItem } from 'lottie-web';
import { onDestroy, onMount } from 'svelte';
let {
src,
loop = true,
autoplay = true,
speed = 1,
}: {
src: string;
loop?: boolean;
autoplay?: boolean;
speed?: number;
} = $props();
let container = $state<HTMLDivElement | undefined>(undefined);
let animation: AnimationItem | undefined;
let reducedMotion = false;
onMount(() => {
if (!container || !src) return;
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
let cancelled = false;
const start = (animationData?: unknown) => {
if (cancelled || !container) return;
try {
animation = lottie.loadAnimation({
container,
renderer: 'svg',
loop,
autoplay: autoplay && !reducedMotion,
...(animationData ? { animationData } : { path: src }),
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
});
animation.setSpeed(speed);
if (reducedMotion) {
animation.addEventListener('DOMLoaded', () => animation?.goToAndStop(0, true));
}
} catch (error) {
console.warn('Could not load the Lottie layer', error);
}
};
// Fetching the JSON ourselves works with Tauri's asset protocols, where
// lottie-web's internal XHR can be blocked. Fall back to its own loader.
void fetch(src)
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((data) => start(data))
.catch(() => start());
return () => {
cancelled = true;
};
});
$effect(() => {
if (!animation) return;
animation.loop = loop;
animation.setSpeed(speed);
if (autoplay && !reducedMotion) animation.play();
else animation.pause();
});
onDestroy(() => {
animation?.destroy();
animation = undefined;
});
</script>
<div bind:this={container} class="h-full w-full" aria-hidden="true"></div>
@@ -0,0 +1,890 @@
<script lang="ts">
import { isThemeClockVariable, THEME_CLOCK_VARIABLE_NAMES } from '$lib/themeClock';
import { parseThemeScript } from '$lib/themeScriptLang';
import type { ThemeV2Element, ThemeV2Group, ThemeV2ScriptScreen } from '$lib/themeV2';
import { Braces, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
import type * as Monaco from 'monaco-editor';
import { onMount } from 'svelte';
type MonacoApi = typeof import('monaco-editor');
let {
value = '',
onchange,
variables = [],
elements = [],
groups = [],
height = 280,
}: {
value?: string;
onchange?: (value: string) => void;
variables?: string[];
elements?: ThemeV2Element[];
groups?: ThemeV2Group[];
height?: number;
} = $props();
const LANGUAGE_ID = 'ezpp-theme-script';
const THEME_ID = 'ezpp-theme-script-dark';
const MARKER_OWNER = 'ezpp-theme-script';
const screens: ThemeV2ScriptScreen[] = ['home', 'themes', 'settings', 'login'];
const statementDocs: Record<string, string> = {
let: 'Declare a theme variable.\n\n`let score = 0;`',
const: 'Declare a value that will not be reassigned.\n\n`const bonus = 10;`',
var: 'Declare a theme variable using the older JavaScript keyword.\n\n`var score = 0;`',
if: 'Run a block only when a condition is true.\n\n`if (score >= 10) { ... }`',
else: 'Run a block when the `if` condition is false.\n\n`else { ... }`',
for: 'Run a bounded loop.\n\n`for (let i = 0; i < 3; i++) { ... }`',
while: 'Run while a condition stays true. Loops stop after 1,000 iterations.',
true: 'Boolean true.',
false: 'Boolean false.',
screen: 'The current launcher screen, such as `"home"` or `"themes"`.',
Math: 'JavaScript math helpers such as `Math.floor(value)` and `Math.random()`.',
};
const actionFunctions: {
name: string;
label: string;
insert: string;
documentation: string;
}[] = [
{
name: 'showLayer',
label: 'showLayer(id)',
insert: 'showLayer(${1:"layer_id"});',
documentation: 'Make a layer visible.',
},
{
name: 'hideLayer',
label: 'hideLayer(id)',
insert: 'hideLayer(${1:"layer_id"});',
documentation: 'Hide a layer.',
},
{
name: 'toggleLayer',
label: 'toggleLayer(id)',
insert: 'toggleLayer(${1:"layer_id"});',
documentation: 'Flip a layer between visible and hidden.',
},
{
name: 'showGroup',
label: 'showGroup(id)',
insert: 'showGroup(${1:"group_id"});',
documentation: 'Make a group visible.',
},
{
name: 'hideGroup',
label: 'hideGroup(id)',
insert: 'hideGroup(${1:"group_id"});',
documentation: 'Hide a group.',
},
{
name: 'toggleGroup',
label: 'toggleGroup(id)',
insert: 'toggleGroup(${1:"group_id"});',
documentation: 'Flip a group between visible and hidden.',
},
{
name: 'goTo',
label: 'goTo(screen)',
insert: 'goTo(${1:"themes"});',
documentation: 'Navigate to `home`, `themes`, `settings` or `login`.',
},
{
name: 'navigate',
label: 'navigate(screen)',
insert: 'navigate(${1:"themes"});',
documentation: 'Alias for `goTo`.',
},
{
name: 'openUrl',
label: 'openUrl(url)',
insert: 'openUrl(${1:"https://example.com"});',
documentation: 'Open a website after a confirmation dialog. Click triggers only.',
},
{
name: 'launch',
label: 'launch()',
insert: 'launch();',
documentation: 'Start the normal osu! launch flow. Click triggers only.',
},
{
name: 'stop',
label: 'stop()',
insert: 'stop();',
documentation: 'End the current script immediately.',
},
];
const functions: {
name: string;
label: string;
insert: string;
documentation: string;
}[] = [
{
name: 'min',
label: 'min(a, b, ...)',
insert: 'min(${1:a}, ${2:b})',
documentation: 'Returns the smallest number.',
},
{
name: 'max',
label: 'max(a, b, ...)',
insert: 'max(${1:a}, ${2:b})',
documentation: 'Returns the largest number.',
},
{
name: 'clamp',
label: 'clamp(value, min, max)',
insert: 'clamp(${1:value}, ${2:0}, ${3:100})',
documentation: 'Keeps a number inside a range.',
},
{
name: 'round',
label: 'round(value)',
insert: 'round(${1:value})',
documentation: 'Rounds to the nearest whole number.',
},
{
name: 'floor',
label: 'floor(value)',
insert: 'floor(${1:value})',
documentation: 'Rounds down.',
},
{
name: 'ceil',
label: 'ceil(value)',
insert: 'ceil(${1:value})',
documentation: 'Rounds up.',
},
{
name: 'abs',
label: 'abs(value)',
insert: 'abs(${1:value})',
documentation: 'Returns the absolute value.',
},
{
name: 'random',
label: 'random()',
insert: 'random()',
documentation: 'Returns a random number between 0 and 1.',
},
{
name: 'chance',
label: 'chance(percent)',
insert: 'chance(${1:25})',
documentation: 'Returns true with the given percentage chance.',
},
{
name: 'visible',
label: 'visible(layerId)',
insert: 'visible(${1:"layer_id"})',
documentation: 'Whether a layer is currently visible.',
},
{
name: 'groupVisible',
label: 'groupVisible(groupId)',
insert: 'groupVisible(${1:"group_id"})',
documentation: 'Whether a group is currently visible.',
},
{
name: 'contains',
label: 'contains(text, search)',
insert: 'contains(${1:text}, ${2:"search"})',
documentation: 'Whether text contains a smaller string.',
},
{
name: 'length',
label: 'length(text)',
insert: 'length(${1:text})',
documentation: 'Returns the number of characters in text.',
},
{
name: 'number',
label: 'number(value)',
insert: 'number(${1:value})',
documentation: 'Converts a value to a number.',
},
{
name: 'string',
label: 'string(value)',
insert: 'string(${1:value})',
documentation: 'Converts a value to text.',
},
{
name: 'boolean',
label: 'boolean(value)',
insert: 'boolean(${1:value})',
documentation: 'Converts a value to true or false.',
},
];
const snippets: { label: string; insert: string; documentation: string }[] = [
{
label: 'let … = …;',
insert: 'let ${1:name} = ${2:0};',
documentation: 'Declare a theme variable.',
},
{
label: 'if (…) { … }',
insert: 'if (${1:condition}) {\n\t$0\n}',
documentation: 'Run statements when a condition matches.',
},
{
label: 'if (…) { … } else { … }',
insert: 'if (${1:condition}) {\n\t$0\n} else {\n\t\n}',
documentation: 'Run one of two blocks.',
},
{
label: 'for (…) { … }',
insert: 'for (let ${1:i} = 0; ${1:i} < ${2:3}; ${1:i}++) {\n\t$0\n}',
documentation: 'Run a bounded loop.',
},
{
label: 'while (…) { … }',
insert: 'while (${1:condition}) {\n\t$0\n}',
documentation: 'Run while a condition stays true.',
},
{
label: 'clicks += 1;',
insert: '${1:clicks} += ${2:1};',
documentation: 'Add to a number variable.',
},
{
label: 'showLayer(…);',
insert: 'showLayer(${1:"layer_id"});',
documentation: 'Make a layer visible.',
},
{
label: 'hideLayer(…);',
insert: 'hideLayer(${1:"layer_id"});',
documentation: 'Hide a layer.',
},
{
label: 'toggleLayer(…);',
insert: 'toggleLayer(${1:"layer_id"});',
documentation: 'Flip a layer between visible and hidden.',
},
{
label: 'goTo(…);',
insert: 'goTo(${1:"themes"});',
documentation: 'Navigate to another launcher screen.',
},
{
label: 'openUrl(…);',
insert: 'openUrl(${1:"https://example.com"});',
documentation: 'Open a website after a confirmation dialog.',
},
];
let host = $state<HTMLDivElement | undefined>(undefined);
let monaco: MonacoApi | undefined;
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
let model: Monaco.editor.ITextModel | undefined;
let ready = $state(false);
let loadError = $state('');
let expanded = $state(false);
let lineCount = $state(1);
let cursorLine = $state(1);
let cursorColumn = $state(1);
let errorCount = $state(0);
let currentValue = '';
const ensureLanguage = (api: MonacoApi) => {
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
api.languages.register({ id: LANGUAGE_ID });
}
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
indentationRules: {
increaseIndentPattern: /\{[^}"']*$/,
decreaseIndentPattern: /^\s*\}/,
},
});
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
tokenizer: {
root: [
[/\s+/, 'white'],
[/\/\/.*$/, 'comment'],
[/\/\*/, { token: 'comment', next: '@comment' }],
[/"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
[/'/, { token: 'string.quote', bracket: '@open', next: '@singleString' }],
[/`/, { token: 'string.quote', bracket: '@open', next: '@templateString' }],
[
/\b(?:let|const|var|if|else|for|while|do|function|return|break|continue|switch|case|default|new|delete|typeof|instanceof|in|of|class|try|catch|finally|throw|this)\b/,
'keyword',
],
[/\b(?:true|false|null|undefined)\b/, 'keyword'],
[
/\b(?:min|max|clamp|round|floor|ceil|abs|random|chance|visible|groupVisible|contains|length|number|string|boolean|Number|String|Boolean|showLayer|hideLayer|toggleLayer|showGroup|hideGroup|toggleGroup|goTo|navigate|openUrl|launch|stop|Math)\b/,
'predefined',
],
[/\b(?:screen)\b/, 'constant'],
[/\b\d+(?:\.\d+)?\b/, 'number'],
[/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'],
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|\*\*|[=!<>+\-*/%?:]/, 'operator'],
[/[{}()[\]]/, '@brackets'],
[/[,;]/, 'delimiter'],
[/[^\s]/, 'invalid'],
],
string: [
[/[^"\\]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
singleString: [
[/[^'\\]+/, 'string'],
[/\\./, 'string.escape'],
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
templateString: [
[/[^`\\]+/, 'string'],
[/\\./, 'string.escape'],
[/`/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
comment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment', next: '@pop' }],
[/[*/]/, 'comment'],
],
},
});
api.editor.defineTheme(THEME_ID, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
{ token: 'predefined', foreground: '8be9fd' },
{ token: 'constant', foreground: 'ffb86c' },
{ token: 'identifier', foreground: 'f8f8f2' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'string.escape', foreground: 'ffb86c' },
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'operator', foreground: 'ff79c6' },
{ token: 'invalid', foreground: 'ff5555' },
],
colors: {
'editor.background': '#08080c',
'editor.foreground': '#f4f4f5',
'editorLineNumber.foreground': '#52525b',
'editorLineNumber.activeForeground': '#e4e4e7',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ff006e33',
'editor.inactiveSelectionBackground': '#ff006e22',
'editorCursor.foreground': '#ff006e',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#52525b',
'editorBracketHighlight.foreground1': '#ff79c6',
'editorBracketHighlight.foreground2': '#8be9fd',
'editorBracketHighlight.foreground3': '#f1fa8c',
'editorGutter.background': '#08080c',
'editorWidget.background': '#121218',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#121218',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#ff006e26',
'editorSuggestWidget.highlightForeground': '#ff79c6',
'editorHoverWidget.background': '#121218',
'editorHoverWidget.border': '#3f3f46',
'editorError.foreground': '#f87171',
'editorOverviewRuler.errorForeground': '#f87171',
'scrollbarSlider.background': '#52525b55',
'scrollbarSlider.hoverBackground': '#71717a88',
'scrollbarSlider.activeBackground': '#a1a1aa99',
},
});
};
const completionRange = (
api: MonacoApi,
currentModel: Monaco.editor.ITextModel,
position: Monaco.Position
) => {
const word = currentModel.getWordUntilPosition(position);
return new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
);
};
const insideString = (text: string) => {
let inside = false;
for (let index = 0; index < text.length; index += 1) {
if (text[index] !== '"') continue;
let backslashes = 0;
for (let check = index - 1; check >= 0 && text[check] === '\\'; check -= 1) {
backslashes += 1;
}
if (backslashes % 2 === 0) inside = !inside;
}
return inside;
};
const registerIntellisense = (api: MonacoApi) => {
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['"', '.'],
provideCompletionItems(currentModel, position) {
const range = completionRange(api, currentModel, position);
const line = currentModel.getLineContent(position.lineNumber);
const before = line.slice(0, position.column - 1);
const suggestions: Monaco.languages.CompletionItem[] = [];
if (insideString(before)) {
const prefix = before.slice(0, before.lastIndexOf('"')).toLowerCase();
const wantsScreen = /(?:goTo|navigate)\s*\(\s*$/.test(prefix);
const wantsGroup = /(?:showGroup|hideGroup|toggleGroup|groupVisible)\s*\(\s*$/.test(
prefix
);
const wantsLayer = /(?:showLayer|hideLayer|toggleLayer|visible)\s*\(\s*$/.test(prefix);
if (wantsScreen) {
for (const screen of screens) {
suggestions.push({
label: screen,
kind: api.languages.CompletionItemKind.EnumMember,
insertText: screen,
range,
documentation: `Go to the ${screen} screen.`,
sortText: `0_${screen}`,
});
}
} else if (wantsGroup) {
for (const group of groups) {
suggestions.push({
label: group.name?.trim() || group.id,
detail: group.id,
kind: api.languages.CompletionItemKind.Struct,
insertText: group.id,
range,
documentation: `Group id: \`${group.id}\``,
sortText: `0_${group.id}`,
});
}
} else if (wantsLayer) {
for (const element of elements) {
suggestions.push({
label: element.name?.trim() || element.id,
detail: element.id,
kind: api.languages.CompletionItemKind.Field,
insertText: element.id,
range,
documentation: `${element.type} layer id: \`${element.id}\``,
sortText: `0_${element.id}`,
});
}
}
return { suggestions, incomplete: false };
}
for (const snippet of snippets) {
suggestions.push({
label: snippet.label,
kind: api.languages.CompletionItemKind.Snippet,
insertText: snippet.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: snippet.documentation,
sortText: `0_${snippet.label}`,
});
}
for (const entry of Object.entries(statementDocs)) {
suggestions.push({
label: entry[0],
kind: api.languages.CompletionItemKind.Keyword,
insertText: entry[0],
range,
documentation: entry[1],
sortText: `1_${entry[0]}`,
});
}
for (const variable of new Set([...THEME_CLOCK_VARIABLE_NAMES, ...variables])) {
const clock = isThemeClockVariable(variable);
suggestions.push({
label: variable,
kind: api.languages.CompletionItemKind.Variable,
insertText: variable,
range,
documentation: clock
? `Built-in clock value, read-only and refreshed every second. Display it in text layers with \`{${variable}}\`.`
: `Theme variable. Assign it with \`${variable} += 1;\` and display it in text layers with \`{${variable}}\`.`,
sortText: `${clock ? '1' : '2'}_${variable}`,
});
}
for (const screen of screens) {
suggestions.push({
label: `"${screen}"`,
kind: api.languages.CompletionItemKind.EnumMember,
insertText: `"${screen}"`,
range,
documentation: 'Screen name for `goTo()` and `screen`.',
sortText: `3_${screen}`,
});
}
for (const entry of actionFunctions) {
suggestions.push({
label: entry.label,
kind: api.languages.CompletionItemKind.Function,
insertText: entry.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.name}`,
});
}
for (const entry of functions) {
suggestions.push({
label: entry.label,
kind: api.languages.CompletionItemKind.Function,
insertText: entry.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.name}`,
});
}
return { suggestions, incomplete: false };
},
});
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(currentModel, position) {
const word = currentModel.getWordAtPosition(position);
if (!word) return null;
let contents = statementDocs[word.word];
let kind = 'Keyword';
if (!contents) {
const entry = [...functions, ...actionFunctions].find(
(candidate) => candidate.name === word.word
);
if (entry) {
contents = `**${entry.label}**\n\n${entry.documentation}`;
kind = 'Function';
}
}
if (!contents && (isThemeClockVariable(word.word) || variables.includes(word.word))) {
contents = isThemeClockVariable(word.word)
? `**${word.word}** — built-in clock value\n\nRefreshes every second. Use it in text layers as \`{${word.word}}\` or read it directly in logic.`
: `**${word.word}** — theme variable\n\nUse it in text layers as \`{${word.word}}\`.`;
kind = 'Variable';
}
if (!contents) {
const element = elements.find((candidate) => candidate.id === word.word);
if (element) {
contents = `**${element.name?.trim() || element.type}**\n\nLayer id: \`${element.id}\``;
kind = 'Layer';
}
}
if (!contents) {
const group = groups.find((candidate) => candidate.id === word.word);
if (group) {
contents = `**${group.name?.trim() || 'Group'}**\n\nGroup id: \`${group.id}\``;
kind = 'Group';
}
}
if (!contents) return null;
return {
range: new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
),
contents: [{ value: `_${kind}_\n\n${contents}` }],
};
},
});
return () => {
completion.dispose();
hover.dispose();
};
};
const syncDiagnostics = (source: string) => {
if (!monaco || !model) return;
const result = parseThemeScript(source);
const markers: Monaco.editor.IMarkerData[] = result.errors.map((error) => {
const lineNumber = Math.min(Math.max(1, error.line), model!.getLineCount());
const maxColumn = model!.getLineMaxColumn(lineNumber);
const startColumn = Math.min(Math.max(1, error.column), maxColumn);
return {
severity: monaco!.MarkerSeverity.Error,
message: error.message,
source: 'EZPP theme script',
startLineNumber: lineNumber,
startColumn,
endLineNumber: lineNumber,
endColumn: Math.min(maxColumn, startColumn + 1),
};
});
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
errorCount = markers.length;
};
const updateStats = (source: string) => {
lineCount = Math.max(1, source.split('\n').length);
};
$effect(() => {
const next = value;
if (!model || !monaco || next === currentValue) return;
currentValue = next;
model.setValue(next);
updateStats(next);
syncDiagnostics(next);
});
$effect(() => {
if (!expanded) return;
requestAnimationFrame(() => editor?.layout());
});
onMount(() => {
let disposed = false;
let disposeIntellisense: (() => void) | undefined;
void (async () => {
try {
const { default: EditorWorker } =
await import('monaco-editor/editor/editor.worker.js?worker');
if (disposed) return;
type WorkerHost = typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
};
(globalThis as WorkerHost).MonacoEnvironment = {
getWorker: () => new EditorWorker(),
};
const api = await import('monaco-editor');
if (disposed) return;
monaco = api;
ensureLanguage(api);
if (!host) return;
currentValue = value;
updateStats(currentValue);
model = api.editor.createModel(currentValue, LANGUAGE_ID);
editor = api.editor.create(host, {
model,
theme: THEME_ID,
ariaLabel: 'Advanced theme rule script',
automaticLayout: true,
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
fontSize: 12,
lineHeight: 19,
fontLigatures: true,
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: true,
folding: true,
minimap: { enabled: false },
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
insertSpaces: true,
scrollBeyondLastLine: false,
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'line',
fixedOverflowWidgets: true,
quickSuggestions: { other: true, comments: false, strings: false },
suggestOnTriggerCharacters: true,
suggest: { showWords: false, preview: true },
wordBasedSuggestions: 'off',
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
});
editor.onDidChangeModelContent(() => {
const next = model?.getValue() ?? '';
currentValue = next;
updateStats(next);
syncDiagnostics(next);
onchange?.(next);
});
editor.onDidChangeCursorPosition((event) => {
cursorLine = event.position.lineNumber;
cursorColumn = event.position.column;
});
disposeIntellisense = registerIntellisense(api);
syncDiagnostics(currentValue);
updateStats(currentValue);
ready = true;
editor.focus();
} catch (error) {
console.error('Could not load the Monaco script editor', error);
loadError = error instanceof Error ? error.message : String(error);
}
})();
return () => {
disposed = true;
disposeIntellisense?.();
editor?.dispose();
model?.dispose();
editor = undefined;
model = undefined;
monaco = undefined;
};
});
function toggleExpanded() {
expanded = !expanded;
requestAnimationFrame(() => {
editor?.layout();
editor?.focus();
});
}
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape' && expanded) {
event.preventDefault();
expanded = false;
}
}}
/>
{#if expanded}
<button
type="button"
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close expanded script editor"
onclick={() => (expanded = false)}
></button>
{/if}
<div
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
: 'relative rounded-md'}"
style={expanded ? '' : `height:${height}px`}
>
<div
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
>
<Braces class="size-3 shrink-0 text-primary" />
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
Advanced script
</span>
<span class="ml-auto flex items-center gap-1.5">
{#if loadError}
<span class="text-[8px] text-destructive">Editor unavailable</span>
{:else if errorCount > 0}
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
<CircleAlert class="size-2.5" />
{errorCount}
</span>
{:else}
<span class="text-[8px] text-emerald-400">Ready</span>
{/if}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
onclick={toggleExpanded}
>
{#if expanded}
<Minimize2 class="size-3" />
{:else}
<Maximize2 class="size-3" />
{/if}
</button>
{#if expanded}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Close editor"
aria-label="Close expanded editor"
onclick={() => (expanded = false)}
>
<X class="size-3" />
</button>
{/if}
</span>
</div>
{#if loadError}
<textarea
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
spellcheck={false}
{value}
placeholder={'score += 1;\nif (score >= 3) {\n showLayer("secret");\n}'}
oninput={(event) => {
const next = (event.currentTarget as HTMLTextAreaElement).value;
onchange?.(next);
}}
></textarea>
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
Monaco could not load: {loadError}
</p>
{:else}
<div class="relative min-h-0 flex-1">
<div bind:this={host} class="absolute inset-0"></div>
{#if !ready}
<div
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
>
Loading editor…
</div>
{/if}
</div>
<div
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
>
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
<span>Ln {cursorLine}, Col {cursorColumn}</span>
<span class="ml-auto">
{#if errorCount > 0}
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
{:else}
No errors
{/if}
</span>
</div>
{/if}
</div>
@@ -0,0 +1,72 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import type { Component, Snippet } from 'svelte';
let {
title,
open = $bindable(true),
icon: Icon,
badge,
description,
actions,
children,
class: className = '',
contentClass = 'space-y-3 pt-2.5',
headingClass = 'text-xs font-semibold text-theme-100',
bordered = true,
}: {
title: string;
open?: boolean;
icon?: Component<{ class?: string }>;
badge?: string | number;
description?: string;
actions?: Snippet;
children?: Snippet;
class?: string;
contentClass?: string;
headingClass?: string;
bordered?: boolean;
} = $props();
</script>
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
<div class="flex items-center gap-1.5">
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
aria-expanded={open}
onclick={() => (open = !open)}
>
{#if Icon}
<Icon class="size-3.5 shrink-0 text-primary" />
{/if}
<span class="truncate {headingClass}">{title}</span>
{#if badge !== undefined && badge !== ''}
<span
class="shrink-0 rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground"
>
{badge}
</span>
{/if}
<ChevronDown
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
? 'rotate-180'
: ''}"
/>
</button>
{#if actions}
<div class="flex shrink-0 items-center gap-1">
{@render actions()}
</div>
{/if}
</div>
{#if open}
<div class={contentClass}>
{#if description}
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
{/if}
{@render children?.()}
</div>
{/if}
</section>
+447
View File
@@ -0,0 +1,447 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { currentUserInfo } from '$lib/data';
import { scopeThemeCss } from '$lib/themeCssScope';
import { fontStack, monoFontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
import {
normalizeChrome,
readableTextColor,
themeFontFaceCss,
themeHasLaunchButton,
type ThemeV2ButtonAction,
type ThemeV2Definition,
type ThemeV2Group,
type ThemeV2ScriptValue,
type ThemeV2WidgetKind,
} from '$lib/themeV2';
import { currentUser } from '$lib/userAuthentication';
import { countryCodeToEmoji } from '$lib/utils';
import { Clock, Trophy } from '@lucide/svelte';
let {
definition,
assets = '',
assetOverrides = {},
selectedIds = [],
selectedGroupId,
interactive = false,
scale = 1,
variables = {},
onselect,
onselectmany,
onselectgroup,
onmove,
onpatch,
onpatchgroup,
oncontext,
oncontextgroup,
ondetach,
onbuttonaction,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
selectedIds?: string[];
selectedGroupId?: string;
interactive?: boolean;
scale?: number;
variables?: Record<string, ThemeV2ScriptValue>;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
onselectgroup?: (id: string) => void;
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
oncontext?: (id: string | undefined, event: MouseEvent) => void;
oncontextgroup?: (id: string, event: MouseEvent) => void;
ondetach?: (widgetKind: ThemeV2WidgetKind) => void;
onbuttonaction?: (action: ThemeV2ButtonAction) => void;
} = $props();
const bodyFont = $derived(definition.typography?.body);
const headingFont = $derived(definition.typography?.heading);
const monoFont = $derived(definition.typography?.mono);
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
const accentText = $derived(readableTextColor(accent));
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
const chrome = $derived(definition.chrome ?? normalizeChrome(undefined, definition.elements));
const hasLaunchBtn = $derived(themeHasLaunchButton(definition));
const stageStyle = $derived(
[
`font-family:${fontStack(bodyFont)}`,
`--primary:${accent}`,
`--primary-foreground:${accentText}`,
`--accent:${accent}`,
`--accent-foreground:${accentText}`,
`--ring:${accent}`,
`--color-primary:${accent}`,
`--color-primary-foreground:${accentText}`,
`--color-primary-500:${accent}`,
`--color-accent:${accent}`,
`--color-accent-foreground:${accentText}`,
`--color-ring:${accent}`,
`--font-mono:${monoFontStack(monoFont)}`,
].join(';')
);
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
const customCss = $derived(scopeThemeCss(definition.css, '.theme-stage'));
const elements = $derived(definition.elements ?? []);
</script>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${fontFaces}\n${customCss}</style>`}
<div
class="theme-stage dark relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
style={stageStyle}
role="presentation"
>
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
<div class="relative z-10 grid h-full grid-cols-[0.085fr_1fr]">
<aside
class="flex flex-col items-center gap-2 border-r border-theme-900 bg-black/40 p-3 backdrop-blur-sm"
>
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
</div>
{#if variables.launcher_version !== undefined && variables.launcher_version !== ''}
<span
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
>
{variables.launcher_version}
</span>
{/if}
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
>
<ThemeIcon name="home" set={iconSet} class="size-5" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
>
<ThemeIcon name="themes" set={iconSet} class="size-5" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
>
<ThemeIcon name="settings" set={iconSet} class="size-5" />
</div>
<div
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
>
<ThemeIcon name="user" set={iconSet} class="size-5 text-muted-foreground" />
</div>
</aside>
<section class="relative flex min-w-0 flex-col justify-between">
{#if chrome.header !== false}
<div class="px-8 pt-7 pb-2 shrink-0 z-10 flex items-start justify-between gap-4">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="group/header relative flex flex-col {interactive
? 'cursor-pointer rounded-xl p-2 -m-2 transition-all hover:bg-white/5 hover:ring-2 hover:ring-primary/40'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('header');
}
}}
title={interactive ? 'Click to detach into moveable Header Title layer' : undefined}
>
<span
class="text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
style="font-family:{fontStack(headingFont, bodyFont)}"
>
EZPPLauncher
</span>
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
Good evening, Guest!
</span>
{#if interactive}
<div
class="pointer-events-none absolute -bottom-6 left-0 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/header:opacity-100"
>
Click to detach & move freely
</div>
{/if}
</div>
</div>
{:else}
<div></div>
{/if}
<div class="px-8 py-2 my-auto z-10">
{#if chrome.layoutMode === 'dashboard'}
<div class="flex flex-row items-stretch gap-5 w-full max-w-4xl">
{#if chrome.playerCard !== false}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="flex-1 min-w-[320px] max-w-[440px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-4 flex flex-col justify-between gap-3 shadow-xl {interactive
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('player-card');
}
}}
title={interactive ? 'Click to detach into moveable Player Card layer' : undefined}
>
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<Avatar.Root
class="size-11 rounded-xl border border-white/15 bg-theme-900 shadow"
>
<Avatar.Image
src="https://a.ez-pp.farm/{$currentUser?.id ??
$currentUserInfo?.info?.id ??
0}"
alt={$currentUser?.name ?? 'Player'}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={20} />
</Avatar.Fallback>
</Avatar.Root>
{#if $currentUserInfo?.info?.country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
>
{countryCodeToEmoji($currentUserInfo.info.country)}
</span>
{/if}
</div>
<div>
<span class="font-bold text-sm text-theme-50 block"
>{$currentUser?.name ?? 'Guest Player'}</span
>
<span class="text-xs text-muted-foreground block">
Lv.{$currentUserInfo?.stats?.['0']?.level ?? 98} · {(
$currentUserInfo?.stats?.['0']?.acc ?? 93
).toFixed(1)}%
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="bg-white/5 rounded-xl p-2.5">
<span class="text-[10px] text-muted-foreground block">Rank</span>
<span class="font-bold text-sm text-theme-50">#102</span>
</div>
<div class="bg-white/5 rounded-xl p-2.5">
<span class="text-[10px] text-muted-foreground block">Performance</span>
<span class="font-bold text-sm text-primary">4,521pp</span>
</div>
</div>
</div>
{/if}
{#if chrome.recentActivity !== false}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="flex-1 min-w-[300px] max-w-[380px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden shadow-xl flex flex-col justify-between {interactive
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('recent-activity');
}
}}
title={interactive
? 'Click to detach into moveable Recent Activity layer'
: undefined}
>
<!-- Mini Cover Header -->
<div class="relative h-20 w-full shrink-0 overflow-hidden bg-theme-900/80">
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
></div>
<div class="absolute top-2 left-2.5 right-2.5 flex items-center justify-between">
<span
class="rounded-full border border-white/10 bg-black/60 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-white/90"
>
Recent Activity
</span>
<span
class="rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
>
S
</span>
</div>
<div class="absolute bottom-1.5 left-2.5 right-2.5 flex min-w-0 flex-col">
<span class="truncate text-xs font-bold text-white">Space Pirates</span>
<span class="truncate text-[10px] text-white/70">Waterflame</span>
</div>
</div>
<!-- Metrics Body -->
<div class="flex flex-1 flex-col justify-center gap-2 p-2.5">
<div class="flex items-center justify-between gap-1 text-[11px]">
<span class="max-w-[180px] truncate font-semibold text-primary">
[Galactic Thieves]
</span>
<div
class="flex items-center gap-1 rounded-md border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
>
<Trophy class="size-3" />
<span>Rank #1</span>
</div>
</div>
<div
class="flex items-center justify-between border-t border-white/5 pt-1.5 text-[10px] text-muted-foreground"
>
<span class="flex items-center gap-1 font-mono">
<Clock class="size-2.5" />
5d ago
</span>
<span class="text-white/60">ez-pp.farm</span>
</div>
</div>
</div>
{/if}
</div>
{:else if chrome.layoutMode === 'classic'}
{#if chrome.playerCard !== false}
<div class="flex flex-col justify-end">
<div
class="w-72 h-48 bg-black/40 backdrop-blur-sm rounded-md ring-1 ring-inset ring-white/5 flex flex-col items-center justify-center p-3 gap-3"
>
<div class="flex flex-col items-center gap-1 text-center">
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
<span class="text-xs text-muted-foreground"
>Log in with your EZPPFarm account to track your rank.</span
>
</div>
<div class="flex w-full flex-col gap-2">
<div
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
>
Log In
</div>
</div>
</div>
</div>
{/if}
{/if}
</div>
{#if chrome.dock !== false}
<div
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-3.5 backdrop-blur-sm shrink-0"
>
{#if chrome.dockTelemetry !== false}
<div class="flex items-center gap-3">
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
>
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
<span>1.2k</span>
</div>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
>
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
<span>42</span>
</div>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
>
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
<span>19ms</span>
</div>
</div>
{:else}
<div></div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="group/launch relative flex h-11 items-center rounded-full bg-primary shadow-lg shadow-primary/25 {interactive
? 'cursor-pointer hover:ring-2 hover:ring-white/40 transition-all'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('launch-button');
}
}}
title={interactive
? 'Click to detach and position Launch Button freely anywhere'
: undefined}
>
<div class="relative flex size-11 shrink-0 items-center justify-center">
<div
class="relative z-10 flex size-8 items-center justify-center rounded-full bg-black/30"
>
<ThemeIcon
name="launch"
set={iconSet}
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
/>
</div>
</div>
<div class="w-48 pl-1 pr-5 text-center">
<p class="text-xs font-bold leading-tight text-primary-foreground">Launch</p>
</div>
{#if interactive}
<div
class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/launch:opacity-100"
>
Click to detach & move freely
</div>
{/if}
</div>
</div>
{:else if !hasLaunchBtn}
<div
class="fixed bottom-6 right-6 z-30 flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-300 backdrop-blur"
>
<span>⚠️ Launch Button Missing</span>
</div>
{/if}
</section>
</div>
<ThemeV2Elements
{elements}
groups={definition.groups}
{assets}
{assetOverrides}
themeName={definition.name}
themeAuthor={definition.author}
themeVersion={definition.version}
accentColor={definition.accentColor}
{iconSet}
{interactive}
{selectedIds}
{selectedGroupId}
{scale}
{variables}
{onselect}
{onselectmany}
{onselectgroup}
{onmove}
{onpatch}
{onpatchgroup}
{oncontext}
{oncontextgroup}
{onbuttonaction}
/>
</div>
@@ -0,0 +1,360 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import type { ThemeV2TextDecoration, ThemeV2TextStyle, ThemeV2TextTransform } from '$lib/themeV2';
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
let {
element,
onchange,
}: {
element: ThemeV2TextStyle;
onchange: (patch: Partial<ThemeV2TextStyle>) => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const colorField = 'h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5';
const bold = $derived((element.fontWeight ?? 600) >= 700);
const italic = $derived(element.fontStyle === 'italic');
const underline = $derived((element.textDecoration ?? 'none').includes('underline'));
const strike = $derived((element.textDecoration ?? 'none').includes('line-through'));
const gradient = $derived(element.fillMode === 'gradient');
const transformOptions: { value: ThemeV2TextTransform; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'uppercase', label: 'UPPERCASE' },
{ value: 'lowercase', label: 'lowercase' },
{ value: 'capitalize', label: 'Capitalize' },
];
const inputValue = (event: Event) => (event.currentTarget as HTMLInputElement).value;
function toggleDecoration(nextUnderline: boolean, nextStrike: boolean) {
const parts: string[] = [];
if (nextUnderline) parts.push('underline');
if (nextStrike) parts.push('line-through');
onchange({ textDecoration: (parts.join(' ') || 'none') as ThemeV2TextDecoration });
}
function setFillMode(value: ThemeV2TextStyle['fillMode']) {
const fillMode = value === 'gradient' ? 'gradient' : 'solid';
onchange({
fillMode,
...(fillMode === 'gradient'
? {
gradientFrom: element.gradientFrom ?? '#ff006e',
gradientTo: element.gradientTo ?? '#7c3aed',
}
: {}),
});
}
function resetTextStyle() {
onchange({
fontWeight: 600,
fontStyle: 'normal',
textDecoration: 'none',
textTransform: 'none',
fillMode: 'solid',
gradientFrom: '#ff006e',
gradientTo: '#7c3aed',
gradientAngle: 90,
outlineWidth: 0,
outlineColor: '#000000',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
shadowColor: '#000000',
});
}
</script>
{#snippet textStyleActions()}
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
title="Reset bold, italic, decoration, case, fill, outline and shadow"
onclick={resetTextStyle}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="Text style"
open={false}
actions={textStyleActions}
contentClass="space-y-2 pt-2.5"
>
<div class="grid grid-cols-4 gap-1">
<Button
variant={bold ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Bold"
aria-label="Bold"
aria-pressed={bold}
onclick={() => onchange({ fontWeight: bold ? 400 : 700 })}
>
<Bold class="size-3.5" />
</Button>
<Button
variant={italic ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Italic"
aria-label="Italic"
aria-pressed={italic}
onclick={() => onchange({ fontStyle: italic ? 'normal' : 'italic' })}
>
<Italic class="size-3.5" />
</Button>
<Button
variant={underline ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Underline"
aria-label="Underline"
aria-pressed={underline}
onclick={() => toggleDecoration(!underline, strike)}
>
<Underline class="size-3.5" />
</Button>
<Button
variant={strike ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Strikethrough"
aria-label="Strikethrough"
aria-pressed={strike}
onclick={() => toggleDecoration(underline, !strike)}
>
<Strikethrough class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-text-transform" class={label}>Case</Label>
<Select.Root
type="single"
value={element.textTransform ?? 'none'}
onValueChange={(value) => {
if (value) onchange({ textTransform: value as ThemeV2TextTransform });
}}
>
<Select.Trigger
id="element-text-transform"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{transformOptions.find((option) => option.value === (element.textTransform ?? 'none'))
?.label ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each transformOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-fill-mode" class={label}>Fill</Label>
<Select.Root
type="single"
value={element.fillMode ?? 'solid'}
onValueChange={(value) => setFillMode(value as ThemeV2TextStyle['fillMode'])}
>
<Select.Trigger
id="element-fill-mode"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.fillMode === 'gradient' ? 'Gradient' : 'Solid'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="solid" label="Solid" />
<Select.Item value="gradient" label="Gradient" />
</Select.Content>
</Select.Root>
</div>
</div>
{#if gradient}
<div class="space-y-2">
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Gradient from</Label>
<span class="font-mono text-[9px] text-theme-100"
>{element.gradientFrom ?? '#ff006e'}</span
>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.gradientFrom ?? '#ff006e'}
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.gradientFrom ?? '#ff006e'}
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Gradient to</Label>
<span class="font-mono text-[9px] text-theme-100">{element.gradientTo ?? '#7c3aed'}</span>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.gradientTo ?? '#7c3aed'}
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.gradientTo ?? '#7c3aed'}
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<Label for="element-gradient-angle" class={label}>Gradient angle</Label>
<Input
id="element-gradient-angle"
class={field}
type="number"
min="0"
max="360"
step="1"
value={element.gradientAngle ?? 90}
onchange={(event) =>
onchange({
gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)),
})}
/>
</div>
</div>
{:else}
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Color</Label>
<span class="font-mono text-[9px] text-theme-100">{element.color ?? '#ffffff'}</span>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.color ?? '#ffffff'}
oninput={(event) => onchange({ color: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.color ?? '#ffffff'}
oninput={(event) => onchange({ color: inputValue(event) })}
/>
</div>
</div>
{/if}
<div class="space-y-1">
<Label class={label}>Outline</Label>
<div class="grid grid-cols-[72px_40px_1fr] gap-2">
<Input
class={field}
type="number"
min="0"
max="20"
step="1"
aria-label="Outline width"
title="Outline width in pixels; 0 disables it"
value={element.outlineWidth ?? 0}
onchange={(event) =>
onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })}
/>
<Input
class={colorField}
type="color"
aria-label="Outline color"
value={element.outlineColor ?? '#000000'}
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
/>
<Input
class="{field} min-w-0 font-mono"
aria-label="Outline color hex"
value={element.outlineColor ?? '#000000'}
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<Label class={label}>Shadow</Label>
<div class="grid grid-cols-3 gap-2">
<Input
class={field}
type="number"
min="0"
max="40"
step="1"
aria-label="Shadow blur"
title="Shadow blur"
value={element.shadowBlur ?? 0}
onchange={(event) =>
onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })}
/>
<Input
class={field}
type="number"
min="-40"
max="40"
step="1"
aria-label="Shadow offset X"
title="Shadow offset X"
value={element.shadowOffsetX ?? 0}
onchange={(event) =>
onchange({
shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
})}
/>
<Input
class={field}
type="number"
min="-40"
max="40"
step="1"
aria-label="Shadow offset Y"
title="Shadow offset Y"
value={element.shadowOffsetY ?? 0}
onchange={(event) =>
onchange({
shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
})}
/>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
aria-label="Shadow color"
value={element.shadowColor ?? '#000000'}
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
aria-label="Shadow color hex"
value={element.shadowColor ?? '#000000'}
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
/>
</div>
</div>
</ThemeSection>
@@ -0,0 +1,260 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import type { ThemeV2Transform } from '$lib/themeV2';
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
type TransformTarget = ThemeV2Transform & { offsetX?: number; offsetY?: number };
type TransformPatch = Partial<TransformTarget>;
let {
transform,
idPrefix,
includeTranslate = false,
onchange,
}: {
transform: TransformTarget;
idPrefix: string;
includeTranslate?: boolean;
onchange: (patch: TransformPatch) => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const scaleX = $derived(transform.scaleX ?? transform.scale ?? 1);
const scaleY = $derived(transform.scaleY ?? transform.scale ?? 1);
const isDefault = $derived(
(transform.rotation ?? 0) === 0 &&
scaleX === 1 &&
scaleY === 1 &&
(transform.skewX ?? 0) === 0 &&
(transform.skewY ?? 0) === 0 &&
!transform.flipX &&
!transform.flipY &&
(!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0))
);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function numberValue(value: string): number | undefined {
if (value.trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function setScale(axis: 'scaleX' | 'scaleY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const scale = clamp(parsed / 100, 0.05, 10);
onchange(axis === 'scaleX' ? { scaleX: scale } : { scaleY: scale });
}
function setSkew(axis: 'skewX' | 'skewY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const skew = clamp(parsed, -89, 89);
onchange(axis === 'skewX' ? { skewX: skew } : { skewY: skew });
}
function setRotation(value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
onchange({ rotation: clamp(parsed, -360, 360) });
}
function rotateBy(delta: number) {
const next = (transform.rotation ?? 0) + delta;
onchange({ rotation: ((((next + 180) % 360) + 360) % 360) - 180 });
}
function setOffset(axis: 'offsetX' | 'offsetY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const offset = clamp(parsed, -2000, 2000);
onchange(axis === 'offsetX' ? { offsetX: offset } : { offsetY: offset });
}
function resetTransform() {
onchange({
rotation: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
flipX: false,
flipY: false,
...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}),
});
}
</script>
{#snippet transformActions()}
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
title={includeTranslate
? 'Reset translation, rotation, scale, skew and flips'
: 'Reset rotation, scale, skew and flips'}
disabled={isDefault}
onclick={resetTransform}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="Transform"
open={false}
actions={transformActions}
contentClass="space-y-2 pt-2.5"
>
{#if includeTranslate}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="{idPrefix}-translate-x" class={label}>Translate X</Label>
<Input
id="{idPrefix}-translate-x"
class={field}
type="number"
min="-2000"
max="2000"
value={transform.offsetX ?? 0}
onchange={(event) => setOffset('offsetX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-translate-y" class={label}>Translate Y</Label>
<Input
id="{idPrefix}-translate-y"
class={field}
type="number"
min="-2000"
max="2000"
value={transform.offsetY ?? 0}
onchange={(event) => setOffset('offsetY', event.currentTarget.value)}
/>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="{idPrefix}-scale-x" class={label}>Scale X %</Label>
<Input
id="{idPrefix}-scale-x"
class={field}
type="number"
min="5"
max="1000"
step="5"
value={Math.round(scaleX * 100)}
onchange={(event) => setScale('scaleX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-scale-y" class={label}>Scale Y %</Label>
<Input
id="{idPrefix}-scale-y"
class={field}
type="number"
min="5"
max="1000"
step="5"
value={Math.round(scaleY * 100)}
onchange={(event) => setScale('scaleY', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-skew-x" class={label}>Skew X °</Label>
<Input
id="{idPrefix}-skew-x"
class={field}
type="number"
min="-89"
max="89"
step="1"
value={transform.skewX ?? 0}
onchange={(event) => setSkew('skewX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-skew-y" class={label}>Skew Y °</Label>
<Input
id="{idPrefix}-skew-y"
class={field}
type="number"
min="-89"
max="89"
step="1"
value={transform.skewY ?? 0}
onchange={(event) => setSkew('skewY', event.currentTarget.value)}
/>
</div>
</div>
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
<Label for="{idPrefix}-rotation" class={label}>Rotation °</Label>
<Input
id="{idPrefix}-rotation"
class={field}
type="number"
min="-360"
max="360"
step="1"
value={transform.rotation ?? 0}
onchange={(event) => setRotation(event.currentTarget.value)}
/>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant={transform.flipX ? 'default' : 'secondary'}
size="icon"
class="size-7"
title="Flip horizontally"
aria-label="Flip horizontally"
aria-pressed={transform.flipX === true}
onclick={() => onchange({ flipX: !transform.flipX })}
>
<FlipHorizontal2 class="size-3.5" />
</Button>
<Button
variant={transform.flipY ? 'default' : 'secondary'}
size="icon"
class="size-7"
title="Flip vertically"
aria-label="Flip vertically"
aria-pressed={transform.flipY === true}
onclick={() => onchange({ flipY: !transform.flipY })}
>
<FlipVertical2 class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-7"
title="Rotate left 90°"
aria-label="Rotate left 90 degrees"
onclick={() => rotateBy(-90)}
>
<RotateCcw class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-7"
title="Rotate right 90°"
aria-label="Rotate right 90 degrees"
onclick={() => rotateBy(90)}
>
<RotateCw class="size-3.5" />
</Button>
</div>
</div>
</ThemeSection>
@@ -0,0 +1,252 @@
<script lang="ts">
import { custom_theme_volume } from '$lib/global';
import { themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
import type { ThemeV2Definition } from '$lib/themeV2';
import { themeAssetUrl } from '$lib/themeV2';
import { reduceAnimations } from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils';
let {
definition,
assets = '',
assetOverrides = {},
preview = false,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
preview?: boolean;
} = $props();
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
const assetUrl = (path: string | undefined) => {
if (!path) return '';
return assetOverrides[path] ?? themeAssetUrl(assets, path);
};
const background = $derived(definition.background);
const overlay = $derived(definition.overlay);
const effects = $derived(definition.effects);
const random = (seed: number) => {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
};
const particles = $derived.by(() => {
if ($reduceAnimations) return [];
if (!effects || effects.type === 'none') return [];
const count = Math.min(120, Math.max(0, Math.round(effects.density ?? 30)));
const speed = Math.max(0.2, effects.speed ?? 1);
return Array.from({ length: count }, (_, index) => {
const value = (offset: number) => random(index + 1 + offset);
const isStar = effects.type === 'stars';
const size = isStar ? 1 + value(1) * 2.5 : 4 + value(1) * 7;
const duration = (isStar ? 2 + value(2) * 3 : 9 + value(2) * 11) / speed;
return {
id: index,
left: value(3) * 100,
top: isStar ? value(4) * 100 : value(4) * 15,
size,
duration,
delay: value(5) * duration,
drift: -90 + value(6) * 180,
opacity: 0.25 + value(7) * 0.65,
rotate: value(8) * 360,
};
});
});
function trackMedia(node: HTMLElement) {
if (preview) return {};
return trackMediaContainer(node);
}
// `muted` is not reliably reflected after load, so keep the live property in
// sync when the theme toggles it in the editor.
function applyMuted(node: HTMLVideoElement, muted: boolean) {
node.muted = muted;
return {
update(value: boolean) {
node.muted = value;
},
};
}
$effect(() => {
if (!audioElement || !definition.audio) return;
audioElement.volume = $custom_theme_volume;
if (!preview) {
audioElement.play().catch(() => {
// Autoplay can be rejected until the window has been interacted with.
});
}
});
</script>
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
{#if background.type === 'color'}
<div
class="absolute inset-0"
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'gradient'}
<div
class="absolute inset-0"
style="background-image:linear-gradient({background.angle ??
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'image'}
{#if background.src}
<img
class="absolute inset-0 h-full w-full"
src={assetUrl(background.src)}
alt=""
aria-hidden="true"
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
/>
{/if}
{:else if background.type === 'video'}
{#if background.src}
<video
class="absolute inset-0 h-full w-full object-cover"
use:themeMediaSource={assetUrl(background.src)}
use:themeVideoLifecycle
autoplay
loop
muted={background.muted !== false}
use:applyMuted={background.muted !== false}
playsinline
disablepictureinpicture
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
></video>
{/if}
{/if}
{#if overlay && (overlay.opacity ?? 0) > 0}
<div
class="absolute inset-0 pointer-events-none"
style="background-color:{overlay.color ?? '#000000'};opacity:{overlay.opacity ?? 0}"
></div>
{/if}
{#if overlay && (overlay.vignette ?? 0) > 0}
<div
class="vignette absolute inset-0 pointer-events-none"
style="opacity:{overlay.vignette ?? 0}"
></div>
{/if}
{#if effects && effects.type !== 'none'}
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
{#each particles as particle (particle.id)}
<span
class="particle {effects.type}"
style="left:{particle.left}%;top:{particle.top}%;width:{particle.size}px;height:{particle.size}px;opacity:{particle.opacity};--particle-color:{effects.color ??
'#ffffff'};--particle-drift:{particle.drift}px;--particle-duration:{particle.duration}s;--particle-delay:-{particle.delay}s;--particle-rotate:{particle.rotate}deg;animation-duration:{particle.duration}s;animation-delay:-{particle.delay}s;"
></span>
{/each}
</div>
{/if}
{#if definition.audio && !preview}
<audio
bind:this={audioElement}
use:themeMediaSource={assetUrl(definition.audio.src)}
loop={definition.audio.loop !== false}
autoplay
></audio>
{/if}
</div>
<style lang="scss">
.vignette {
background: radial-gradient(ellipse at center, transparent 38%, rgba(0, 0, 0, 0.92) 100%);
}
.particle {
position: absolute;
display: block;
background: var(--particle-color);
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform, opacity;
&.snow {
border-radius: 9999px;
filter: blur(0.5px);
animation-name: v2-snow;
}
&.petals {
border-radius: 70% 0 70% 0;
animation-name: v2-petals;
}
&.bubbles {
border-radius: 9999px;
border: 1px solid var(--particle-color);
background: color-mix(in srgb, var(--particle-color) 16%, transparent);
animation-name: v2-bubbles;
}
&.stars {
border-radius: 9999px;
box-shadow: 0 0 6px var(--particle-color);
animation-name: v2-stars;
animation-timing-function: ease-in-out;
}
}
@keyframes v2-snow {
from {
transform: translate3d(0, -12vh, 0);
}
to {
transform: translate3d(var(--particle-drift), 112vh, 0);
}
}
@keyframes v2-petals {
from {
transform: translate3d(0, -12vh, 0) rotate(var(--particle-rotate));
}
50% {
transform: translate3d(calc(var(--particle-drift) * 0.55), 50vh, 0)
rotate(calc(var(--particle-rotate) + 180deg));
}
to {
transform: translate3d(var(--particle-drift), 112vh, 0)
rotate(calc(var(--particle-rotate) + 360deg));
}
}
@keyframes v2-bubbles {
from {
transform: translate3d(0, 110vh, 0);
}
to {
transform: translate3d(var(--particle-drift), -12vh, 0);
}
}
@keyframes v2-stars {
0%,
100% {
opacity: 0.15;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,94 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import type { ThemeV2ScriptValue } from '$lib/themeV2';
import { RotateCcw } from '@lucide/svelte';
let {
variables,
defaults,
onchange,
}: {
variables: string[];
defaults: Record<string, ThemeV2ScriptValue>;
onchange: (name: string, value: ThemeV2ScriptValue | undefined) => void;
} = $props();
const hasDefault = (name: string) => Object.prototype.hasOwnProperty.call(defaults, name);
const valueLabel = (value: ThemeV2ScriptValue) =>
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text';
const displayValue = (name: string) => {
if (!hasDefault(name)) return '';
const value = defaults[name];
return typeof value === 'string' ? value : String(value);
};
const parseValue = (raw: string): ThemeV2ScriptValue | undefined => {
if (!raw.trim()) return undefined;
if (raw.trim() === 'true') return true;
if (raw.trim() === 'false') return false;
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(raw.trim())) {
const number = Number(raw.trim());
if (Number.isFinite(number)) return number;
}
return raw;
};
</script>
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
<p class="text-[10px] leading-relaxed text-muted-foreground">
Starting values for
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
placeholders and logic variables. Leave a field empty to start at
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">0</code>.
</p>
{#if variables.length === 0}
<p
class="rounded-md border border-dashed border-theme-800 p-2.5 text-[10px] leading-relaxed text-muted-foreground"
>
Add a
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{placeholder}'}</code>
to a text layer or use a variable in a rule to see it here.
</p>
{:else}
<div class="space-y-1.5">
{#each variables as name (name)}
<div
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1.5"
>
<div class="min-w-0 flex-1">
<div class="truncate font-mono text-[10px] text-theme-100" title={`{${name}}`}>
{name}
</div>
<div class="text-[8px] leading-tight text-muted-foreground">
{hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'}
</div>
</div>
<Input
class="h-7 w-24 shrink-0 border-theme-800 bg-theme-950 px-2 font-mono text-[10px] text-theme-50"
value={displayValue(name)}
placeholder="0"
spellcheck="false"
aria-label={`Default value for ${name}`}
onchange={(event) => onchange(name, parseValue(event.currentTarget.value))}
/>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-theme-100"
title={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
aria-label={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
disabled={!hasDefault(name)}
onclick={() => onchange(name, undefined)}
>
<RotateCcw class="size-3" />
</Button>
</div>
{/each}
</div>
{/if}
</ThemeSection>
@@ -1,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;
@@ -0,0 +1,86 @@
<script lang="ts">
import { reduceAnimations } from '$lib/userSettings';
//TODO: maybe dynamic background images fetched from ezpp?
const COUNT = 60;
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: rand(300, 320) * scale,
height: 32 * 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;
"
></div>
{/each}
</div>
<style>
.bg {
position: fixed;
inset: 0;
overflow: hidden;
background: linear-gradient(
180deg,
var(--color-primary-300) 0%,
var(--color-primary-600) 40%,
var(--color-primary-900) 100%
);
}
.streak {
position: absolute;
background: #d6f3ff;
border-radius: 999px;
transform: rotate(-35deg);
animation: move linear infinite;
will-change: transform;
}
@keyframes move {
from {
transform: translate(-150vw, 150vh) rotate(-35deg);
}
to {
transform: translate(150vw, -150vh) rotate(-35deg);
}
}
</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,
@@ -1,10 +0,0 @@
<script lang="ts">
const prop: { beatmapId: number } = $props();
</script>
<div class="absolute w-screen h-screen overflow-hidden pointer-events-none rounded">
<div
style="background: url(https://osu.direct/api/media/background/{prop.beatmapId})"
class="absolute top-0 left-0 w-full h-full !bg-cover -z-10 pointer-events-none blur opacity-10 rounded"
></div>
</div>
+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?.()}
+4 -2
View File
@@ -53,13 +53,15 @@
</script>
{#if href}
<a bind:this={ref} class={cn(buttonVariants({ variant, size, className }))} {href} {...restProps}>
<!-- href is a pass-through prop and may point to an external URL -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{...restProps}
>
@@ -0,0 +1,76 @@
<script lang="ts">
import CalendarMonthSelect from './calendar-month-select.svelte';
import CalendarYearSelect from './calendar-year-select.svelte';
import type Calendar from './calendar.svelte';
import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
import type { ComponentProps } from 'svelte';
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
months: ComponentProps<typeof CalendarMonthSelect>['months'];
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
years: ComponentProps<typeof CalendarYearSelect>['years'];
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === 'dropdown'}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === 'dropdown-months'}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === 'dropdown-years'}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md',
className
)}
{...restProps}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: 'ghost' }),
'flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
'data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground',
// Outside months
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
// hover
'dark:hover:text-accent-foreground',
// focus
'focus:border-ring focus:ring-ring/50 focus:relative',
// inner spans
'[&>span]:text-xs [&>span]:opacity-70',
className
)}
{...restProps}
/>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more