Compare commits

...
219 Commits
Author SHA1 Message Date
Lncvrt 84e45bf408 Update launcher dependencies & make possible run on windows 2026-09-27 18:05:56 -07:00
Lncvrt cefe6c3ced This is pointless 2026-07-29 15:48:52 -07:00
Lncvrt 03f7c62665 Remove this 2026-07-29 12:57:07 -07:00
Lncvrt 2223f41f5d Why was this different 2026-07-29 12:39:45 -07:00
Lncvrt ace4030692 Add descriptions here 2026-07-29 12:35:20 -07:00
Lncvrt 5f06db1339 Hide next/prev buttons if not usable 2026-07-29 12:33:46 -07:00
Lncvrt c1082e6741 Update swiper 2026-07-29 12:29:25 -07:00
Lncvrt 37cd1f9547 Fix default height 2026-07-29 11:47:59 -07:00
Lncvrt 3d06cc4e60 Set max slides to the number of slides 2026-07-29 07:29:18 -07:00
Lncvrt 577f12f669 Keep installed date 2026-07-29 07:25:14 -07:00
Lncvrt 2b061c2c38 Filter by installed 2026-07-29 07:24:51 -07:00
Lncvrt e591e0766e Use dynamic slides per view 2026-07-29 07:22:32 -07:00
Lncvrt 8221b16819 Add swiper buttons 2026-07-29 06:59:36 -07:00
Lncvrt 83894e686c Very important change 2026-07-29 06:44:11 -07:00
Lncvrt 334862744a Tweak these descriptions 2026-07-29 06:41:43 -07:00
Lncvrt affc6c19cb Fix issues with non custom data location installs 2026-07-29 06:38:23 -07:00
Lncvrt 5ce8aa76ab WIP home page & rework some stuff 2026-07-29 06:26:52 -07:00
Lncvrt 28b88f4033 Add dropdown arrow to library to show/hide games 2026-07-29 05:31:08 -07:00
Lncvrt bac028efc3 Rename Games page to Library & remove sidebar always show games setting 2026-07-29 05:08:57 -07:00
Lncvrt b777a0a4ec Add response status check 2026-07-29 04:43:43 -07:00
Lncvrt e9286c4b64 Improve moving data system 2026-07-28 12:59:33 -07:00
Lncvrt 916e9bc08c Update icon to be for macOS 15 2026-07-28 12:50:38 -07:00
Lncvrt 672d95f433 Fix descriptions 2026-07-28 12:10:56 -07:00
Lncvrt 0f8e9dcbdf Add launch history tracking 2026-07-28 11:37:41 -07:00
Lncvrt 0d69848a1d Update dependencies 2026-07-28 11:18:15 -07:00
Lncvrt 57f70d4433 Going to use the macOS 15 SDK lmao, can't stick to one 2026-07-28 11:12:12 -07:00
Lncvrt 1407611dcc Improvements & add home page (not even made just going to work on it) 2026-06-05 09:58:54 -07:00
Lncvrt f4e389915f Going back to modern macOS SDK lol 2026-06-02 08:14:15 -07:00
Lncvrt 3a6e4e3eb6 Send launcher version to website 2026-06-01 17:10:24 -07:00
Lncvrt b7457c0cb5 Fix windows problems 2026-05-31 14:08:01 -07:00
Lncvrt 603edbdb4e Set version to 1.10.0 2026-05-31 12:06:05 -07:00
Lncvrt 9bd2a848b6 allow resizing 2026-05-31 11:28:06 -07:00
Lncvrt 16fa83729b Make it log into berry dash chatroom 2026-05-31 10:23:56 -07:00
Lncvrt a5dafa1cdb Add berry dash chatroom & change geometry rays chatroom url 2026-05-31 10:15:33 -07:00
Lncvrt 93c9ebedeb Improvements here 2026-05-30 09:51:36 -07:00
Lncvrt f7ebc8f0f1 Make this consistent with the other popup 2026-05-29 18:29:16 -07:00
Lncvrt e4ae6a2cc3 Update this 2026-05-29 18:28:02 -07:00
Lncvrt a65045e359 Using macOS 14.5 SDK for this now 2026-05-29 16:22:07 -07:00
Lncvrt 0197841092 Add geometry rays chatroom button 2026-05-25 11:21:42 -07:00
Lncvrt 55974c1bce Fix installs text 2026-05-25 11:21:14 -07:00
Lncvrt 910d6b30c6 Update dependencies 2026-05-23 13:18:45 -07:00
Lncvrt dad0022353 Update icon, no idea what changed 2026-05-13 12:59:42 -07:00
Lncvrt 3771a9e8fc Add a blur effect to sidebar buttons when scrolling up 2026-05-10 13:56:19 -07:00
Lncvrt 848a95b576 Use proper tauri dev mode detection 2026-05-10 08:26:31 -07:00
Lncvrt 0f1c84c3c9 Hide the window until loading is done 2026-05-10 08:19:34 -07:00
Lncvrt 7103d0e3a1 Get some async functions to be awaited, and also use different failed to load screen 2026-05-10 07:54:38 -07:00
Lncvrt 36b9f6bb55 Send authorization to server for version list 2026-05-10 07:43:37 -07:00
Lncvrt d61f090c92 Add open app folder button to settings 2026-05-09 21:10:48 -07:00
Lncvrt fff13b0075 macOS Ventura cannot run LGL anymore 2026-05-09 15:14:10 -07:00
Lncvrt 3cc7257923 Remove allow notifications setting, it can be handled natively 2026-05-08 17:40:49 -07:00
Lncvrt 68864356b1 Remove save formatting 2026-05-08 17:40:35 -07:00
Lncvrt 43678cc64d Add relaunch, reset, delete download cache, delete invalid games buttons to settings page 2026-05-08 17:11:31 -07:00
Lncvrt 3f27a330b6 Change style of setting thing 2026-05-08 16:05:47 -07:00
Lncvrt fd7c86a512 COSNISJTREBNCNCYYCY 2026-05-08 15:43:04 -07:00
Lncvrt 421871705b Change input style 2026-05-08 15:01:05 -07:00
Lncvrt 41f3893348 oop 2026-05-08 14:56:33 -07:00
Lncvrt b107bd675c ÇÒÑÇÌÑÇÈÝJKSDJKFSDFJKLKLSDFKLJSDFUIO#$E*UIWER*(UFS*(UDSDFUIJSDFJKLmsdvsd 2026-05-08 14:49:27 -07:00
Lncvrt 9e14aeaa0c Add account stuff 2026-05-08 13:51:52 -07:00
Lncvrt dfc9b32264 why didnt that delete 2026-05-08 13:03:10 -07:00
Lncvrt 4dfee5dda9 Pretty sure thats supposed to be that way 2026-05-08 12:56:45 -07:00
Lncvrt 377f8b7522 Ignore these flags, can be bad 2026-05-07 21:14:10 -07:00
Lncvrt 38275f1869 Remove unneeded endpoints from here 2026-05-07 18:19:06 -07:00
Lncvrt 85e01d91a0 Update dependencies 2026-05-07 18:17:25 -07:00
Lncvrt b2fefd2c6b Move away from using hidden 2026-05-07 17:12:36 -07:00
Lncvrt 635cbfc14c Recreate leaderboards window on theme change 2026-05-07 14:32:01 -07:00
Lncvrt 302a449fd7 Use frontend for making windows instead 2026-05-07 12:57:07 -07:00
Lncvrt 43d286b4e5 it auto saves 2026-05-07 12:31:08 -07:00
Lncvrt 6b8d9795b8 Change desc here 2026-05-07 12:30:15 -07:00
Lncvrt ca6d85e23c Use website for berry dash leaderboards 2026-05-07 11:56:41 -07:00
Lncvrt f6b027d3b2 When main window closes, close all other ones too 2026-05-06 15:12:35 -07:00
Lncvrt 1d024296c2 Add a developer mode to show some debug stuff 2026-05-06 15:10:15 -07:00
Lncvrt 403c1dee4e CONSISTENCYYFDGYFYSYDFYSHDHJKSDFJHKSDFJLSFLD 2026-05-06 14:41:32 -07:00
Lncvrt e341d41d74 consistencyyyýyhy223yhefodsjl;vd 2026-05-06 14:38:39 -07:00
Lncvrt 830378448e Make this work here for consistency 2026-05-05 17:44:56 -07:00
Lncvrt 9550d29821 Move these functions out of here, it's needed for something 2026-05-01 15:05:48 -07:00
Lncvrt 0c96a39b3e Update dependencies 2026-05-01 13:41:10 -07:00
Lncvrt f3689c5236 Remove duration from this, not really needed 2026-05-01 13:40:14 -07:00
Lncvrt 683158e31e Improve the logic for this I guess 2026-05-01 13:37:24 -07:00
Lncvrt a1a719ba72 Improve this popup a little 2026-05-01 13:26:41 -07:00
Lncvrt e0a20ddcf1 Remove game info from manage version popup 2026-05-01 13:22:08 -07:00
Lncvrt abeeab79d7 Make it say there is no versions available if there are none, rather than showing nothing 2026-05-01 13:15:46 -07:00
Lncvrt 3e45caf4f3 Fixes & Improvements 2026-05-01 13:01:19 -07:00
Lncvrt 8270e420e4 Redesign downloads menu & add install count to manage game popup 2026-04-30 21:51:04 -07:00
Lncvrt a2382a5836 Async config handling 2026-04-30 21:09:03 -07:00
Lncvrt 2c406ab531 Make it so if the folder exists already it'll just add it 2026-04-30 20:58:07 -07:00
Lncvrt 7c519bf8eb Improve this logic 2026-04-30 20:43:01 -07:00
Lncvrt 5226ce4a62 Add manage game popup 2026-04-30 20:39:16 -07:00
Lncvrt 254a1d5b5c Fix custom data location problems 2026-04-29 15:50:56 -07:00
Lncvrt 998fb6d309 Use headers instead of params 2026-04-26 18:14:00 -07:00
Lncvrt f9e67fee0f huh 2026-04-26 18:12:50 -07:00
Lncvrt bec02b1578 Add a way to bypass update checking 2026-04-26 17:53:18 -07:00
Lncvrt 2955c5d3f9 Make it so configs from newer versions cant be loaded on older clients 2026-04-26 16:47:22 -07:00
Lncvrt 0bd855acd3 Windows only thing 2026-04-26 13:46:02 -07:00
Lncvrt 7e88311939 Fix state problems when downloading more than 2 versions at once 2026-04-24 17:31:31 -07:00
Lncvrt 12cdda13fb Use new format for update 2026-04-19 16:27:20 -07:00
Lncvrt 8ca82777cc Move away from openssl 2026-04-19 16:00:06 -07:00
Lncvrt c3cee9c927 Update dependencies + Properly use default prevent default features 2026-04-19 13:39:46 -07:00
Lncvrt 3281c4e1a1 1.9.2 + Update dependencies 2026-04-18 11:00:46 -07:00
Lncvrt 3a9e0b0358 Dropdown improvements 2026-04-14 16:06:45 -07:00
Lncvrt c45e7fb47c Make the dropdown more compact 2026-04-14 16:02:44 -07:00
Lncvrt c7086fbc31 Use claude to fix bugs & add a custom data location setting 2026-04-14 15:58:54 -07:00
Lncvrt 33a1e4e780 Set description for launcher 2026-04-13 19:58:33 -07:00
Lncvrt 066f4aae8c 1.9.1 2026-04-13 17:33:24 -07:00
Lncvrt 2ebe127def Set app category & update dependencies 2026-04-13 17:33:04 -07:00
Lncvrt e0300acb6d Dropdown opening animation
This part is vibecoded
2026-04-13 15:12:15 -07:00
Lncvrt d18dc5afb7 Move away from native dropdowns, it looks inconsistent across systems 2026-04-13 15:01:54 -07:00
Lncvrt d20c99651c No point in this 2026-04-13 14:41:23 -07:00
Lncvrt 86a5a7b744 Re-add this back 2026-04-13 14:40:49 -07:00
Lncvrt 18f8c2316f Improvements 2026-04-13 14:11:46 -07:00
Lncvrt 12914e42dd More fixes 2026-04-13 13:28:58 -07:00
Lncvrt 91ff7caaed Fix using bash forcefully even if it doesn't exist for launching with wine 2026-04-13 13:20:36 -07:00
Lncvrt fad7073607 oop 2026-04-13 13:11:44 -07:00
Lncvrt beb5ce175e FIx issue with wine command input box showing even if wine is not enabled on linux & fix a notification issue 2026-04-13 13:08:43 -07:00
Lncvrt b8498d36e4 Add dashboard button to XPS game 2026-04-06 17:38:13 -07:00
Lncvrt 141d17fea0 Fix issues with macOS app launching on non .app executables 2026-04-06 13:05:21 -07:00
Lncvrt 138275d752 Fix for windows 2026-04-03 13:42:03 -07:00
Lncvrt 1f035882f7 1.9.0 2026-04-03 11:38:28 -07:00
Lncvrt 68cd3c891d Update typescript & node type 2026-04-03 11:37:25 -07:00
Lncvrt 65bd6cfe82 Fix dev mode issues on Windows 2026-04-03 11:32:50 -07:00
Lncvrt d91314112d Should be ready for release now. Will do some testing before. 2026-04-03 10:51:23 -07:00
Lncvrt da27bcb32c Smaller filesize & Close on issue 2026-04-03 10:35:18 -07:00
Lncvrt 6c8dfd1200 Update tokio & remove redundant Windows config 2026-04-03 10:13:45 -07:00
Lncvrt e09d125980 Should fix issues on linux 2026-04-03 07:12:24 -07:00
Lncvrt 35dd842576 I was lied to 2026-04-02 21:10:37 -07:00
Lncvrt 55714c8edb Stupid git 2026-04-02 17:27:05 -07:00
Lncvrt 04d1f2822b Add auto updater to the launcher 2026-04-02 17:11:25 -07:00
Lncvrt dfd99ab12e Use a popup for changelog again 2026-04-01 19:09:24 -07:00
Lncvrt 837c4a8863 Heavily improve codebase 2026-04-01 18:33:06 -07:00
Lncvrt c69060a360 1.8.1 2026-03-27 12:47:27 -07:00
Lncvrt f2b0358605 Big smart 2026-03-27 12:45:58 -07:00
Lncvrt 57441a8872 Close popup & notify on download finish 2026-03-27 12:45:29 -07:00
Lncvrt df522122ac Remove debug logging and fix hashing 2026-03-27 12:40:25 -07:00
Lncvrt f4e16c12e5 Fix overscroll 2026-03-27 12:28:33 -07:00
Lncvrt e2d98307c0 Optimize builds 2026-03-27 12:27:05 -07:00
Lncvrt 387bd54ce2 Rename "unix wine" to "linux wine" 2026-03-24 22:50:09 -07:00
Lncvrt e7a61872b7 Switch to Tauris json store management for settings 2026-03-24 22:45:28 -07:00
Lncvrt ac66c78dbf Fix this not using Rust for sending requests 2026-03-24 21:31:52 -07:00
Lncvrt d9302319fa Fix sorting issue 2026-03-24 20:15:41 -07:00
Lncvrt 5206a7f25d Fix downloads 2026-03-24 19:55:00 -07:00
Lncvrt 3ee371998a Should fix linux/macos BIE support? 2026-03-24 19:01:44 -07:00
Lncvrt 5733c21fbb 1.8.0 2026-03-24 18:38:28 -07:00
Lncvrt 61b9d07fc6 Fix number formatting 2026-03-24 18:34:56 -07:00
Lncvrt 59cd625238 Finish mod manager & Count downloads for mods 2026-03-24 18:33:06 -07:00
Lncvrt 6bf2733cec Show that no mods are available to download/installed 2026-03-24 18:08:08 -07:00
Lncvrt 60f9f95be4 Finish mod installing and uninstalling 2026-03-24 18:01:24 -07:00
Lncvrt ea1c05d2d9 Add view mod info mode 2026-03-24 17:11:12 -07:00
Lncvrt 49c03e82b1 Pre-work on adding view mod info popup 2026-03-24 16:29:15 -07:00
Lncvrt 0c52169533 Add a refresh button 2026-03-24 16:12:44 -07:00
Lncvrt bf6da20fd8 Improvements here 2026-03-24 16:08:24 -07:00
Lncvrt c1f87580c7 Early WIP version of the mod manager 2026-03-24 15:57:33 -07:00
Lncvrt dd7d2b96e3 Fix potential issues here 2026-03-13 18:34:58 -07:00
Lncvrt 874f0627f7 Add downloading mod manager 2026-03-13 18:31:52 -07:00
Lncvrt e8dec1a1b2 Add types to downloads, will be for modding stuff 2026-03-13 18:11:58 -07:00
Lncvrt e1f0ce1290 wtf 2026-03-13 17:46:16 -07:00
Lncvrt bc5ea81170 Make remove button how it should be 2026-03-13 17:40:15 -07:00
Lncvrt 71b998f4bc Fix outdated page 2026-03-13 17:31:09 -07:00
Lncvrt cbac50753f Built in modding support wip 2026-03-13 17:10:11 -07:00
Lncvrt fb65348ba3 Fix this logic broken in 1.6.0 2026-03-12 23:20:29 -07:00
Lncvrt f23d1025d2 1.7.0 2026-03-10 21:43:24 -07:00
Lncvrt a8573c4b6a Improve that 2026-03-10 21:39:23 -07:00
Lncvrt 4054fdd06b Add download pausing, canceling and resuming 2026-03-10 21:22:24 -07:00
Lncvrt 0b4e639630 Add unzip status 2026-03-10 20:35:26 -07:00
Lncvrt c65fb5e1a5 Optimize crates & a import 2026-03-10 20:33:01 -07:00
Lncvrt 6486498c74 Readd progress bar status, but optimized 2026-03-10 19:56:55 -07:00
Lncvrt cf72d143f3 oop 2026-03-10 15:44:33 -07:00
Lncvrt ee7c50c6f5 Reset berry state on back 2026-03-10 15:44:01 -07:00
Lncvrt f7317c8232 Improve berry select popup 2026-03-10 15:22:49 -07:00
Lncvrt 9856cf2426 Remove progress bar status, causes so much issues 2026-03-10 15:17:06 -07:00
Lncvrt f46d9c67b8 1.6.5 2026-03-07 23:48:13 -07:00
Lncvrt 132616dc29 Fix issues with custom (not icon marketplace) icons in bd leaderboards 2026-03-07 23:46:21 -07:00
Lncvrt 8b22fdb648 Add .env 2026-03-04 20:52:11 -07:00
Lncvrt dc55c7549d Add taskbar status icon stuff 2026-02-28 20:10:10 -07:00
Lncvrt 9d9c6147c5 Fix issues with building 2026-02-28 14:43:13 -07:00
Lncvrt 48c8365b7c Nevermind 2026-02-28 13:09:39 -07:00
Lncvrt 8884d08c7c Switch to native-tls 2026-02-28 13:06:51 -07:00
Lncvrt b516a9cc43 1.6.4 2026-02-28 13:01:11 -07:00
Lncvrt 6b7b2b198a Add signature checking to requests 2026-02-28 12:57:10 -07:00
Lncvrt fbf87dceba Add verbose debugging 2026-02-28 12:47:48 -07:00
Lncvrt 9ae28690a7 Forgot about this again 2026-02-28 11:33:38 -07:00
Lncvrt f83cc118fe 1.6.3 2026-02-26 23:27:32 -07:00
Lncvrt 19788a8a3c Update launche to new API 2026-02-26 23:22:16 -07:00
Lncvrt e066dd0681 Update icon 2026-02-26 14:31:28 -07:00
Lncvrt 29b3c3c58b Flip this 2026-02-18 20:43:40 -07:00
Lncvrt a8390216d0 1.6.2 2026-02-17 00:41:31 -07:00
Lncvrt 513f1a0c16 Replace all .unwrap() statements with proper code with Claude Code 2026-02-17 00:39:20 -07:00
Lncvrt e7e624f042 Add overlay & custom bird color rendering to leaderboards 2026-02-16 23:44:56 -07:00
Lncvrt 654405e016 1.6.1 2026-02-14 23:33:58 -07:00
Lncvrt c461a0a459 Disable flags like reloading 2026-02-14 23:31:58 -07:00
Lncvrt 16ce04ba7a Make it so there is a topbar for windows if something fails to load 2026-02-14 19:04:16 -07:00
Lncvrt 1700d127c3 Improvements to download button, just makes more sense, and other stuff 2026-02-14 18:55:40 -07:00
Lncvrt 7c47b32c68 Merge view info & manage into one popup 2026-02-14 18:50:05 -07:00
Lncvrt 56781b5d1a Fix a issue with folders that don't exist not showing an error 2026-02-14 18:34:26 -07:00
Lncvrt 6ce7c2a8ae Improvements here 2026-02-14 18:17:19 -07:00
Lncvrt 15dd9ad013 Make logo look better on windows 2026-02-14 18:14:23 -07:00
Lncvrt 217222a22b Fix issues with leaderboards 2026-02-14 18:05:13 -07:00
Lncvrt 39da88a856 Fix linux and windows compiling issue 2026-02-14 16:07:57 -07:00
Lncvrt 29c90d2f06 1.6.0 2026-02-14 15:52:38 -07:00
Lncvrt 467dd1b0a1 Fix a issue with entries not clearing on back button press 2026-02-14 15:20:19 -07:00
Lncvrt a1a69c9ac5 Add name to games page title 2026-02-14 15:05:24 -07:00
Lncvrt 0163e09b02 Add berry dash leaderboards 2026-02-14 14:56:47 -07:00
Lncvrt fbb4b15971 Switch to ES2024 and webpack (for dev mode only) 2026-02-14 14:56:39 -07:00
Lncvrt 77933a2298 Move popups to their own files
Big improvement, needed to do this for a while even though it's so basic
2026-02-14 14:06:32 -07:00
Lncvrt 7209a36752 Make this have less code 2026-02-14 13:45:21 -07:00
Lncvrt f00b78996a Fix failed thing 2026-02-14 13:34:48 -07:00
Lncvrt 38779c1715 Rename this to remove, makes more sense 2026-02-14 13:31:36 -07:00
Lncvrt fa67d26fe9 Improvements again 2026-02-14 13:25:07 -07:00
Lncvrt 81d47ea71d Replace rust version of this with frontend logic 2026-02-14 13:03:31 -07:00
Lncvrt 70433b76ab Move some files around to where they should be 2026-02-14 12:49:57 -07:00
Lncvrt 105913503c Remove legacy interaction option 2026-02-14 12:46:01 -07:00
Lncvrt 2ad54f976b Update dependencies 2026-02-14 12:23:34 -07:00
Lncvrt d8d59febbb oops 2026-02-14 12:15:28 -07:00
Lncvrt a618c76292 Have Claude Code make downloads a proper queue system 2026-02-14 02:53:42 -07:00
Lncvrt 118fad49d8 Minimum macOS version is now 13 2026-02-14 02:27:53 -07:00
Lncvrt 62c0f84d14 1.5.8 2026-02-10 16:10:09 -07:00
Lncvrt 4a96911885 Make it so if a category is selected, it'll only show the versions from that category 2026-02-10 16:02:11 -07:00
Lncvrt 51cd3c3620 Fix being able to update while downloads are in progress 2026-02-10 15:58:38 -07:00
Lncvrt dfbfac33d3 Use system popup for showing changelog too 2026-02-10 15:02:23 -07:00
Lncvrt 03ec5a220a Use system popup for revision update warning 2026-02-10 14:58:53 -07:00
Lncvrt abebc0bb08 Add categories to sidebar 2026-02-10 14:25:02 -07:00
86 changed files with 5539 additions and 3001 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_PUBLIC_SIGNING_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQVJiK1N5c0VQemRYaGN4OWtyMy9rY3NNNURheDZRS1Q0VXZ3bW9PbWd2UHc9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=
-3
View File
@@ -30,9 +30,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel # vercel
.vercel .vercel
+314 -273
View File
File diff suppressed because it is too large. Load diff
+37 -28
View File
@@ -1,7 +1,7 @@
{ {
"name": "lncvrt-games-launcher", "name": "lncvrt-games-launcher",
"private": true, "private": true,
"version": "1.5.7", "version": "1.10.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -11,36 +11,45 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "7.1.0", "@fortawesome/fontawesome-svg-core": "7.3.1",
"@fortawesome/free-brands-svg-icons": "7.1.0", "@fortawesome/free-brands-svg-icons": "7.3.1",
"@fortawesome/free-solid-svg-icons": "7.1.0", "@fortawesome/free-solid-svg-icons": "7.3.1",
"@fortawesome/react-fontawesome": "3.2.0", "@fortawesome/react-fontawesome": "3.5.0",
"@tauri-apps/api": "2.10.1", "@tauri-apps/api": "2.12.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-cli": "2.5.0",
"@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-fs": "2.4.5", "@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-opener": "2.5.3", "@tauri-apps/plugin-http": "2.7.0",
"@tauri-apps/plugin-os": "2.3.2", "@tauri-apps/plugin-notification": "2.5.0",
"@tauri-apps/plugin-window-state": "2.4.1", "@tauri-apps/plugin-opener": "2.6.0",
"axios": "1.13.5", "@tauri-apps/plugin-os": "2.4.0",
"next": "16.1.6", "@tauri-apps/plugin-process": "2.4.0",
"pretty-bytes": "7.1.0", "@tauri-apps/plugin-store": "2.5.0",
"react": "19.2.4", "@tauri-apps/plugin-updater": "2.13.0",
"react-dom": "19.2.4" "@tauri-apps/plugin-window-state": "2.5.0",
"date-fns": "4.4.0",
"dompurify": "3.4.16",
"next": "16.3.6",
"pretty-bytes": "7.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"semver": "7.8.5",
"swiper": "14.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.3", "@eslint/eslintrc": "3.3.7",
"@tailwindcss/postcss": "4.1.18", "@tailwindcss/postcss": "4.3.3",
"@tauri-apps/cli": "2.10.0", "@tauri-apps/cli": "2.12.0",
"@types/crypto-js": "4.2.2", "@types/crypto-js": "4.2.2",
"@types/node": "25.2.2", "@types/node": "26.6.3",
"@types/react": "19.2.13", "@types/react": "19.3.0",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.3.0",
"@types/semver": "7.8.0",
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"eslint": "9.39.2", "eslint": "9.39.5",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.3.6",
"tailwindcss": "4.1.18", "tailwindcss": "4.3.3",
"typescript": "5.9.3" "typescript": "6.0.3"
} }
} }
+42 -23
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lncvrt-games-launcher" name = "lncvrt-games-launcher"
version = "1.5.7" version = "1.10.0"
authors = ["Lncvrt"] authors = ["Lncvrt"]
edition = "2024" edition = "2024"
@@ -9,32 +9,51 @@ name = "lncvrt_games_launcher_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.5.5", features = [] } tauri-build = { version = "2.7.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.10.2", features = ["macos-private-api"] } tauri = { version = "2.12.0", features = [] }
tauri-plugin-opener = "2.5.3" tauri-plugin-opener = "2.6.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.151"
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.4.0"
reqwest = { version = "0.13.2", default-features = false, features = ["stream", "rustls"] } reqwest = { version = "0.13.5", default-features = false, features = [
tokio = "1.49.0" "stream",
futures-util = { version = "0.3.31", features = ["io"] } "rustls",
tauri-plugin-decorum = "1.1.1" ] }
tauri-plugin-fs = "2.4.5" tokio = "1.53.1"
zip = "7.4.0" futures-util = { version = "0.3.34", features = ["io"] }
libc = "0.2.181" tauri-plugin-fs = "2.6.0"
tauri-plugin-dialog = "2.6.0" zip = "8.6.0"
tauri-plugin-notification = "2.3.3" tauri-plugin-dialog = "2.8.0"
sysinfo = "0.38.1" tauri-plugin-notification = "2.5.0"
sha2 = "0.10.9" sha2 = "0.11.0"
tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-clipboard-manager = "2.4.0"
tauri-plugin-prevent-default = "6.0.0"
base64 = "0.23.1"
tauri-plugin-http = "2.8.0"
dashmap = "6.2.1"
tauri-plugin-store = "2.5.0"
hex = "0.4.3"
ed25519-dalek = { version = "3.0.0", features = ["pkcs8"] }
pem = "4.0.0"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
shlex = "1.3.0" sysinfo = "0.39.6"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.4.0" tauri-plugin-single-instance = "2.5.0"
tauri-plugin-window-state = "2.4.1" tauri-plugin-updater = "2.13.0"
tauri-plugin-window-state = "2.5.0"
tauri-plugin-process = "2.4.0"
tauri-plugin-cli = "2.5.0"
[target.'cfg(target_os = "windows")'.dependencies]
tauri-plugin-decorum = "1.1.1"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"
+24 -5
View File
@@ -2,9 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": [ "windows": ["main"],
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
@@ -19,18 +17,39 @@
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"decorum:allow-show-snap-overlay", "core:window:allow-request-user-attention",
"core:window:allow-show",
"core:window:allow-hide",
"core:webview:allow-create-webview-window",
"fs:default", "fs:default",
"fs:allow-remove",
"dialog:default", "dialog:default",
"dialog:allow-open",
"notification:default", "notification:default",
"clipboard-manager:default", "clipboard-manager:default",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"store:default",
"store:allow-load",
"store:allow-save",
"fs:allow-applocaldata-read", "fs:allow-applocaldata-read",
"fs:allow-applocaldata-write", "fs:allow-applocaldata-write",
{ {
"identifier": "fs:scope", "identifier": "fs:scope",
"allow": [{ "path": "**" }]
},
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "**" }]
},
{
"identifier": "http:default",
"allow": [ "allow": [
{ "path": "$APPLOCALDATA/game/**" } { "url": "https://games.lncvrt.xyz/api/launcher/versions" },
{ "url": "https://games.lncvrt.xyz/api/launcher/download" },
{ "url": "https://games.lncvrt.xyz/api/launcher/mods" },
{ "url": "https://games.lncvrt.xyz/api/account/login" },
{ "url": "https://games.lncvrt.xyz/api/account/change-username" },
{ "url": "https://games.lncvrt.xyz/api/account/change-password" }
] ]
} }
] ]
+3 -11
View File
@@ -1,14 +1,6 @@
{ {
"identifier": "desktop-capability", "identifier": "desktop-capability",
"platforms": [ "platforms": ["macOS", "windows", "linux"],
"macOS", "windows": ["main"],
"windows", "permissions": ["window-state:default", "updater:default", "process:default"]
"linux"
],
"windows": [
"main"
],
"permissions": [
"window-state:default"
]
} }
+6
View File
@@ -0,0 +1,6 @@
{
"identifier": "windows-capability",
"platforms": ["windows"],
"windows": ["main"],
"permissions": ["decorum:allow-show-snap-overlay"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 250 KiB

+590 -120
View File
@@ -1,7 +1,13 @@
use base64::Engine;
use dashmap::DashMap;
use ed25519_dalek::pkcs8::DecodePublicKey;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use futures_util::stream::StreamExt; use futures_util::stream::StreamExt;
use pem::parse;
use sha2::{Digest, Sha512}; use sha2::{Digest, Sha512};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::{OnceLock, RwLock};
use std::time::Instant; use std::time::Instant;
use std::{ use std::{
fs::{File, create_dir_all}, fs::{File, create_dir_all},
@@ -10,29 +16,54 @@ use std::{
process::Command, process::Command,
time::Duration, time::Duration,
}; };
use sysinfo::System; use tauri::window::{ProgressBarState, ProgressBarStatus};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_os::platform; use tauri_plugin_os::platform;
use tauri_plugin_window_state::StateFlags;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::{io::AsyncWriteExt, time::timeout}; use tokio::{io::AsyncWriteExt, time::timeout};
use zip::ZipArchive; use zip::ZipArchive;
use zip::result::ZipError;
#[cfg(target_os = "windows")] #[cfg(any(target_os = "windows", target_os = "linux"))]
use tauri_plugin_decorum::WebviewWindowExt; use sysinfo::System;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
#[allow(unused)] #[cfg(not(debug_assertions))]
use tauri_plugin_updater::UpdaterExt;
struct AppState {
custom_data_dir: RwLock<Option<PathBuf>>,
}
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
let state = app.state::<AppState>();
let guard = state.custom_data_dir.read().unwrap();
match &*guard {
Some(p) => Ok(p.clone()),
None => app.path().app_local_data_dir().map_err(|e| e.to_string()),
}
}
static CANCEL_MAP: OnceLock<DashMap<String, bool>> = OnceLock::new();
fn cancel_map() -> &'static DashMap<String, bool> {
CANCEL_MAP.get_or_init(DashMap::new)
}
fn is_cancelled(name: &str) -> bool {
cancel_map().get(name).map(|v| *v).unwrap_or(false)
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn is_running_by_path(path: &Path) -> bool { fn is_running_by_path(path: &Path) -> bool {
let sys = System::new_all(); let sys = System::new_all();
let target = path.canonicalize().ok(); let target = match path.canonicalize() {
if target.is_none() { Ok(t) => t,
return false; Err(_) => return false,
} };
let target = target.unwrap();
sys.processes().values().any(|proc| { sys.processes().values().any(|proc| {
proc.exe() proc.exe()
.and_then(|exe| exe.canonicalize().ok()) .and_then(|exe| exe.canonicalize().ok())
@@ -47,20 +78,25 @@ fn should_skip(name: &str) -> bool {
|| name.ends_with(".DS_Store") || name.ends_with(".DS_Store")
} }
async fn unzip_to_dir(zip_path: PathBuf, out_dir: PathBuf) -> String { async fn unzip_to_dir(app: AppHandle, zip_path: PathBuf, out_dir: PathBuf, name: String) -> String {
let _ = app.emit("unzip-start", format!("{}", name));
let res = tauri::async_runtime::spawn_blocking(move || { let res = tauri::async_runtime::spawn_blocking(move || {
let file = File::open(zip_path)?; let file = File::open(zip_path)?;
let mut archive = ZipArchive::new(BufReader::new(file))?; let mut archive = ZipArchive::new(BufReader::new(file))?;
let total = archive.len();
for i in 0..archive.len() { for i in 0..total {
let mut entry = archive.by_index(i)?; let mut entry = archive.by_index(i)?;
let name = entry.name(); let entry_name = entry.name();
if should_skip(name) { if should_skip(entry_name) {
continue; continue;
} }
let outpath = out_dir.join(name); let outpath = out_dir.join(entry_name);
if !outpath.starts_with(&out_dir) {
return Err(ZipError::InvalidArchive("Path traversal detected".into()));
}
if entry.is_dir() { if entry.is_dir() {
create_dir_all(&outpath)?; create_dir_all(&outpath)?;
@@ -82,6 +118,8 @@ async fn unzip_to_dir(zip_path: PathBuf, out_dir: PathBuf) -> String {
std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode))?; std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode))?;
} }
} }
let _ = app.emit("unzip-progress", format!("{}:{}:{}", name, i + 1, total));
} }
Ok::<(), zip::result::ZipError>(()) Ok::<(), zip::result::ZipError>(())
@@ -96,12 +134,10 @@ async fn unzip_to_dir(zip_path: PathBuf, out_dir: PathBuf) -> String {
#[tauri::command] #[tauri::command]
fn folder_size(app: AppHandle, version: String) -> String { fn folder_size(app: AppHandle, version: String) -> String {
let path = app let path = match data_dir(&app) {
.path() Ok(p) => p.join("game").join(&version),
.app_local_data_dir() Err(_) => return "-1".to_string(),
.unwrap() };
.join("game")
.join(&version);
fn inner(path: &Path) -> u64 { fn inner(path: &Path) -> u64 {
let mut size = 0; let mut size = 0;
if let Ok(entries) = fs::read_dir(path) { if let Ok(entries) = fs::read_dir(path) {
@@ -126,57 +162,147 @@ fn folder_size(app: AppHandle, version: String) -> String {
} }
} }
#[allow(unused_variables)]
#[tauri::command] #[tauri::command]
async fn download( async fn download(
app: AppHandle, app: AppHandle,
url: String, url: String,
name: String, name: String,
executable: String,
hash: String, hash: String,
download_type: i8,
mod_id: String,
) -> String { ) -> String {
let client = reqwest::Client::new(); let _ = app.emit("download-start", format!("{}", name));
let resp = match client.get(&url).send().await { let window = app.get_webview_window("main").expect("main window missing");
Ok(r) => r,
let base_dir = match data_dir(&app) {
Ok(p) => p,
Err(_) => { Err(_) => {
let _ = &app.emit("download-stop", format!("{}", name));
return "-1".to_string(); return "-1".to_string();
} }
}; };
let total_size = resp.content_length().unwrap_or(0); let downloads_path = base_dir.join("downloads");
let game_path = base_dir.join("game");
let mut downloaded: u64 = 0;
let mut stream = resp.bytes_stream();
let downloads_path = app.path().app_local_data_dir().unwrap().join("downloads");
let game_path = app.path().app_local_data_dir().unwrap().join("game");
let download_part_path = downloads_path.join(format!("{}.part", name)); let download_part_path = downloads_path.join(format!("{}.part", name));
let download_zip_path = downloads_path.join(format!("{}.zip", name)); let download_zip_path = downloads_path.join(format!("{}.zip", name));
if download_part_path.exists() { if tokio::fs::try_exists(game_path.join(&name))
let _ = tokio::fs::remove_file(&download_part_path).await; .await
.is_ok_and(|v| v)
&& !tokio::fs::try_exists(&download_part_path)
.await
.is_ok_and(|v| v)
&& !tokio::fs::try_exists(&download_zip_path)
.await
.is_ok_and(|v| v)
{
let _ = window.set_progress_bar(ProgressBarState {
status: Some(ProgressBarStatus::None),
progress: Some(0),
});
return "1".to_string();
} }
let _ = tokio::fs::create_dir_all(&downloads_path).await; let _ = tokio::fs::create_dir_all(&downloads_path).await;
if let Ok(true) = tokio::fs::try_exists(&game_path.join(name.clone())).await {
let _ = tokio::fs::remove_dir_all(&game_path.join(name.clone())).await; let resume_from: u64 = match tokio::fs::metadata(&download_part_path).await {
Ok(meta) => {
let size = meta.len();
size
} }
let _ = tokio::fs::create_dir_all(&game_path.join(&name)).await; Err(_) => 0,
let mut file = tokio::fs::File::create(download_part_path.clone()) };
.await
.unwrap();
let start = Instant::now(); let client = reqwest::Client::new();
let mut request = client.get(&url);
if resume_from > 0 {
request = request.header("Range", format!("bytes={}-", resume_from));
}
while let Ok(Some(chunk_result)) = timeout(Duration::from_secs(5), stream.next()).await { let resp = match request.send().await {
let chunk = match chunk_result { Ok(r) => r,
Ok(c) => c,
Err(_) => { Err(_) => {
let _ = &app.emit("download-stop", format!("{}", name));
return "-1".to_string(); return "-1".to_string();
} }
}; };
let status = resp.status();
let actual_resume_from = if resume_from > 0 && status == reqwest::StatusCode::OK {
let _ = tokio::fs::remove_file(&download_part_path).await;
0
} else if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT {
resume_from
} else if resume_from > 0 {
let _ = tokio::fs::remove_file(&download_part_path).await;
0
} else {
0
};
let remaining_size = resp.content_length().unwrap_or(0);
let total_size = actual_resume_from + remaining_size;
let _ = app.emit("download-size", format!("{}:{}", &name, &total_size));
let mut file = if actual_resume_from > 0 {
match tokio::fs::OpenOptions::new()
.append(true)
.open(&download_part_path)
.await
{
Ok(f) => f,
Err(_) => {
let _ = &app.emit("download-stop", format!("{}", name));
return "-1".to_string();
}
}
} else {
if download_type == 0 {
if let Ok(true) = tokio::fs::try_exists(&game_path.join(&name)).await {
let _ = tokio::fs::remove_dir_all(&game_path.join(&name)).await;
}
let _ = tokio::fs::create_dir_all(&game_path.join(&name)).await;
}
match tokio::fs::File::create(&download_part_path).await {
Ok(f) => f,
Err(_) => {
let _ = &app.emit("download-stop", format!("{}", name));
return "-1".to_string();
}
}
};
let mut downloaded: u64 = actual_resume_from;
let mut stream = resp.bytes_stream();
let start = Instant::now();
let mut chunk_count: u64 = 0;
while let Ok(Some(chunk_result)) = timeout(Duration::from_secs(5), stream.next()).await {
if is_cancelled(&name) {
cancel_map().remove(&name);
let _ = window.set_progress_bar(ProgressBarState {
status: Some(ProgressBarStatus::None),
progress: Some(0),
});
return "0".to_string();
}
let chunk = match chunk_result {
Ok(c) => c,
Err(_) => {
let _ = &app.emit("download-stop", format!("{}", name));
return "-1".to_string();
}
};
chunk_count += 1;
if let Err(_) = file.write_all(&chunk).await { if let Err(_) = file.write_all(&chunk).await {
let _ = &app.emit("download-stop", format!("{}", name));
return "-1".to_string(); return "-1".to_string();
} }
@@ -188,71 +314,120 @@ async fn download(
0.0 0.0
}; };
let session_bytes = downloaded - actual_resume_from;
let elapsed_secs = start.elapsed().as_secs_f64(); let elapsed_secs = start.elapsed().as_secs_f64();
let speed = (downloaded as f64) / elapsed_secs; let speed = if elapsed_secs > 0.0 {
let eta_secs = if total_size > downloaded { session_bytes as f64 / elapsed_secs
((total_size - downloaded) as f64) / speed } else {
0.0
};
let eta_secs = if total_size > downloaded && speed > 0.0 {
(total_size - downloaded) as f64 / speed
} else { } else {
0.0 0.0
}; };
app.emit( if chunk_count % 50 == 0 || downloaded == total_size {
let _ = window.set_progress_bar(ProgressBarState {
status: Some(ProgressBarStatus::Normal),
progress: Some(progress as u64),
});
}
let _ = app.emit(
"download-progress", "download-progress",
format!( format!(
"{}:{:.8}:{}:{}:{:.2}", "{}:{:.8}:{}:{}:{:.2}",
&name, progress, downloaded, speed, eta_secs &name, progress, downloaded, speed, eta_secs
), ),
) );
.unwrap();
} }
if total_size > 0 && downloaded < total_size { if total_size > 0 && downloaded < total_size {
let _ = window.set_progress_bar(ProgressBarState {
status: Some(ProgressBarStatus::None),
progress: Some(0),
});
return "-1".to_string(); return "-1".to_string();
} }
app.emit("download-hash-checking", format!("{}", &name)) let _ = app.emit("download-hash-checking", format!("{}", &name));
.unwrap();
let download_hash = { let download_hash = {
let mut file = tokio::fs::File::open(download_part_path.clone()) let mut file = match tokio::fs::File::open(&download_part_path).await {
.await Ok(f) => f,
.unwrap(); Err(_) => {
return "-1".to_string();
}
};
let mut hasher = Sha512::new(); let mut hasher = Sha512::new();
{ let mut buffer = [0u8; 8192];
let mut buffer = [0; 8192];
loop { loop {
let bytes_read = file.read(&mut buffer).await.unwrap(); let bytes_read = match file.read(&mut buffer).await {
Ok(n) => n,
Err(_) => {
return "-1".to_string();
}
};
if bytes_read == 0 { if bytes_read == 0 {
break; break;
} }
hasher.update(&buffer[..bytes_read]); hasher.update(&buffer[..bytes_read]);
} }
}
drop(file); drop(file);
format!("{:x}", hasher.finalize()) hex::encode(hasher.finalize())
}; };
if hash != download_hash { if hash != download_hash {
tokio::fs::remove_file(download_part_path.clone()) let _ = tokio::fs::remove_file(&download_part_path).await;
.await let _ = window.set_progress_bar(ProgressBarState {
.unwrap(); status: Some(ProgressBarStatus::None),
progress: Some(0),
});
return "-1".to_string(); return "-1".to_string();
} }
app.emit("download-finishing", format!("{}", &name))
.unwrap();
tokio::fs::rename(download_part_path.clone(), download_zip_path.clone()) if let Err(_) = tokio::fs::rename(&download_part_path, &download_zip_path).await {
.await let _ = window.set_progress_bar(ProgressBarState {
.unwrap(); status: Some(ProgressBarStatus::None),
let unzip_res = unzip_to_dir(download_zip_path.clone(), game_path.join(&name)).await; progress: Some(0),
tokio::fs::remove_file(download_zip_path.clone()) });
.await return "-1".to_string();
.unwrap(); }
let _ = tokio::fs::create_dir_all(&game_path.join(&name)).await;
let unzip_res = unzip_to_dir(
app.clone(),
download_zip_path.clone(),
if download_type == 2 {
game_path
.join(&name)
.join("BepInEx")
.join("plugins")
.join(&mod_id)
} else {
game_path.join(&name)
},
name.clone(),
)
.await;
let _ = tokio::fs::remove_file(&download_zip_path).await;
if unzip_res == "-1" { if unzip_res == "-1" {
let _ = window.set_progress_bar(ProgressBarState {
status: Some(ProgressBarStatus::None),
progress: Some(0),
});
return "-1".to_string(); return "-1".to_string();
} }
return "1".to_string(); let _ = window.set_progress_bar(ProgressBarState {
status: Some(ProgressBarStatus::None),
progress: Some(0),
});
"1".to_string()
} }
#[allow(unused_variables)] #[allow(unused_variables)]
@@ -265,12 +440,10 @@ fn launch_game(
use_wine: bool, use_wine: bool,
wine_command: String, wine_command: String,
) { ) {
let game_folder = app let game_folder = match data_dir(&app) {
.path() Ok(p) => p.join("game").join(&name),
.app_local_data_dir() Err(_) => return,
.unwrap() };
.join("game")
.join(&name);
if !game_folder.exists() { if !game_folder.exists() {
return; return;
} }
@@ -280,6 +453,8 @@ fn launch_game(
//if already running on macos, it'll auto take the user to that proccess //if already running on macos, it'll auto take the user to that proccess
#[cfg(any(target_os = "windows", target_os = "linux"))] #[cfg(any(target_os = "windows", target_os = "linux"))]
{ {
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
if !use_wine && is_running_by_path(&exe_path) { if !use_wine && is_running_by_path(&exe_path) {
app.dialog() app.dialog()
.message(format!( .message(format!(
@@ -299,87 +474,382 @@ fn launch_game(
let quoted_path = format!("\"{}\"", exe_path.to_string_lossy()); let quoted_path = format!("\"{}\"", exe_path.to_string_lossy());
let cmd = wine_command.replace("%path%", &quoted_path); let cmd = wine_command.replace("%path%", &quoted_path);
Command::new("bash") if let Err(e) =
Command::new(&std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()))
.arg("-c") .arg("-c")
.arg(cmd) .arg(cmd)
.current_dir(&game_folder) .current_dir(&game_folder)
.spawn() .spawn()
.unwrap(); {
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
eprintln!("Failed to launch game with Wine: {}", e);
app.dialog()
.message(format!(
"{} failed to launch with Wine\n\n{}",
display_name, e
))
.kind(MessageDialogKind::Error)
.title("Game already running")
.show(|_| {});
}
return; return;
} }
} }
if platform() == "macos" { let bepinex_folder = game_folder.join("BepInEx");
Command::new("open")
if platform() == "macos" && !bepinex_folder.exists() && executable.ends_with(".app") {
if let Err(_) = Command::new("open")
.arg(&executable) .arg(&executable)
.current_dir(&game_folder) .current_dir(&game_folder)
.spawn() .spawn()
.unwrap(); {
eprintln!("Failed to launch game on macOS");
}
} else { } else {
Command::new(&exe_path) if (platform() == "macos" || platform() == "linux") && bepinex_folder.exists() {
if let Err(_) = Command::new("./run_bepinex.sh")
.arg(&executable)
.current_dir(&game_folder) .current_dir(&game_folder)
.spawn() .spawn()
.unwrap(); {
eprintln!("Failed to launch game");
}
} else {
if let Err(_) = Command::new(&exe_path).current_dir(&game_folder).spawn() {
eprintln!("Failed to launch game");
}
}
} }
} }
#[tauri::command] #[tauri::command]
async fn open_folder(app: AppHandle, name: String) { fn verify_signature(body: String, signature: String, public_key: String) -> bool {
let game_path = app let Ok(pubkey_pem_bytes) = base64::engine::general_purpose::STANDARD.decode(public_key) else {
.path() return false;
.app_local_data_dir() };
.unwrap()
.join("game") let Ok(sig_bytes) = base64::engine::general_purpose::STANDARD.decode(signature) else {
.join(&name); return false;
if game_path.exists() { };
app.opener()
.open_path(game_path.to_string_lossy(), None::<&str>) let Ok(pubkey_pem) = String::from_utf8(pubkey_pem_bytes) else {
.unwrap(); return false;
};
let Ok(parsed) = parse(pubkey_pem) else {
return false;
};
let Ok(pubkey) = VerifyingKey::from_public_key_der(&parsed.contents()) else {
return false;
};
let Ok(sig) = Signature::from_slice(&sig_bytes) else {
return false;
};
pubkey.verify(body.as_bytes(), &sig).is_ok()
}
#[tauri::command]
async fn cancel_download(name: String) {
cancel_map().insert(name.clone(), true);
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else { } else {
app.dialog() fs::copy(&src_path, &dst_path)?;
.message(format!( }
"Game folder \"{}\" not found.", }
game_path.display() Ok(())
)) }
.kind(MessageDialogKind::Error)
.title("Folder not found") fn move_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
.show(|_| {}); if !src.exists() {
return Ok(());
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if dst.exists() {
fs::remove_dir_all(dst)?;
}
match fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(_) => {
copy_dir_recursive(src, dst)?;
fs::remove_dir_all(src)?;
Ok(())
}
}
}
#[tauri::command]
async fn move_game_data(app: AppHandle, destination: String) -> Result<(), String> {
let default_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?;
let current_dir = data_dir(&app)?;
let new_dir = if destination.is_empty() {
default_dir.clone()
} else {
PathBuf::from(&destination)
};
if current_dir == new_dir {
return Ok(());
}
let src_game = current_dir.join("game");
let dst_game = new_dir.join("game");
let src_downloads = current_dir.join("downloads");
let dst_downloads = new_dir.join("downloads");
tauri::async_runtime::spawn_blocking(move || {
fs::create_dir_all(&new_dir).map_err(|e| e.to_string())?;
move_dir(&src_game, &dst_game).map_err(|e| e.to_string())?;
move_dir(&src_downloads, &dst_downloads).map_err(|e| e.to_string())?;
Ok::<(), String>(())
})
.await
.map_err(|e| e.to_string())??;
let state = app.state::<AppState>();
let mut guard = state.custom_data_dir.write().unwrap();
if destination.is_empty() {
*guard = None;
} else {
*guard = Some(PathBuf::from(&destination));
}
Ok(())
}
#[tauri::command]
fn open_game_folder(app: AppHandle, version: String) -> Result<(), String> {
let dir = data_dir(&app)?;
let path = dir.join("game").join(&version);
if !path.exists() || !path.is_dir() {
return Err(format!("Game folder \"{}\" not found.", path.display()));
}
#[cfg(target_os = "macos")]
Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "windows")]
Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn remove_stale_executable(app: AppHandle, version: String, executable: String) -> bool {
let dir = match data_dir(&app) {
Ok(d) => d,
Err(_) => return false,
};
let path = dir.join("game").join(&version).join(&executable);
if path.exists() {
fs::remove_file(&path).is_ok()
} else {
false
} }
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
#[allow(unused_variables)] #[allow(unused)]
tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_cli::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(
tauri_plugin_store::Builder::new()
.default_serialize_fn(|cache| Ok(serde_json::to_vec(cache)?))
.build(),
)
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_prevent_default::init())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
StateFlags::all() & !StateFlags::VISIBLE & !StateFlags::DECORATIONS,
)
.build(),
)
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
let _ = app if let Some(window) = app.get_webview_window("main") {
.get_webview_window("main") let _ = window.set_focus();
.expect("no main window") }
.set_focus();
})) }))
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_decorum::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
download, download,
launch_game, launch_game,
open_folder, folder_size,
folder_size verify_signature,
cancel_download,
move_game_data,
open_game_folder,
remove_stale_executable
]) ])
.setup(|app| { .setup(|app| {
let custom_dir = app
.path()
.app_config_dir()
.ok()
.map(|d| d.join("settings.json"))
.filter(|p| p.exists())
.and_then(|p| fs::read_to_string(p).ok())
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
.and_then(|v| {
v.get("customDataLocation")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty())
.map(PathBuf::from)
});
app.manage(AppState {
custom_data_dir: RwLock::new(custom_dir),
});
let window = app.get_webview_window("main").unwrap();
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
app_handle.exit(0);
}
});
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let main_window = app.get_webview_window("main").unwrap(); use tauri_plugin_decorum::WebviewWindowExt;
main_window.create_overlay_titlebar().unwrap(); if let Err(e) = window.create_overlay_titlebar() {
eprintln!("Failed to create overlay titlebar: {:?}", e);
} }
}
#[cfg(not(debug_assertions))]
{
use tauri_plugin_cli::CliExt;
match app.cli().matches() {
Ok(matches) => match matches.args.get("no-check-updates") {
Some(arg) => {
if arg.value == false {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
update(handle, window.clone()).await.unwrap();
});
} else {
let mut new_url = window.url().unwrap();
new_url.set_path("/main");
let _ = window.navigate(new_url);
}
}
None => {}
},
Err(_) => {}
}
}
Ok(()) Ok(())
}) });
#[cfg(target_os = "windows")]
{
builder = builder.plugin(tauri_plugin_decorum::init());
}
builder
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
#[cfg(not(debug_assertions))]
async fn update(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
) -> tauri_plugin_updater::Result<()> {
use tauri_plugin_os::arch;
let plat = {
#[cfg(target_os = "linux")]
{
if std::path::Path::new("/etc/debian_version").exists() {
"linux-debian"
} else if std::path::Path::new("/etc/redhat-release").exists() {
"linux-redhat"
} else {
let mut new_url = window.url().unwrap();
new_url.set_path("/update/outdated");
window.navigate(new_url).unwrap();
return Ok(());
}
}
#[cfg(not(target_os = "linux"))]
{
platform()
}
};
if let Some(update) = app
.updater_builder()
.target(format!("{}-{}", plat, arch()))
.build()?
.check()
.await?
{
let mut new_url = window.url().unwrap();
new_url.set_path("/update/updating");
window.navigate(new_url).unwrap();
let _ = window.show();
let _ = window.set_focus();
let mut downloaded = 0;
update
.download_and_install(
|chunk_length, content_length| {
downloaded += chunk_length;
println!("downloaded {downloaded} from {content_length:?}");
},
|| {
println!("download finished");
},
)
.await?;
println!("update installed");
app.restart();
}
let mut new_url = window.url().unwrap();
new_url.set_path("/main");
window.navigate(new_url).unwrap();
Ok(())
}
+3 -11
View File
@@ -1,24 +1,16 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Lncvrt Games Launcher", "productName": "Lncvrt Games Launcher",
"version": "1.5.7", "version": "1.10.0",
"identifier": "xyz.lncvrt.games-launcher", "identifier": "xyz.lncvrt.games-launcher",
"build": { "build": {
"beforeDevCommand": "next dev", "beforeDevCommand": "next dev --webpack",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:3000",
"beforeBuildCommand": "next build", "beforeBuildCommand": "next build",
"frontendDist": "../out" "frontendDist": "../out"
}, },
"app": { "app": {
"windows": [ "windows": [],
{
"title": "Lncvrt Games Launcher",
"width": 1000,
"height": 600,
"minWidth": 875,
"minHeight": 500
}
],
"security": { "security": {
"csp": null "csp": null
} }
+30 -5
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Lncvrt Games Launcher", "productName": "Lncvrt Games Launcher",
"version": "1.5.7", "version": "1.10.0",
"identifier": "xyz.lncvrt.games-launcher", "identifier": "xyz.lncvrt.games-launcher",
"build": { "build": {
"beforeDevCommand": "next dev", "beforeDevCommand": "next dev --webpack",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:3000",
"beforeBuildCommand": "next build", "beforeBuildCommand": "next build",
"frontendDist": "../out" "frontendDist": "../out"
@@ -13,9 +13,10 @@
"windows": [ "windows": [
{ {
"title": "Lncvrt Games Launcher", "title": "Lncvrt Games Launcher",
"url": "/update/checking",
"width": 1000, "width": 1000,
"height": 600, "height": 500,
"minWidth": 875, "minWidth": 1000,
"minHeight": 500 "minHeight": 500
} }
], ],
@@ -24,6 +25,30 @@
} }
}, },
"bundle": { "bundle": {
"active": false "active": true,
"category": "Game",
"shortDescription": "A launcher for downloading, managing, and launching games with built-in mod support.",
"longDescription": "Lncvrt Games Launcher lets you download and manage multiple versions of games, install mods, and launch them directly. Linux users can run Windows exclusive games via Wine (or proton via a custom command).",
"targets": ["deb", "rpm"],
"icon": ["icons/icon.png"],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERFODI3MTAwQkNBMjE4REEKUldUYUdLSzhBSEdDM3J0N1NvbENsdDQ3c3BOdWlFajErVnlLZitSc2l5WVI0VjQ1ZHNMWjYzaUwK",
"endpoints": [
"https://games.lncvrt.xyz/api/launcher/update?target={{target}}&currentVersion={{current_version}}"
]
},
"cli": {
"description": "Lncvrt Games Launcher CLI",
"args": [
{
"short": "n",
"name": "no-check-updates",
"description": "Bypasses th eupdate checker"
}
]
}
} }
} }
+30 -7
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Lncvrt Games Launcher", "productName": "Lncvrt Games Launcher",
"version": "1.5.7", "version": "1.10.0",
"identifier": "xyz.lncvrt.games-launcher", "identifier": "xyz.lncvrt.games-launcher",
"build": { "build": {
"beforeDevCommand": "next dev", "beforeDevCommand": "next dev --webpack",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:3000",
"beforeBuildCommand": "next build", "beforeBuildCommand": "next build",
"frontendDist": "../out" "frontendDist": "../out"
@@ -14,9 +14,10 @@
"windows": [ "windows": [
{ {
"title": "Lncvrt Games Launcher", "title": "Lncvrt Games Launcher",
"url": "/update/checking",
"width": 1000, "width": 1000,
"height": 600, "height": 500,
"minWidth": 875, "minWidth": 1000,
"minHeight": 500, "minHeight": 500,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true "hiddenTitle": true
@@ -28,10 +29,32 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app"], "category": "Game",
"shortDescription": "A launcher for downloading, managing, and launching games with built-in mod support.",
"longDescription": "Lncvrt Games Launcher lets you download and manage multiple versions of games, install mods, and launch them directly. Linux users can run Windows exclusive games via Wine (or proton via a custom command).",
"targets": ["dmg", "app"],
"icon": ["icons/icon.icns"],
"createUpdaterArtifacts": true,
"macOS": { "macOS": {
"minimumSystemVersion": "15.0" "minimumSystemVersion": "14.0"
}
}, },
"icon": ["icons/icon.icns"] "plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERFODI3MTAwQkNBMjE4REEKUldUYUdLSzhBSEdDM3J0N1NvbENsdDQ3c3BOdWlFajErVnlLZitSc2l5WVI0VjQ1ZHNMWjYzaUwK",
"endpoints": [
"https://games.lncvrt.xyz/api/launcher/update?target={{target}}&currentVersion={{current_version}}"
]
},
"cli": {
"description": "Lncvrt Games Launcher CLI",
"args": [
{
"short": "n",
"name": "no-check-updates",
"description": "Bypasses th eupdate checker"
}
]
}
} }
} }
+30 -8
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Lncvrt Games Launcher", "productName": "Lncvrt Games Launcher",
"version": "1.5.7", "version": "1.10.0",
"identifier": "xyz.lncvrt.games-launcher", "identifier": "xyz.lncvrt.games-launcher",
"build": { "build": {
"beforeDevCommand": "next dev", "beforeDevCommand": "next dev --webpack",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:3000",
"beforeBuildCommand": "next build", "beforeBuildCommand": "next build",
"frontendDist": "../out" "frontendDist": "../out"
@@ -14,12 +14,11 @@
"windows": [ "windows": [
{ {
"title": "Lncvrt Games Launcher", "title": "Lncvrt Games Launcher",
"url": "/update/checking",
"width": 1000, "width": 1000,
"height": 632, "height": 532,
"minWidth": 875, "minWidth": 1000,
"minHeight": 532, "minHeight": 532,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"decorations": false, "decorations": false,
"shadow": true "shadow": true
} }
@@ -29,7 +28,30 @@
} }
}, },
"bundle": { "bundle": {
"active": false, "active": true,
"icon": ["icons/icon.ico"] "category": "Game",
"shortDescription": "A launcher for downloading, managing, and launching games with built-in mod support.",
"longDescription": "Lncvrt Games Launcher lets you download and manage multiple versions of games, install mods, and launch them directly. Linux users can run Windows exclusive games via Wine (or proton via a custom command).",
"targets": ["msi"],
"icon": ["icons/icon.ico"],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERFODI3MTAwQkNBMjE4REEKUldUYUdLSzhBSEdDM3J0N1NvbENsdDQ3c3BOdWlFajErVnlLZitSc2l5WVI0VjQ1ZHNMWjYzaUwK",
"endpoints": [
"https://games.lncvrt.xyz/api/launcher/update?target={{target}}&currentVersion={{current_version}}"
]
},
"cli": {
"description": "Lncvrt Games Launcher CLI",
"args": [
{
"short": "n",
"name": "no-check-updates",
"description": "Bypasses th eupdate checker"
}
]
}
} }
} }
-64
View File
@@ -1,64 +0,0 @@
'use client'
import {
createContext,
useContext,
ReactNode,
Dispatch,
SetStateAction
} from 'react'
import { DownloadProgress } from './types/DownloadProgress'
import { VersionsConfig } from './types/VersionsConfig'
import { NormalConfig } from './types/NormalConfig'
import { ServerVersionsResponse } from './types/ServerVersionsResponse'
import { GameVersion } from './types/GameVersion'
import { Game } from './types/Game'
type GlobalCtxType = {
serverVersionList: ServerVersionsResponse | null
selectedVersionList: string[]
setSelectedVersionList: (value: SetStateAction<string[]>) => void
downloadProgress: DownloadProgress[]
setDownloadProgress: Dispatch<SetStateAction<DownloadProgress[]>>
showPopup: boolean
setShowPopup: Dispatch<SetStateAction<boolean>>
popupMode: number | null
setPopupMode: Dispatch<SetStateAction<number | null>>
fadeOut: boolean
setFadeOut: Dispatch<SetStateAction<boolean>>
downloadedVersionsConfig: VersionsConfig | null
setDownloadedVersionsConfig: Dispatch<SetStateAction<VersionsConfig | null>>
normalConfig: NormalConfig | null
setNormalConfig: Dispatch<SetStateAction<NormalConfig | null>>
managingVersion: string | null
setManagingVersion: Dispatch<SetStateAction<string | null>>
setSelectedGame: Dispatch<SetStateAction<number | null>>
getVersionInfo: (id: string | undefined) => GameVersion | undefined
getGameInfo: (game: number | undefined) => Game | undefined
getListOfGames(): Game[]
getVersionsAmountData: (gameId: number) => {
installed: number
total: number
} | null
viewingInfoFromDownloads: boolean
version: string | null
downloadVersions: (list: string[]) => Promise<void>
}
const GlobalCtx = createContext<GlobalCtxType | null>(null)
export const useGlobal = () => {
const ctx = useContext(GlobalCtx)
if (!ctx) throw new Error('useGlobal must be inside GlobalProvider')
return ctx
}
export const GlobalProvider = ({
children,
value
}: {
children: ReactNode
value: GlobalCtxType
}) => {
return <GlobalCtx.Provider value={value}>{children}</GlobalCtx.Provider>
}
-13
View File
@@ -1,13 +0,0 @@
@import "tailwindcss";
.downloads-container {
@apply flex justify-center;
}
.downloads-scroll {
@apply bg-(--col1) border border-(--col3) rounded-lg overflow-y-auto w-full;
}
.downloads-entry {
@apply flex justify-between items-center m-2 p-2 rounded-lg text-gray-200 text-lg transition-colors bg-(--col2) hover:bg-(--col3) border border-(--col4) hover:border-(--col5);
}
-146
View File
@@ -1,146 +0,0 @@
'use client'
import './Sidebar.css'
import Icon from '@/assets/Icon.png'
import { openUrl } from '@tauri-apps/plugin-opener'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCog,
faDownload,
faGamepad,
faHexagonNodes
} from '@fortawesome/free-solid-svg-icons'
import { faDiscord } from '@fortawesome/free-brands-svg-icons'
import { platform } from '@tauri-apps/plugin-os'
import { useGlobal } from '../GlobalProvider'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Lexend } from 'next/font/google'
const lexend = Lexend({
subsets: ['latin']
})
export default function Sidebar () {
const {
normalConfig,
getListOfGames,
setShowPopup,
setPopupMode,
setFadeOut,
downloadProgress
} = useGlobal()
const pathname = usePathname()
const params = useSearchParams()
return (
<aside className='sidebar'>
<div
className='macos-drag'
hidden={platform() != 'macos'}
onMouseDown={e => {
if (e.buttons === 1) {
if (e.detail === 2) {
getCurrentWindow().toggleMaximize()
} else {
getCurrentWindow().startDragging()
}
}
}}
></div>
<div
className='logo'
style={{
marginTop:
platform() == 'windows'
? '32px'
: platform() == 'macos'
? '28px'
: ''
}}
onMouseDown={e => {
if (platform() != 'macos') return
if (e.buttons === 1) {
if (e.detail === 2) {
getCurrentWindow().toggleMaximize()
} else {
getCurrentWindow().startDragging()
}
}
}}
>
<Image draggable={false} src={Icon} width={36} height={36} alt='' />
<p className={`ml-1 text-[16px] whitespace-nowrap ${lexend.className}`}>
Lncvrt Games Launcher
</p>
</div>
<nav className='nav-links overflow-auto pt-2'>
<Link
draggable={false}
href='/'
className={`link relative flex items-center ${
pathname === '/' || pathname === '/game' ? 'active' : ''
}`}
>
<FontAwesomeIcon icon={faHexagonNodes} className='mr-2' /> Games
</Link>
{getListOfGames()
.sort((a, b) => {
return a.id - b.id
})
.map(i => (
<Link
key={i.id}
draggable={false}
href={'/game?id=' + i.id}
className={`link ${
pathname === '/game' && Number(params.get('id') || 0) == i.id
? 'active'
: ''
} ml-auto w-50 ${
normalConfig?.settings.alwaysShowGamesInSidebar ||
pathname === '/' ||
pathname === '/game'
? ''
: 'hidden'
}`}
>
<div className='flex items-center'>
<FontAwesomeIcon icon={faGamepad} className='mr-1' />
<span className='truncate max-w-full'>{i.name}</span>
</div>
</Link>
))}
<Link
draggable={false}
href='/settings'
className={`link ${pathname === '/settings' ? 'active' : ''}`}
>
<FontAwesomeIcon icon={faCog} className='mr-1' /> Settings
</Link>
<button
onClick={() => openUrl('https://games.lncvrt.xyz/discord')}
className='link mr-auto'
>
<FontAwesomeIcon icon={faDiscord} className='mr-1' /> Community
</button>
</nav>
<div
className='sidebar-downloads'
hidden={downloadProgress.length == 0}
onClick={() => {
setPopupMode(1)
setShowPopup(true)
setFadeOut(false)
}}
>
<p>
<FontAwesomeIcon icon={faDownload} /> Downloads
</p>
</div>
</aside>
)
}
-27
View File
@@ -1,27 +0,0 @@
'use client'
import { useGlobal } from '../GlobalProvider'
export default function VersionChangelog () {
const { getVersionInfo, managingVersion, downloadedVersionsConfig } =
useGlobal()
if (!managingVersion || !downloadedVersionsConfig) return <></>
const versionInfo = getVersionInfo(managingVersion)
return (
<>
<p className='text-xl text-center'>
Viewing changelog for {versionInfo?.displayName}
</p>
<div className='popup-content text-center p-2'>
<span
className='whitespace-pre-wrap select-text'
dangerouslySetInnerHTML={{
__html: versionInfo?.changelog ? atob(versionInfo.changelog) : ''
}}
/>
</div>
</>
)
}
-125
View File
@@ -1,125 +0,0 @@
'use client'
import {
faArrowUpRightFromSquare,
faCheck,
faCode,
faHardDrive,
faShieldHalved,
faWarning
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useGlobal } from '../GlobalProvider'
import { invoke } from '@tauri-apps/api/core'
import { useEffect, useState } from 'react'
import prettyBytes from 'pretty-bytes'
export default function VersionInfo () {
const {
getGameInfo,
getVersionInfo,
managingVersion,
downloadedVersionsConfig,
viewingInfoFromDownloads,
setPopupMode
} = useGlobal()
const [versionSize, setVersionSize] = useState<number>(0)
useEffect(() => {
invoke<string>('folder_size', {
version: managingVersion
}).then(size => {
setVersionSize(Number(size))
})
}, [managingVersion, setVersionSize])
if (!managingVersion || !downloadedVersionsConfig) return <></>
const versionInfo = getVersionInfo(managingVersion)
const gameInfo = getGameInfo(versionInfo?.game)
return (
<>
<p className='text-xl text-center'>
Viewing info for {versionInfo?.displayName}
</p>
<div className='popup-content flex flex-col items-center justify-center gap-2 h-full'>
<div
className='entry-info-item btntheme2'
hidden={viewingInfoFromDownloads}
>
<p>
Installed{' '}
{new Intl.DateTimeFormat(undefined).format(
downloadedVersionsConfig.list[managingVersion] ?? 0
)}
</p>
</div>
<div
className='entry-info-item btntheme2'
hidden={!versionInfo || versionInfo.releaseDate == 0}
>
<p>
Released{' '}
{new Intl.DateTimeFormat(undefined).format(
versionInfo?.releaseDate ? versionInfo.releaseDate * 1000 : 0
)}
</p>
</div>
<div className='entry-info-item btntheme2' hidden={!gameInfo?.official}>
<FontAwesomeIcon icon={faCheck} color='#19c84b' />
<p>Official</p>
</div>
<div className='entry-info-item btntheme2' hidden={gameInfo?.official}>
<FontAwesomeIcon
icon={gameInfo?.verified ? faShieldHalved : faWarning}
color={gameInfo?.verified ? '#19c84b' : '#ffc800'}
/>
<p>{gameInfo?.verified ? 'Verified' : 'Unverified'}</p>
</div>
<div
className='entry-info-item btntheme2'
hidden={gameInfo?.developer == null}
>
<FontAwesomeIcon icon={faCode} color='lightgray' />
<p>Developer: {gameInfo?.developer}</p>
</div>
<div
className='entry-info-item btntheme2'
hidden={viewingInfoFromDownloads || versionSize === null}
>
<FontAwesomeIcon icon={faHardDrive} color='lightgray' />
<p>
Size on disk:{' '}
{versionSize > 0
? prettyBytes(versionSize, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
: 'N/A'}
</p>
</div>
<div className='entry-info-item btntheme2' hidden={!versionInfo}>
<FontAwesomeIcon icon={faHardDrive} color='lightgray' />
<p>
Size when downloaded (zipped):{' '}
{versionInfo
? prettyBytes(versionInfo.size, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
: 'Loading...'}
</p>
</div>
<div
className='entry-info-item btntheme2'
onClick={() => setPopupMode(4)}
hidden={!versionInfo?.changelog}
>
<p>View Changelog</p>
<FontAwesomeIcon icon={faArrowUpRightFromSquare} color='lightgray' />
</div>
</div>
</>
)
}
-105
View File
@@ -1,105 +0,0 @@
'use client'
import { invoke } from '@tauri-apps/api/core'
import { useGlobal } from '../GlobalProvider'
import { BaseDirectory, exists, remove } from '@tauri-apps/plugin-fs'
import { writeVersionsConfig } from '../util/BazookaManager'
import { useState } from 'react'
export default function VersionUpdateWarning () {
const [confirmed, setConfirmed] = useState<-1 | 0>(-1)
const {
managingVersion,
setDownloadedVersionsConfig,
setManagingVersion,
setPopupMode,
setSelectedVersionList,
downloadVersions
} = useGlobal()
if (!managingVersion) return <p>Error</p>
return (
<>
<p className='text-xl text-center'>Warning!</p>
<div className='popup-content text-center p-2 relative'>
<p className='mb-2'>
Before proceeding, please note that any modifications to the
installation directory <b>(NOT THE SAVE DATA)</b> will be completely
wiped/reset.
</p>
<p>
If you do not want your installation directory wiped just yet, please
backup the files to another directory. When you click update, it will
be wiped.
</p>
<p className='my-2'>
Updating will have the same effect as clicking the uninstall button
then installing again.
</p>
<p>Revisions are not a frequent thing and rarely ever happen.</p>
<div className='flex flex-row gap-2 absolute bottom-2 left-1/2 -translate-x-1/2 w-max'>
<button
className='button btntheme2'
onClick={async () =>
invoke('open_folder', {
name: managingVersion
})
}
title="Click to browse the game's files."
>
Open Folder / Installation directory
</button>
<button
className='button btntheme2'
onClick={async () => {
if (confirmed == -1) {
setConfirmed(0)
return
}
//change popup to downloads
setManagingVersion(null)
setPopupMode(1)
//uninstall
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updatedList = Object.fromEntries(
Object.entries(prev.list).filter(
([k]) => k !== managingVersion
)
)
const updatedConfig = {
...prev,
list: updatedList
}
writeVersionsConfig(updatedConfig)
return updatedConfig
})
if (
await exists('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
//reinstall
setSelectedVersionList([managingVersion])
downloadVersions([managingVersion])
}}
title='Click to update the game'
>
<span className='text-red-500 font-bold underline'>
{confirmed == -1 ? 'Update' : 'Are you sure?'}
</span>
</button>
</div>
</div>
</>
)
}
-429
View File
@@ -1,429 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import '../Installs.css'
import { invoke } from '@tauri-apps/api/core'
import { useGlobal } from '../GlobalProvider'
import { useSearchParams } from 'next/navigation'
import { platform } from '@tauri-apps/plugin-os'
import { faWarning } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
export default function Installs () {
const {
showPopup,
setShowPopup,
setPopupMode,
setFadeOut,
setSelectedVersionList,
downloadedVersionsConfig,
normalConfig,
setManagingVersion,
getVersionInfo,
getGameInfo,
setSelectedGame,
serverVersionList
} = useGlobal()
const params = useSearchParams()
const id = Number(params.get('id') || 0)
const game = serverVersionList?.games.find(g => g.id === id)
const [category, setCategory] = useState<number>(-1)
const [lastId, setLastId] = useState(id)
useEffect(() => {
if (!showPopup) return
setSelectedVersionList([])
}, [normalConfig, setSelectedVersionList, showPopup])
if (!id || !game) return <p>Invalid game</p>
if (lastId !== id) {
setLastId(id)
setCategory(-1)
}
const needsRevisionUpdate = (
lastRevision: number | undefined,
version: string
) => {
if (!lastRevision) return false
return (
lastRevision > 0 &&
(downloadedVersionsConfig == undefined
? 0
: downloadedVersionsConfig?.list[version]) /
1000 <=
lastRevision
)
}
return (
<div className='mx-4 mt-4'>
<div className='flex justify-between items-center mb-4'>
<p className='text-3xl'>Installs</p>
<div className='flex gap-2'>
<button
className='button btntheme1'
onClick={() => {
setCategory(-1)
}}
title='Click to go up a level.'
hidden={category == -1}
>
Back
</button>
<button
className='button btntheme1'
onClick={() => {
setSelectedGame(id)
setPopupMode(0)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to download more versions of this game.'
>
Download versions
</button>
</div>
</div>
<div className='downloads-container'>
<div
className={`downloads-scroll ${
platform() === 'windows'
? 'h-[calc(100vh-116px)]'
: 'h-[calc(100vh-84px)]'
}`}
>
{category == -1 &&
Object.entries(game.categoryNames)
.sort(([a], [b]) => Number(b) - Number(a))
.filter(([key]) => {
const count = Object.keys(
downloadedVersionsConfig?.list ?? {}
).filter(v => {
const info = getVersionInfo(v)
if (!info) return false
if (
platform() === 'linux' &&
info.wine &&
!normalConfig?.settings.useWineOnUnixWhenNeeded
)
return false
return info.game === id && info.category === Number(key)
}).length
return count >= 1
})
.map(([key, value]) => {
return (
<div
key={key}
className={`downloads-entry ${
normalConfig?.settings.useLegacyInteractButtons
? ''
: 'cursor-pointer'
}`}
title={
normalConfig?.settings.useLegacyInteractButtons
? ''
: 'Click to view category'
}
onClick={() => {
if (normalConfig?.settings.useLegacyInteractButtons)
return
setCategory(Number(key))
}}
>
<div className='h-18 w-screen relative'>
<p className='text-2xl'>{value}</p>
<div
className='entry-info-item flex absolute left-0 bottom-0'
title='The amount of versions installed of this game in installed/installable format.'
onClick={e => e.stopPropagation()}
>
<p>
{(() => {
const count =
Object.keys(
downloadedVersionsConfig?.list ?? []
).filter(v => {
const info = getVersionInfo(v)
if (!info) return false
if (
platform() === 'linux' &&
info.wine &&
!normalConfig?.settings
.useWineOnUnixWhenNeeded
)
return false
return (
info.game === id &&
info.category == Number(key)
)
}).length ?? 0
return `${count} install${count === 1 ? '' : 's'}`
})()}
</p>
</div>
<button
className='button absolute right-0 bottom-0'
hidden={
!normalConfig?.settings.useLegacyInteractButtons
}
title='Click to view category'
onClick={() => setCategory(Number(key))}
>
Installs
</button>
</div>
</div>
)
})}
{Object.keys(downloadedVersionsConfig?.list ?? []).filter(v => {
const info = getVersionInfo(v)
if (!info) return false
return info.game === id
}).length != 0 ? (
Object.keys(downloadedVersionsConfig?.list ?? [])
.sort((a, b) => {
const infoA = getVersionInfo(a)
const infoB = getVersionInfo(b)
if (!infoA || !infoB) return -1
return infoB.place - infoA.place
})
.filter(v => {
const info = getVersionInfo(v)
if (!info) return false
if (
platform() === 'linux' &&
info.wine &&
!normalConfig?.settings.useWineOnUnixWhenNeeded
)
return false
return (
info.game === id &&
(category == -1
? info.category == -1
: info.category == category)
)
})
.map(entry => (
<div
key={entry}
className={`downloads-entry ${
normalConfig?.settings.useLegacyInteractButtons ||
needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
? ''
: 'cursor-pointer'
}`}
title={
normalConfig?.settings.useLegacyInteractButtons ||
needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
? ''
: 'Click to launch game. Right-click to manage this version install'
}
onClick={async () => {
if (
normalConfig?.settings.useLegacyInteractButtons ||
needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
)
return
const verInfo = getVersionInfo(entry)
if (verInfo == undefined) return
const gameInfo = getGameInfo(verInfo.game)
if (gameInfo == undefined) return
invoke('launch_game', {
name: verInfo.id,
executable: verInfo.executable,
displayName: verInfo.displayName,
useWine: !!(
platform() === 'linux' &&
verInfo.wine &&
normalConfig?.settings.useWineOnUnixWhenNeeded
),
wineCommand: normalConfig?.settings.wineOnUnixCommand
})
}}
onContextMenu={e => {
e.preventDefault()
if (
normalConfig?.settings.useLegacyInteractButtons ||
needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
)
return
setManagingVersion(entry)
setPopupMode(2)
setShowPopup(true)
setFadeOut(false)
}}
>
<div className='h-18 w-screen relative'>
<p className='text-2xl'>
{getVersionInfo(entry)?.displayName}{' '}
</p>
<div className='flex gap-2 absolute left-0 bottom-0'>
<div
className='entry-info-item'
title='The date the game was installed.'
onClick={e => e.stopPropagation()}
>
<p>
Installed{' '}
{new Intl.DateTimeFormat(undefined).format(
downloadedVersionsConfig?.list[entry]
)}
</p>
</div>
<div
className='entry-info-item'
title='This version is using wine. It cannot be guarenteed to work fully and might not work at all.'
hidden={
!(
platform() === 'linux' &&
getVersionInfo(entry)?.wine
)
}
onClick={e => e.stopPropagation()}
>
<FontAwesomeIcon icon={faWarning} color='#ffc800' />
<p>Uses wine</p>
</div>
<div
className='entry-info-item'
onClick={e => e.stopPropagation()}
hidden={
!needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
}
>
<FontAwesomeIcon icon={faWarning} color='#ffc800' />
<p>Needs revision update!</p>
</div>
</div>
<div className='flex gap-2 absolute right-0 bottom-0'>
<button
className='button'
onClick={e => {
e.stopPropagation()
setManagingVersion(entry)
setPopupMode(3)
setShowPopup(true)
setFadeOut(false)
}}
hidden={needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)}
title='Click to view version info'
>
View Info
</button>
<button
className='button'
hidden={
!normalConfig?.settings.useLegacyInteractButtons ||
needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
}
onClick={e => {
e.stopPropagation()
setManagingVersion(entry)
setPopupMode(2)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to manage this version install'
>
Manage
</button>
<button
className='button'
onClick={e => {
e.stopPropagation()
const verInfo = getVersionInfo(entry)
if (verInfo == undefined) return
const gameInfo = getGameInfo(verInfo.game)
if (gameInfo == undefined) return
invoke('launch_game', {
name: verInfo.id,
executable: verInfo.executable,
displayName: verInfo.displayName,
useWine: !!(
platform() === 'linux' &&
verInfo.wine &&
normalConfig?.settings.useWineOnUnixWhenNeeded
),
wineCommand:
normalConfig?.settings.wineOnUnixCommand
})
}}
hidden={
!normalConfig?.settings.useLegacyInteractButtons ||
needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
}
title='Click to launch game'
>
Launch
</button>
<button
className='button'
onClick={e => {
e.stopPropagation()
setManagingVersion(entry)
setPopupMode(5)
setShowPopup(true)
setFadeOut(false)
}}
hidden={
!needsRevisionUpdate(
getVersionInfo(entry)?.lastRevision,
entry
)
}
title='Click to update the game'
>
Update
</button>
</div>
</div>
</div>
))
) : (
<div className='flex justify-center items-center h-full'>
<p className='text-3xl'>No versions installed</p>
</div>
)}
</div>
</div>
</div>
)
}
-965
View File
@@ -1,965 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Sidebar from './componets/Sidebar'
import './Globals.css'
import { DownloadProgress } from './types/DownloadProgress'
import { invoke } from '@tauri-apps/api/core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faAdd,
faCheck,
faChevronLeft,
faCode,
faDownload,
faInfo,
faRemove,
faShieldHalved,
faWarning,
faXmark
} from '@fortawesome/free-solid-svg-icons'
import {
readNormalConfig,
readVersionsConfig,
writeVersionsConfig
} from './util/BazookaManager'
import { VersionsConfig } from './types/VersionsConfig'
import { NormalConfig } from './types/NormalConfig'
import { app } from '@tauri-apps/api'
import axios from 'axios'
import { openUrl } from '@tauri-apps/plugin-opener'
import { GlobalProvider } from './GlobalProvider'
import { Roboto } from 'next/font/google'
import { ServerVersionsResponse } from './types/ServerVersionsResponse'
import { GameVersion } from './types/GameVersion'
import { Game } from './types/Game'
import { listen } from '@tauri-apps/api/event'
import { usePathname } from 'next/navigation'
import { arch, platform } from '@tauri-apps/plugin-os'
import VersionInfo from './componets/VersionInfo'
import prettyBytes from 'pretty-bytes'
import ProgressBar from './componets/ProgressBar'
import { notifyUser } from './util/Notifications'
import {
isPermissionGranted,
requestPermission
} from '@tauri-apps/plugin-notification'
import VersionChangelog from './componets/VersionChangelog'
import { BaseDirectory, exists, remove } from '@tauri-apps/plugin-fs'
import VersionUpdateWarning from './componets/VersionUpdateWarning'
const roboto = Roboto({
subsets: ['latin']
})
export default function RootLayout ({
children
}: {
children: React.ReactNode
}) {
const [loading, setLoading] = useState(true)
const [loadingText, setLoadingText] = useState('Loading...')
const [outdated, setOutdated] = useState(false)
const [version, setVersion] = useState<string | null>(null)
const [serverVersionList, setServerVersionList] =
useState<null | ServerVersionsResponse>(null)
const [selectedVersionList, setSelectedVersionList] = useState<string[]>([])
const [downloadedVersionsConfig, setDownloadedVersionsConfig] =
useState<VersionsConfig | null>(null)
const [normalConfig, setNormalConfig] = useState<NormalConfig | null>(null)
const [showPopup, setShowPopup] = useState(false)
const [popupMode, setPopupMode] = useState<null | number>(null)
const [fadeOut, setFadeOut] = useState(false)
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress[]>(
[]
)
const [managingVersion, setManagingVersion] = useState<string | null>(null)
const [viewingInfoFromDownloads, setViewingInfoFromDownloads] =
useState<boolean>(false)
const [selectedGame, setSelectedGame] = useState<number | null>(null)
const pathname = usePathname()
const revisionCheck = useRef(false)
function getSpecialVersionsList (game?: number): GameVersion[] {
if (!normalConfig || !serverVersionList) return []
return serverVersionList.versions
.filter(
v => !Object.keys(downloadedVersionsConfig?.list ?? []).includes(v.id)
)
.filter(v => {
if (
platform() === 'linux' &&
v.wine &&
!normalConfig.settings.useWineOnUnixWhenNeeded
)
return false
if (game && v.game != game) return false
if (downloadProgress.length != 0) {
return !downloadProgress.some(d => d.version === v.id)
}
return true
})
.sort((a, b) => {
if (b.game !== a.game) return a.game - b.game
return 0
})
}
const getVersionInfo = useCallback(
(id: string | undefined): GameVersion | undefined => {
if (!id) return undefined
return serverVersionList?.versions.find(v => v.id === id)
},
[serverVersionList]
)
const getGameInfo = useCallback(
(game: number | undefined): Game | undefined => {
if (!game) return undefined
return serverVersionList?.games.find(g => g.id === game)
},
[serverVersionList]
)
function getListOfGames (): Game[] {
if (!downloadedVersionsConfig?.list) return []
const gamesMap = new Map<number, Game>()
Object.keys(downloadedVersionsConfig.list).forEach(i => {
const version = getVersionInfo(i)
if (!version) return
const game = getGameInfo(version.game)
if (!game) return
gamesMap.set(game.id, game)
})
return Array.from(gamesMap.values())
}
function getVersionsAmountData (gameId: number): {
installed: number
total: number
} | null {
if (!downloadedVersionsConfig || !serverVersionList) return null
const allowWine =
platform() !== 'linux' || normalConfig?.settings.useWineOnUnixWhenNeeded
const installed = Object.keys(downloadedVersionsConfig.list).filter(v => {
const info = getVersionInfo(v)
if (!info) return false
if (info.wine && !allowWine) return false
return getGameInfo(info.game)?.id === gameId
}).length
const total = serverVersionList.versions.filter(v => {
if (!v) return false
if (v.wine && !allowWine) return false
return getGameInfo(v.game)?.id === gameId
}).length
return { installed, total }
}
function formatEtaSmart (seconds: number) {
if (seconds < 60) return `${Math.floor(seconds)}s`
if (seconds < 3600)
return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`
if (seconds < 86400) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
return `${d}d ${h}h`
}
function closePopup () {
if (popupMode == 0 && selectedGame && pathname === '/') {
setSelectedGame(null)
setSelectedVersionList([])
} else if (viewingInfoFromDownloads) {
setViewingInfoFromDownloads(false)
setPopupMode(0)
} else if (popupMode == 4) {
setPopupMode(3)
} else {
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
}
}
useEffect(() => {
let unlistenProgress: (() => void) | null = null
listen<string>('download-progress', event => {
const [displayName, progStr, totalSizeStr, speedStr, etaSecsStr] =
event.payload.split(':')
const prog = Number(progStr)
const progBytes = Number(totalSizeStr)
const speed = Number(speedStr)
const etaSecs = Number(etaSecsStr)
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
progress: prog,
progressBytes: progBytes,
speed,
etaSecs
}
return copy
})
}).then(f => (unlistenProgress = f))
listen<string>('download-hash-checking', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], hash_checking: true }
return copy
})
}).then(f => (unlistenProgress = f))
listen<string>('download-finishing', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], hash_checking: false, finishing: true }
return copy
})
}).then(f => (unlistenProgress = f))
return () => {
unlistenProgress?.()
}
}, [])
useEffect(() => {
;(async () => {
const client = await app.getVersion()
setVersion(client)
if (process.env.NODE_ENV === 'production') {
setLoadingText('Checking latest version...')
try {
const response = await axios.get(
'https://games.lncvrt.xyz/api/launcher/latest'
)
if (response.data !== client) {
setOutdated(true)
return
}
} catch {
setLoadingText('Failed to check latest version.')
return
}
}
setLoadingText('Downloading version list...')
try {
const res = await axios.get(
`https://games.lncvrt.xyz/api/launcher/versions?platform=${platform()}&arch=${arch()}`
)
setServerVersionList(res.data)
} catch {
setLoadingText('Failed to download versions list.')
return
}
setLoadingText('Loading configs...')
const normalConfig = await readNormalConfig()
const versionsConfig = await readVersionsConfig()
setDownloadedVersionsConfig(versionsConfig)
setNormalConfig(normalConfig)
setLoading(false)
if (!(await isPermissionGranted())) {
await requestPermission()
}
})()
}, [])
useEffect(() => {
if (process.env.NODE_ENV !== 'production') return
const handler = (e: MouseEvent) => e.preventDefault()
document.addEventListener('contextmenu', handler)
return () => document.removeEventListener('contextmenu', handler)
}, [])
const downloadVersions = useCallback(
async (list: string[]): Promise<void> => {
if (list.length === 0) return
setSelectedVersionList([])
const newDownloads = list.map(
version =>
new DownloadProgress(version, 0, 0, false, true, false, false, 0, 0)
)
setDownloadProgress(newDownloads)
for (const download of newDownloads) {
const info = getVersionInfo(download.version)
if (!info) {
setDownloadProgress(prev =>
prev.filter(d => d.version !== download.version)
)
continue
}
const gameInfo = getGameInfo(info.game)
if (!gameInfo) {
setDownloadProgress(prev =>
prev.filter(d => d.version !== download.version)
)
continue
}
setDownloadProgress(prev =>
prev.map(d =>
d.version === download.version ? { ...d, queued: false } : d
)
)
try {
await axios.get(
'https://games.lncvrt.xyz/api/launcher/download?id=' + info.id
)
} catch {}
const res = await invoke<string>('download', {
url: info.downloadUrl,
name: info.id,
executable: info.executable,
hash: info.sha512sum
})
if (res === '1') {
setDownloadProgress(prev =>
prev.filter(d => d.version !== download.version)
)
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updated = {
...prev,
list: {
...prev.list,
[download.version]: Date.now()
}
}
writeVersionsConfig(updated)
return updated
})
} else {
setDownloadProgress(prev =>
prev.map(d =>
d.version === download.version
? { ...d, queued: false, failed: true, progress: 0 }
: d
)
)
if (normalConfig?.settings.allowNotifications)
await notifyUser(
'Download Failed',
`The download for version ${info.displayName} has failed.`
)
}
}
if (normalConfig?.settings.allowNotifications)
await notifyUser('Downloads Finished', 'All downloads have finished.')
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
},
[getGameInfo, getVersionInfo, normalConfig]
)
useEffect(() => {
if (revisionCheck.current) return
if (!serverVersionList || !downloadedVersionsConfig) return
revisionCheck.current = true
;(async () => {
for (const [key, value] of Object.entries(
downloadedVersionsConfig.list
)) {
const verInfo = serverVersionList.versions.find(item => item.id === key)
if (
!verInfo ||
(verInfo.lastRevision > 0 && value / 1000 <= verInfo.lastRevision)
) {
if (
await exists('game/' + key + '/' + verInfo?.executable, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + key + '/' + verInfo?.executable, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
}
}
})()
}, [serverVersionList, downloadedVersionsConfig, downloadVersions])
return (
<>
<html lang='en' className={roboto.className}>
<body
className={
normalConfig?.settings.theme === 1
? 'red-theme'
: normalConfig?.settings.theme === 2
? 'blue-theme'
: normalConfig?.settings.theme === 3
? 'purple-theme'
: 'dark-theme'
}
>
{loading ? (
<div className='w-screen h-screen flex items-center justify-center'>
{outdated ? (
<div className='text-center'>
<p className='text-8xl mb-4'>Outdated Launcher!</p>
<p className='text-4xl mb-4'>
Please update to the latest version to continue.
</p>
<button
className='button'
onClick={() =>
openUrl('https://games.lncvrt.xyz/berrydash/download')
}
>
Download latest version
</button>
</div>
) : (
<p className='text-7xl text-center'>{loadingText}</p>
)}
</div>
) : (
<GlobalProvider
value={{
serverVersionList,
selectedVersionList,
setSelectedVersionList,
downloadProgress,
setDownloadProgress,
showPopup,
setShowPopup,
popupMode,
setPopupMode,
fadeOut,
setFadeOut,
downloadedVersionsConfig,
setDownloadedVersionsConfig,
normalConfig,
setNormalConfig,
managingVersion,
setManagingVersion,
getVersionInfo,
getGameInfo,
getListOfGames,
setSelectedGame,
getVersionsAmountData,
viewingInfoFromDownloads,
version,
downloadVersions
}}
>
<div
tabIndex={0}
onKeyDown={e => {
if (showPopup && e.key === 'Escape') closePopup()
}}
>
<Sidebar />
<div
className='relative z-2 ml-59.75 w-[calc(100vw-239px)] border-b border-(--col3) h-8.25 bg-(--col1)'
style={{
display: platform() === 'windows' ? 'block' : 'none'
}}
/>
<div className='relative z-0'>
<main className='ml-60'>{children}</main>
</div>
{showPopup && (
<div
className={`popup-overlay ${fadeOut ? 'fade-out' : ''}`}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
if (viewingInfoFromDownloads) {
setPopupMode(0)
setViewingInfoFromDownloads(false)
setManagingVersion(null)
setSelectedGame(null)
}
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
}
}}
>
<div className='popup-box'>
<button
className='close-button btntheme1'
onClick={() => closePopup()}
>
<FontAwesomeIcon
icon={
(popupMode == 0 &&
selectedGame &&
pathname === '/') ||
viewingInfoFromDownloads ||
popupMode == 4
? faChevronLeft
: faXmark
}
/>
</button>
{popupMode === 0 && selectedGame ? (
<>
<p className='text-xl text-center'>
Select versions to download
</p>
<div className='popup-content'>
{getSpecialVersionsList(selectedGame).map(
(v, i) => (
<div key={i} className='popup-entry'>
<div className='flex items-center'>
<p
className={`text-2xl truncate ${
selectedVersionList.includes(v.id)
? 'max-w-84.5'
: 'max-w-91.5'
}`}
>
{v.displayName}
</p>
</div>
<button
className='button btntheme3 right-20.75 bottom-1.75'
onClick={() => {
setSelectedVersionList(prev =>
prev.includes(v.id)
? prev.filter(i => i !== v.id)
: [...prev, v.id]
)
}}
title={
selectedVersionList.includes(v.id)
? 'This version will be downloaded. Click to remove from the list of versions that will be downloaded.'
: 'This version will NOT be downloaded. Click to add from the list of versions that will be downloaded.'
}
>
{selectedVersionList.includes(v.id) ? (
<>
<FontAwesomeIcon icon={faRemove} />{' '}
Remove
</>
) : (
<>
<FontAwesomeIcon icon={faAdd} /> Add
</>
)}
</button>
<button
className='button btntheme3 right-1.5 bottom-1.75'
onClick={() => {
setManagingVersion(v.id)
setViewingInfoFromDownloads(true)
setPopupMode(3)
}}
title='Click to view version info'
>
<FontAwesomeIcon icon={faInfo} /> Info
</button>
</div>
)
)}
</div>
</>
) : popupMode === 0 && !selectedGame ? (
<>
<p className='text-xl text-center'>
Select a game to download
</p>
<div className='popup-content'>
{serverVersionList?.games
.filter(v => {
const data = getVersionsAmountData(v.id)
if (!data) return false
if (data.total > 0) return true
})
.map((v, i) => (
<div key={i} className='popup-entry'>
<p className='text-2xl'>{v.name}</p>
<div className='flex gap-2'>
<div
className='entry-info-item btntheme3'
title='The amount of versions installed of this game in installed/installable format.'
>
<p>
{(() => {
const data = getVersionsAmountData(
v.id
)
if (!data) return 'N/A'
return `${data.installed}/${data.total}`
})()}{' '}
versions installed
</p>
</div>
<div
className='entry-info-item btntheme3'
hidden={!v.official}
title='This game is official.'
>
<FontAwesomeIcon
icon={faCheck}
color='#19c84b'
/>
<p>Official</p>
</div>
<div
className='entry-info-item btntheme3'
hidden={v.official}
title={
v.verified
? 'This game is verified to be safe'
: 'This game is NOT verified to be save. Proceed with caution.'
}
>
<FontAwesomeIcon
icon={
v.verified
? faShieldHalved
: faWarning
}
color={
v.verified ? '#19c84b' : '#ffc800'
}
/>
<p>
{v.verified ? 'Verified' : 'Unverified'}
</p>
</div>
</div>
<div
className='entry-info-item btntheme3 mt-2'
hidden={v.developer == null}
title={`The developer of ${v.name} is ${v.developer}.`}
>
<FontAwesomeIcon
icon={faCode}
color='lightgray'
/>
<p>Developer: {v.developer}</p>
</div>
<button
className='button btntheme3 right-2 bottom-2'
onClick={() => setSelectedGame(v.id)}
title={`Click to download specific versions of the game. You have ${(() => {
const data = getVersionsAmountData(v.id)
if (!data) return 'N/A'
return `${data.installed} of ${data.total}`
})()} versions downloaded.`}
>
<>
<FontAwesomeIcon icon={faDownload} />{' '}
Download
</>
</button>
</div>
))}
</div>
</>
) : popupMode === 1 ? (
<>
<p className='text-xl text-center'>Downloads</p>
<div className='popup-content'>
{downloadProgress.map((v, i) => (
<div
key={i}
className='popup-entry flex flex-col justify-between'
>
<p className='text-2xl text-center'>
{getVersionInfo(v.version)?.displayName}
</p>
<div className='mt-6.25 flex items-center justify-between'>
{v.failed ? (
<>
<div className='flex items-center'>
<span className='text-red-500 inline-block w-full text-center'>
Download failed
</span>
<button
className='button btntheme3 ml-30 mb-2'
onClick={() => {
setDownloadProgress(prev =>
prev.filter(
d => d.version !== v.version
)
)
}}
title='Click to remove this version from this menu.'
>
Cancel
</button>
</div>
</>
) : v.queued ? (
<span className='text-yellow-500 inline-block w-full text-center'>
Queued…
</span>
) : v.queued ? (
<span className='text-yellow-500 inline-block w-full text-center'>
Queued…
</span>
) : v.hash_checking ? (
<span className='text-blue-500 inline-block w-full text-center'>
Checking hash...
</span>
) : v.finishing ? (
<span className='text-green-500 inline-block w-full text-center'>
Finishing...
</span>
) : (
<div className='flex flex-col gap-1 w-full'>
<span className='text-center'>
Downloaded{' '}
{prettyBytes(v.progressBytes, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}{' '}
of{' '}
{prettyBytes(
getVersionInfo(v.version)?.size ?? 0,
{
minimumFractionDigits: 1,
maximumFractionDigits: 1
}
)}{' '}
(ETA: {formatEtaSmart(v.etaSecs)} &bull;
Speed:{' '}
{prettyBytes(v.speed, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}
/s)
</span>
<ProgressBar
progress={v.progress}
className='w-full'
/>
</div>
)}
</div>
</div>
))}
</div>
</>
) : popupMode === 2 ? (
managingVersion ? (
<>
<p className='text-xl text-center'>
Manage{' '}
{getVersionInfo(managingVersion)?.displayName}
</p>
<div className='popup-content flex flex-col items-center justify-center gap-2 h-full'>
<button
className='button btntheme2'
disabled={downloadProgress.length != 0}
onClick={async () => {
closePopup()
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updatedList = Object.fromEntries(
Object.entries(prev.list).filter(
([k]) => k !== managingVersion
)
)
const updatedConfig = {
...prev,
list: updatedList
}
writeVersionsConfig(updatedConfig)
return updatedConfig
})
if (
await exists('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
}}
title='Click to uninstall this game. This will NOT remove any progress or any save files.'
>
Uninstall
</button>
<button
className='button btntheme2'
disabled={downloadProgress.length != 0}
onClick={async () => {
//change popup to downloads
setManagingVersion(null)
setPopupMode(1)
//uninstall
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updatedList = Object.fromEntries(
Object.entries(prev.list).filter(
([k]) => k !== managingVersion
)
)
const updatedConfig = {
...prev,
list: updatedList
}
writeVersionsConfig(updatedConfig)
return updatedConfig
})
if (
await exists('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
//reinstall
setSelectedVersionList([managingVersion])
downloadVersions([managingVersion])
}}
title="Click to reinstall this game. This will NOT remove any progress or any save files. This WILL uninstall any modifications to the game's executable files."
>
Reinstall
</button>
<button
className='button btntheme2'
onClick={async () =>
invoke('open_folder', {
name: managingVersion
})
}
title="Click to browse the game's files."
>
Open Folder
</button>
</div>
</>
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : popupMode === 3 ? (
managingVersion && downloadedVersionsConfig ? (
<VersionInfo />
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : popupMode === 4 ? (
managingVersion && downloadedVersionsConfig ? (
<VersionChangelog />
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : popupMode === 5 ? (
managingVersion && downloadedVersionsConfig ? (
<VersionUpdateWarning />
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : null}
{popupMode == 0 &&
selectedGame &&
serverVersionList != null && (
<div className='flex justify-center'>
<button
className='button btntheme1 w-fit mt-2 -mb-4'
onClick={() => {
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
if (downloadedVersionsConfig)
downloadVersions(selectedVersionList)
}}
disabled={downloadProgress.length != 0}
title={
downloadProgress.length != 0
? "You cannot download the versions as you have another download that hasn't completed."
: `Click to download ${
selectedVersionList.length
} version${
selectedVersionList.length == 1 ? '' : 's'
} of ${getGameInfo(selectedGame)?.name}`
}
>
Download {selectedVersionList.length} version
{selectedVersionList.length == 1 ? '' : 's'}
</button>
<button
className='button btntheme1 w-fit mt-2 ml-2 -mb-4'
onClick={() => {
const allIds = getSpecialVersionsList(
selectedGame
).map(v => v.id)
setSelectedVersionList(prev =>
prev.length === allIds.length ? [] : allIds
)
}}
title={
selectedVersionList.length ===
getSpecialVersionsList(selectedGame).length
? 'Click to remove all selected versions for download.'
: 'Click to add all selected versions for download.'
}
>
{selectedVersionList.length ===
getSpecialVersionsList(selectedGame).length
? 'Deselect All'
: 'Select All'}
</button>
</div>
)}
</div>
</div>
)}
</div>
</GlobalProvider>
)}
</body>
</html>
</>
)
}
+416
View File
@@ -0,0 +1,416 @@
'use client'
import { verifySignature } from '@/lib/util'
import { useGlobal } from '@/providers/GlobalProvider'
import { app } from '@tauri-apps/api'
import { message } from '@tauri-apps/plugin-dialog'
import { fetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { arch, platform } from '@tauri-apps/plugin-os'
import { Dispatch, SetStateAction, useState } from 'react'
function AccountLoggedOut () {
const { account, setServerVersionList } = useGlobal()
const [username, setUsername] = useState<string>('')
const [password, setPassword] = useState<string>('')
return (
<>
<p className='text-3xl ml-4 mt-4'>Account Login</p>
<div className='ml-4 mt-4 bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
<form
className='flex flex-col gap-2'
onSubmit={async e => {
e.preventDefault()
const response = await fetch(
'https://games.lncvrt.xyz/api/account/login',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
password
})
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status != 200 ||
!(await verifySignature(JSON.stringify(data), signature))
) {
await message(
"Failed to login, there was an issue validating the data wasn't tampered with.",
{ title: 'Request tampered', kind: 'error' }
)
return
}
if (data.success) {
const response2 = await fetch(
'https://games.lncvrt.xyz/api/launcher/versions',
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch(),
Authorization: data.data.session
}
}
)
const signature2 = response2.headers.get('x-signature') ?? ''
const data2 = await response2.json()
if (
response2.status == 200 &&
(await verifySignature(JSON.stringify(data2), signature2))
) {
setServerVersionList(data2)
}
await account?.set('session', data.data.session)
await account?.set('name', data.data.username)
await account?.set('id', data.data.id)
await account?.set('admin', data.data.admin)
} else {
await message(data.message || 'n/a', {
title: 'Failed to login',
kind: 'error'
})
}
}}
>
<input
id='username'
name='username'
placeholder='Username'
type='username'
value={username}
onChange={e => setUsername(e.target.value)}
required
className='input-field btntheme1'
/>
<input
id='password'
name='password'
placeholder='Password'
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
required
className='input-field btntheme1'
/>
<button type='submit' className='button btntheme1'>
Login
</button>
<div className='flex justify-center flex-col gap-2 mt-6 text-center'>
<button
type='button'
onClick={async () =>
await openUrl(
'https://games.lncvrt.xyz/account/forgot-username'
)
}
className='button btntheme1'
>
Forgot username?
</button>
<button
type='button'
onClick={async () =>
await openUrl(
'https://games.lncvrt.xyz/account/forgot-password'
)
}
className='button btntheme1'
>
Forgot password?
</button>
<button
type='button'
onClick={async () =>
await openUrl('https://games.lncvrt.xyz/account/register')
}
className='button btntheme1'
>
Don&apos;t have an account?
</button>
</div>
</form>
</div>
</>
)
}
function AccountChangeUsername ({
setChangingUsername
}: {
setChangingUsername: Dispatch<SetStateAction<boolean>>
}) {
const { account, accountSession } = useGlobal()
const [newUsername, setNewUsername] = useState<string>('')
return (
<>
<p className='text-3xl ml-4 mt-4'>Change Account Username</p>
<div className='ml-4 mt-4 bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
<form
className='flex flex-col gap-2'
onSubmit={async e => {
e.preventDefault()
const response = await fetch(
'https://games.lncvrt.xyz/api/account/change-username',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: accountSession ?? ''
},
body: JSON.stringify({
newUsername
})
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status != 200 ||
!(await verifySignature(JSON.stringify(data), signature))
) {
await message(
"Failed to change username, there was an issue validating the data wasn't tampered with.",
{ title: 'Request tampered', kind: 'error' }
)
return
}
if (data.success) {
await account?.set('session', data.data)
await account?.set('name', newUsername)
setChangingUsername(false)
} else {
await message(data.message || 'n/a', {
title: 'Failed to change username',
kind: 'error'
})
}
}}
>
<input
id='new-username'
name='new-username'
placeholder='New username'
type='username'
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
required
className='input-field btntheme1'
/>
<button type='submit' className='button btntheme1'>
Change Username
</button>
<button
type='button'
className='button btntheme1'
onClick={() => setChangingUsername(false)}
>
Back
</button>
</form>
</div>
</>
)
}
function AccountChangePassword ({
setChangingPassword
}: {
setChangingPassword: Dispatch<SetStateAction<boolean>>
}) {
const { account, accountSession, setServerVersionList } = useGlobal()
const [newPassword, setNewPassword] = useState<string>('')
const [retypeNewPassword, setRetypeNewPassword] = useState<string>('')
return (
<>
<p className='text-3xl ml-4 mt-4'>Change Account Password</p>
<div className='ml-4 mt-4 bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
<form
className='flex flex-col gap-2'
onSubmit={async e => {
e.preventDefault()
if (newPassword !== retypeNewPassword) {
await message('Passwords must match', {
title: "Passwords don't match!",
kind: 'error'
})
return
}
const response = await fetch(
'https://games.lncvrt.xyz/api/account/change-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: accountSession ?? ''
},
body: JSON.stringify({
newPassword
})
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status != 200 ||
!(await verifySignature(JSON.stringify(data), signature))
) {
await message(
"Failed to change password, there was an issue validating the data wasn't tampered with.",
{ title: 'Request tampered', kind: 'error' }
)
return
}
if (data.success) {
const response2 = await fetch(
'https://games.lncvrt.xyz/api/launcher/versions',
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch(),
Authorization: data.data
}
}
)
const signature2 = response2.headers.get('x-signature') ?? ''
const data2 = await response2.json()
if (
response2.status == 200 &&
(await verifySignature(JSON.stringify(data2), signature2))
) {
setServerVersionList(data2)
}
await account?.set('session', data.data)
setChangingPassword(false)
} else {
await message(data.message || 'n/a', {
title: 'Failed to change password',
kind: 'error'
})
}
}}
>
<input
id='new-password'
name='new-password'
placeholder='New password'
type='password'
autoComplete='new-password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
className='input-field btntheme1'
/>
<input
id='retype-new-password'
name='retype-new-password'
placeholder='Re-type password'
type='password'
autoComplete='new-password'
value={retypeNewPassword}
onChange={e => setRetypeNewPassword(e.target.value)}
required
className='input-field btntheme1'
/>
<button type='submit' className='button btntheme1'>
Change Password
</button>
<button
type='button'
className='button btntheme1'
onClick={() => setChangingPassword(false)}
>
Back
</button>
</form>
</div>
</>
)
}
function AccountLoggedIn () {
const { account, accountName, setServerVersionList } = useGlobal()
const [changingUsername, setChangingUsername] = useState<boolean>(false)
const [changingPassword, setChangingPassword] = useState<boolean>(false)
if (changingUsername)
return <AccountChangeUsername setChangingUsername={setChangingUsername} />
if (changingPassword)
return <AccountChangePassword setChangingPassword={setChangingPassword} />
return (
<>
<p className='text-3xl ml-4 mt-4'>Account</p>
<div className='ml-4 mt-4 bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
<p>Logged in as: {accountName}</p>
<div className='flex justify-center flex-col gap-2 mt-4 text-center'>
<button
onClick={() => setChangingUsername(true)}
className='button btntheme1'
>
Change username
</button>
<button
onClick={() => setChangingPassword(true)}
className='button btntheme1'
>
Change password
</button>
<button
onClick={async () => {
const response = await fetch(
'https://games.lncvrt.xyz/api/launcher/versions',
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch(),
Authorization: ''
}
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status == 200 &&
(await verifySignature(JSON.stringify(data), signature))
) {
setServerVersionList(data)
}
await account?.clear()
}}
className='button btntheme1'
>
Logout
</button>
</div>
</div>
</>
)
}
export default function AccountsPage () {
const { accountSession, accountId, accountName } = useGlobal()
if (!accountSession || !accountId || !accountName) return <AccountLoggedOut />
else return <AccountLoggedIn />
}
+389
View File
@@ -0,0 +1,389 @@
'use client'
import { useEffect } from 'react'
import { useGlobal } from '@/providers/GlobalProvider'
import { useSearchParams } from 'next/navigation'
import { platform } from '@tauri-apps/plugin-os'
import { faWarning } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { ask } from '@tauri-apps/plugin-dialog'
import { BaseDirectory, exists } from '@tauri-apps/plugin-fs'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
export default function GamePage () {
const {
versionsList,
serverVersionList,
showPopup,
setShowPopup,
setPopupMode,
setFadeOut,
setSelectedVersionList,
setManagingVersion,
setSelectedGame,
category,
setCategory,
downloadVersions,
downloadProgress,
linuxUseWine,
customDataLocation,
needsRevisionUpdate,
launchGame,
developerMode,
theme,
accountSession,
accountId,
accountName,
version
} = useGlobal()
const params = useSearchParams()
const id = Number(params.get('id') || 0)
const game = serverVersionList?.games.find(g => g.id === id)
useEffect(() => {
if (!showPopup) return
setSelectedVersionList([])
}, [setSelectedVersionList, showPopup])
if (!id || !game) return null
const filteredVersions = Object.keys(versionsList).filter(v => {
const info = serverVersionList?.versions.find(vf => vf.id == v)
if (!info) return false
if (platform() == 'linux' && info.wine && !linuxUseWine) return false
return (
info.game === id &&
(category == -1 ? info.category == -1 : info.category == category)
)
})
return (
<div className='mx-4 mt-4'>
<div className='flex justify-between items-center mb-4'>
<p className='text-3xl truncate min-w-0 flex-1'>{game.name} Installs</p>
<div className='flex gap-2'>
{game.id == 1 && (
<>
<button
className='button btntheme1'
onClick={async () => {
const window = await WebviewWindow.getByLabel(
'berrydashchatroom'
)
if (window) {
await window.setFocus()
return
}
new WebviewWindow('berrydashchatroom', {
title: 'Berry Dash Chatroom',
url: `https://games.lncvrt.xyz/game/berry-dash/chatroom?launcher=${version}&theme=${theme}&accountToken=${accountSession}&accountId=${accountId}&accountName=${accountName}`,
width: 800,
height: 600,
minWidth: 800,
minHeight: 600
})
}}
title='View the chatroom for this game.'
>
Chatroom
</button>
<button
className='button btntheme1'
onClick={async () => {
const window = await WebviewWindow.getByLabel(
'berrydashleaderboards'
)
if (window) {
await window.setFocus()
return
}
new WebviewWindow('berrydashleaderboards', {
title: 'Berry Dash Leaderboards',
url: `https://games.lncvrt.xyz/game/berry-dash/leaderboards?launcher=${version}&theme=${theme}`,
width: 800,
height: 600,
minWidth: 800,
minHeight: 600
})
}}
title='View the leaderboards for this game.'
>
Leaderboards
</button>
</>
)}
{game.id == 9 && category == -1 && (
<button
className='button btntheme1'
onClick={async () => {
const window = await WebviewWindow.getByLabel('xpsdashboard')
if (window) {
await window.setFocus()
return
}
new WebviewWindow('xpsdashboard', {
title: 'XPS Dashboard',
url: 'https://xps.lncvrt.xyz/dashboard/',
width: 1280,
height: 720,
minWidth: 1280,
minHeight: 720
})
}}
title='Open the GDPS Dashboard!'
>
Dashboard
</button>
)}
{game.id == 11 && category == -1 && (
<button
className='button btntheme1'
onClick={async () => {
const window = await WebviewWindow.getByLabel('georayschatroom')
if (window) {
await window.setFocus()
return
}
new WebviewWindow('georayschatroom', {
title: 'Geometry Rays Chatroom',
url: `https://games.lncvrt.xyz/game/geometry-rays/chatroom?launcher=${version}&theme=${theme}`,
width: 800,
height: 600,
minWidth: 800,
minHeight: 600
})
}}
title='View the chatroom for this game.'
>
Chatroom
</button>
)}
{category != -1 && (
<button
className='button btntheme1'
onClick={() => {
setCategory(-1)
}}
title='Click to go up a level.'
>
Back
</button>
)}
<button
className='button btntheme1'
onClick={() => {
setSelectedGame(id)
setPopupMode(0)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to download more versions of this game.'
>
Download versions
</button>
</div>
</div>
<div
className={`main-container main-flex ${
platform() == 'windows'
? 'h-[calc(100vh-116px)]'
: 'h-[calc(100vh-84px)]'
}`}
>
{category == -1 &&
Object.entries(game.categoryNames)
.filter(([key]) => {
const count = Object.keys(versionsList).filter(v => {
const info = serverVersionList?.versions.find(vf => vf.id == v)
if (!info) return false
if (platform() == 'linux' && info.wine && !linuxUseWine)
return false
return info.game === id && info.category === Number(key)
}).length
return count >= 1
})
.sort(([a], [b]) => Number(b) - Number(a))
.map(([key, value]) => {
return (
<div
key={key}
className='main-entry'
title='Click to view category.'
onClick={() => setCategory(Number(key))}
>
<div className='h-18 w-screen relative'>
{developerMode && (
<p className='absolute bottom-0 right-0 text-sm'>{key}</p>
)}
<p className='text-2xl'>{value}</p>
<div
className='entry-info-item flex absolute left-0 bottom-0'
title='The amount of versions installed of this game in installed/installable format.'
onClick={e => e.stopPropagation()}
>
<p>
{(() => {
const count =
Object.keys(versionsList).filter(v => {
const info = serverVersionList?.versions.find(
vf => vf.id == v
)
if (!info) return false
if (
platform() == 'linux' &&
info.wine &&
!linuxUseWine
)
return false
return (
info.game === id && info.category == Number(key)
)
}).length ?? 0
return `${count} install${count === 1 ? '' : 's'}`
})()}
</p>
</div>
</div>
</div>
)
})}
{filteredVersions.length === 0 &&
(category !== -1 ||
Object.keys(versionsList).filter(v => {
const info = serverVersionList?.versions.find(vf => vf.id == v)
if (!info) return false
if (platform() == 'linux' && info.wine && !linuxUseWine)
return false
return info.game === id
}).length === 0) && (
<div className='flex justify-center items-center h-full'>
<p className='text-3xl'>No versions installed</p>
</div>
)}
{filteredVersions
.sort((a, b) => {
const infoA = serverVersionList?.versions.find(vf => vf.id == a)
const infoB = serverVersionList?.versions.find(vf => vf.id == b)
if (!infoA || !infoB) return 0
return infoB.place - infoA.place
})
.map(v => {
const versionInfo = serverVersionList?.versions.find(
vf => vf.id == v
)
if (!versionInfo) return
return (
<div
key={v}
className='main-entry'
title='Click to launch game. Right-click to manage this version install.'
onClick={async () => await launchGame(versionInfo)}
onContextMenu={e => {
e.preventDefault()
setManagingVersion(v)
setPopupMode(2)
setShowPopup(true)
setFadeOut(false)
}}
>
<div className='h-18 w-screen relative'>
{developerMode && (
<p className='absolute bottom-0 right-0 text-sm'>
{versionInfo.id}
</p>
)}
<p className='text-2xl'>{versionInfo.displayName}</p>
<div className='flex gap-2 absolute left-0 bottom-0'>
<div
className='entry-info-item'
title='The date the game was installed.'
>
<p>
Installed{' '}
{new Intl.DateTimeFormat(undefined).format(
versionsList[v]
)}
</p>
</div>
{platform() == 'linux' &&
versionInfo.wine &&
!needsRevisionUpdate(versionInfo.lastRevision, v) && (
<div
className='entry-info-item'
title='This version is using wine. It cannot be guarenteed to work fully and might not work at all.'
>
<FontAwesomeIcon icon={faWarning} color='#ffc800' />
<p>Uses wine</p>
</div>
)}
{needsRevisionUpdate(versionInfo.lastRevision, v) && (
<div className='entry-info-item'>
<FontAwesomeIcon icon={faWarning} color='#ffc800' />
<p>Needs revision update!</p>
</div>
)}
</div>
{versionInfo.modSupportDownload && (
<button
className='absolute right-0 bottom-0 button'
title='Click to manage mods for this game!'
onClick={async e => {
e.stopPropagation()
if (
!(await exists(
customDataLocation
? customDataLocation + '/'
: '' + 'game/' + v + '/BepInEx',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
))
) {
if (
(await ask(
"You don't have BepInEx (the mod loader for Unity Games), would you like to install it now? It is about 1MB in size. If you choose yes, it will download the recommended BepInEx version for " +
versionInfo.displayName +
'.',
{ title: 'BepInEx not found!', kind: 'error' }
)) &&
!downloadProgress.find(d => d.version == v)
) {
await downloadVersions([
{
id: v,
type: 1
}
])
}
return
}
setManagingVersion(v)
setPopupMode(3)
setShowPopup(true)
setFadeOut(false)
}}
>
Mod Manager
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
+992
View File
@@ -0,0 +1,992 @@
'use client'
import '@/styles/globals.css'
import { useCallback, useEffect, useRef, useState } from 'react'
import Sidebar from '@/components/Sidebar'
import { DownloadProgress } from '@/types/DownloadProgress'
import { app } from '@tauri-apps/api'
import { GlobalProvider } from '@/providers/GlobalProvider'
import { Roboto } from 'next/font/google'
import { ServerVersionsResponse } from '@/types/ServerVersionsResponse'
import { listen } from '@tauri-apps/api/event'
import { usePathname } from 'next/navigation'
import { arch, platform } from '@tauri-apps/plugin-os'
import {
isPermissionGranted,
requestPermission
} from '@tauri-apps/plugin-notification'
import {
BaseDirectory,
copyFile,
exists,
readTextFile,
remove
} from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import { openFolder, verifySignature } from '@/lib/util'
import { getCurrentWindow, UserAttentionType } from '@tauri-apps/api/window'
import { Mod } from '@/types/Mod'
import { load, Store } from '@tauri-apps/plugin-store'
import { notifyUser } from '@/lib/notifications'
import { GameVersion } from '@/types/GameVersion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faXmark } from '@fortawesome/free-solid-svg-icons'
import { invoke } from '@tauri-apps/api/core'
import semver from 'semver'
import { exit } from '@tauri-apps/plugin-process'
import { ask, message } from '@tauri-apps/plugin-dialog'
import VersionsDownloadPopup from '@/popups/VersionsDownload'
import GamesDownloadPopup from '@/popups/GamesDownload'
import DownloadsPopup from '@/popups/Downloads'
import ManageVersionPopup from '@/popups/ManageVersion'
import ModDownloadsPopup from '@/popups/ModDownloads'
import VersionChangelogPopup from '@/popups/VersionChangelog'
import ManageGamePopup from '@/popups/ManageGame'
const roboto = Roboto({
subsets: ['latin']
})
export default function RootLayout ({
children
}: {
children: React.ReactNode
}) {
const [loading, setLoading] = useState(true)
const [version, setVersion] = useState<string | null>(null)
const [platformName, setPlatformName] = useState<string | null>(null)
const [serverVersionList, setServerVersionList] =
useState<null | ServerVersionsResponse>(null)
const [selectedVersionList, setSelectedVersionList] = useState<string[]>([])
const [showPopup, setShowPopup] = useState(false)
const [popupMode, setPopupMode] = useState<null | number>(null)
const [fadeOut, setFadeOut] = useState(false)
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress[]>(
[]
)
const [downloadQueue, setDownloadQueue] = useState<string[]>([])
const [isProcessingQueue, setIsProcessingQueue] = useState<boolean>(false)
const [managingVersion, setManagingVersion] = useState<string | null>(null)
const [managingGame, setManagingGame] = useState<number | null>(null)
const [viewingInfoFromDownloads, setViewingInfoFromDownloads] =
useState<boolean>(false)
const [selectedGame, setSelectedGame] = useState<number | null>(null)
const [category, setCategory] = useState<number>(-1)
const [showModInfo, setShowModInfo] = useState<Mod | null>(null)
const pathname = usePathname()
const revisionCheck = useRef(false)
const [settings, setSettings] = useState<Store | null>(null)
const [versions, setVersions] = useState<Store | null>(null)
const [account, setAccount] = useState<Store | null>(null)
const [sidebarShowGames, setSidebarShowGames] = useState<boolean>(false)
const [linuxUseWine, setLinuxUseWine] = useState<boolean>(false)
const [linuxWineCommand, setLinuxWineCommand] =
useState<string>('wine %path%')
const [theme, setTheme] = useState<string>('dark')
const [customDataLocation, setCustomDataLocation] = useState<string>('')
const [developerMode, setDeveloperMode] = useState<boolean>(false)
const [versionsList, setVersionsList] = useState<Record<string, number>>({})
const [modsList, setModsList] = useState<
Record<string, Record<string, number>>
>({})
const [launchesList, setLaunchesList] = useState<
Record<string, Record<string, number>>
>({})
const [accountSession, setAccountSession] = useState<string | null>(null)
const [accountId, setAccountId] = useState<string | null>(null)
const [accountName, setAccountName] = useState<string | null>(null)
const [accountAdmin, setAccountAdmin] = useState<boolean>(false)
const downloadVersions = useCallback(
async (
list: Array<{
id: string
type: 0 | 1 | 2
modDownload?: number
gameId?: number
modId?: number
modVersion?: string
}>
): Promise<void> => {
if (list.length === 0) return
setSelectedVersionList([])
const newVersions = list.filter(
item =>
!downloadQueue.includes(item.id) &&
!downloadProgress.some(d => d.version === item.id)
)
if (newVersions.length === 0) return
const newDownloads = newVersions.map(
item =>
new DownloadProgress(
item.id,
0,
0,
false,
false,
false,
false,
true,
false,
0,
0,
0,
false,
0,
0,
null,
null,
null,
item.type,
item.modDownload ?? null,
item.gameId ?? null,
item.modId ?? null,
item.modVersion ?? null
)
)
setDownloadProgress(prev => [...prev, ...newDownloads])
setDownloadQueue(prev => [...prev, ...newVersions.map(v => v.id)])
},
[downloadQueue, downloadProgress]
)
useEffect(() => {
if (isProcessingQueue || downloadQueue.length === 0) return
const processNextDownload = async () => {
setIsProcessingQueue(true)
const versionId = downloadQueue[0]
const downloadInfo = downloadProgress.find(d => d.version == versionId)
if (!downloadInfo) {
setDownloadProgress(prev => prev.filter(d => d.version !== versionId))
setDownloadQueue(prev => prev.slice(1))
setIsProcessingQueue(false)
return
}
const info = serverVersionList?.versions.find(vf => vf.id == versionId)
if (!info) {
setDownloadProgress(prev => prev.filter(d => d.version !== versionId))
setDownloadQueue(prev => prev.slice(1))
setIsProcessingQueue(false)
return
}
const gameInfo = serverVersionList?.games.find(vf => vf.id == info.game)
if (!gameInfo) {
setDownloadProgress(prev => prev.filter(d => d.version !== versionId))
setDownloadQueue(prev => prev.slice(1))
setIsProcessingQueue(false)
return
}
setDownloadProgress(prev =>
prev.map(d => (d.version === versionId ? { ...d, queued: false } : d))
)
const downloadInfoRequest = await fetch(
'https://games.lncvrt.xyz/api/launcher/download' +
(downloadInfo.type == 0
? '?versionId=' + info.id + '&downloadId=' + info.download
: '?downloadId=' +
(downloadInfo.type == 1
? info.modSupportDownload
: downloadInfo.modDownload)) +
(downloadInfo.type == 2 ? '&modId=' + downloadInfo.modId : ''),
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch()
}
}
)
const signature = downloadInfoRequest.headers.get('x-signature') ?? ''
const data = await downloadInfoRequest.json()
if (
downloadInfoRequest.status != 200 ||
!(await verifySignature(JSON.stringify(data), signature)) ||
!data.success
) {
setDownloadProgress(prev =>
prev.map(d =>
d.version === versionId
? { ...d, queued: false, failed: true, progress: 0 }
: d
)
)
await notifyUser(
'Download Failed',
`The download for version ${info.displayName} has failed.`
)
await getCurrentWindow().requestUserAttention(
UserAttentionType.Critical
)
return
}
setDownloadProgress(prev =>
prev.map(d =>
d.version === versionId
? {
...d,
url: data.data.url,
executable: downloadInfo.type == 0 ? info.executable : null,
hash: data.data.hash
}
: d
)
)
const res = await invoke<string>('download', {
url: data.data.url,
name: info.id,
hash: data.data.hash,
downloadType: downloadInfo.type,
modId: String(downloadInfo.modId ?? '')
})
if (res === '1') {
setDownloadProgress(prev => prev.filter(d => d.version !== versionId))
if (downloadInfo.type != 1) {
if (downloadInfo.type == 2) {
const currentMods =
(await versions?.get<Record<string, Record<string, number>>>(
'mods'
)) ?? {}
await versions?.set('mods', {
...currentMods,
[downloadInfo.modGame! + '-' + downloadInfo.modId!]: {
[downloadInfo.modVersion!]: Date.now()
}
})
} else {
const currentList =
(await versions?.get<Record<string, number>>('list')) ?? {}
await versions?.set('list', {
...currentList,
[versionId]: Date.now()
})
}
}
} else if (res == '0') {
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === info.id)
if (i === -1) return prev
if (prev[i].canceled) {
return prev.filter((_, idx) => idx !== i)
}
const copy = [...prev]
copy[i] = {
...copy[i],
downloading: false,
paused: true,
failed: false
}
return copy
})
} else if (res == '-1') {
setDownloadProgress(prev =>
prev.map(d =>
d.version === versionId
? { ...d, queued: false, failed: true, progress: 0 }
: d
)
)
await notifyUser(
'Download Failed',
`The download for version ${info.displayName} has failed.`
)
await getCurrentWindow().requestUserAttention(
UserAttentionType.Critical
)
}
setDownloadQueue(prev => prev.slice(1))
setIsProcessingQueue(false)
}
processNextDownload()
}, [
downloadQueue,
isProcessingQueue,
downloadProgress,
serverVersionList,
settings,
versions,
versionsList,
modsList
])
function getSpecialVersionsList (game?: number): GameVersion[] {
if (!serverVersionList) return []
return serverVersionList.versions
.filter(v => !Object.keys(versionsList).includes(v.id))
.filter(v => {
if (platformName == 'linux' && v.wine && !linuxUseWine) return false
if (game && v.game != game) return false
if (category != -1 && v.category != category) return false
if (downloadProgress.length != 0) {
return !downloadProgress.some(d => d.version === v.id)
}
return true
})
.sort((a, b) => {
if (b.game !== a.game) return a.game - b.game
return 0
})
}
const closePopup = useCallback(() => {
if (popupMode == 0 && selectedGame && pathname === '/main/library') {
setSelectedGame(null)
setSelectedVersionList([])
} else if (viewingInfoFromDownloads) {
setViewingInfoFromDownloads(false)
setPopupMode(0)
} else if (popupMode == 3 && showModInfo) {
setShowModInfo(null)
} else if (popupMode == 4) {
setPopupMode(2)
} else {
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
}
}, [popupMode, selectedGame, pathname, viewingInfoFromDownloads, showModInfo])
const needsRevisionUpdate = (
lastRevision: number | undefined,
version: string
) => {
if (!lastRevision) return false
return (
lastRevision > 0 &&
(versionsList == undefined ? 0 : versionsList[version]) / 1000 <=
lastRevision
)
}
const uninstallVersion = async (
version: string,
updateList: boolean = true
) => {
if (updateList)
await versions?.set(
'list',
Object.fromEntries(
Object.entries(versionsList).filter(([k]) => k !== version)
)
)
if (
await exists(
(customDataLocation ? customDataLocation + '/' : '') +
'game/' +
version,
{
baseDir: customDataLocation ? undefined : BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation ? customDataLocation + '/' : '') +
'game/' +
version,
{
baseDir: customDataLocation ? undefined : BaseDirectory.AppLocalData,
recursive: true
}
)
}
const launchGame = async (versionInfo: GameVersion) => {
if (needsRevisionUpdate(versionInfo.lastRevision, versionInfo.id)) {
const answer = await ask(
'Before proceeding, if you do not want your installation directory wiped just yet, please backup the files to another directory. When you click "Yes", it will be wiped. Click "No" if you want to open the installation folder instead.',
{
title: 'Revision Update',
kind: 'warning'
}
)
if (answer) {
const answer2 = await ask(
'Are you sure you want to update? If you did not read the last popup, please go back and read it.',
{
title: 'Revision Update',
kind: 'warning'
}
)
if (!answer2) return
//open downloads popup
setPopupMode(1)
setShowPopup(true)
setFadeOut(false)
//uninstall
await uninstallVersion(versionInfo.id)
//reinstall
setSelectedVersionList([versionInfo.id])
await downloadVersions([
{
id: versionInfo.id,
type: 0
}
])
} else {
await openFolder(versionInfo.id)
}
return
}
await invoke('launch_game', {
name: versionInfo.id,
executable: versionInfo.executable,
displayName: versionInfo.displayName,
useWine: !!(platform() == 'linux' && versionInfo.wine && linuxUseWine),
wineCommand: linuxWineCommand
})
await versions?.set('launches', {
...launchesList,
[versionInfo.game]: {
...launchesList[versionInfo.game],
[versionInfo.id]: Date.now()
}
})
}
useEffect(() => {
const unlisteners: (() => void)[] = []
const setupListeners = async () => {
unlisteners.push(
await listen<string>('download-size', event => {
const [displayName, sizeStr] = event.payload.split(':')
const size = Number(sizeStr)
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], size }
return copy
})
})
)
unlisteners.push(
await listen<string>('download-progress', async event => {
const [displayName, progStr, totalSizeStr, speedStr, etaSecsStr] =
event.payload.split(':')
const prog = Number(progStr)
const progBytes = Number(totalSizeStr)
const speed = Number(speedStr)
const etaSecs = Number(etaSecsStr)
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
progress: prog,
progressBytes: progBytes,
speed,
etaSecs
}
return copy
})
})
)
unlisteners.push(
await listen<string>('download-hash-checking', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], hash_checking: true, downloading: false }
return copy
})
})
)
unlisteners.push(
await listen<string>('unzip-start', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], hash_checking: false, unzipping: true }
return copy
})
})
)
unlisteners.push(
await listen<string>('unzip-progress', async event => {
const [displayName, unzippedStr, unzipTotalStr] =
event.payload.split(':')
const unzipped = Number(unzippedStr)
const unzipTotal = Number(unzipTotalStr)
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
unzipped,
unzipTotal
}
return copy
})
})
)
unlisteners.push(
await listen<string>('download-start', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
downloading: true,
paused: false,
failed: false
}
return copy
})
})
)
unlisteners.push(
await listen<string>('download-stop', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], downloading: false }
return copy
})
})
)
}
setupListeners()
return () => {
unlisteners.forEach(unlisten => unlisten())
}
}, [])
useEffect(() => {
;(async () => {
setPlatformName(platform())
const client = await app.getVersion()
setVersion(client)
const legacyOptions = {
baseDir: BaseDirectory.AppLocalData
}
if (
platform() == 'windows' &&
(await exists('versions.json', legacyOptions))
) {
if (
await exists('versions.json', {
baseDir: BaseDirectory.AppConfig
})
)
await remove('versions.json', {
baseDir: BaseDirectory.AppConfig
})
await copyFile('versions.json', 'versions.json', {
fromPathBaseDir: BaseDirectory.AppLocalData,
toPathBaseDir: BaseDirectory.AppConfig
})
await remove('versions.json', legacyOptions)
}
const settingsLocal = await load('settings.json', {
autoSave: true,
defaults: {
version: client,
sidebarAlwaysShowGames: true,
linuxUseWine: false,
linuxWineCommand: 'wine %path%',
theme: 'dark',
customDataLocation: '',
developerMode: false
}
})
const versionsLocal = await load('versions.json', {
autoSave: true,
defaults: {
version: client,
list: {},
mods: {},
launches: {}
}
})
const accountLocal = await load('account.json', {
autoSave: true,
defaults: {
version: client,
session: null,
id: null,
name: null,
admin: false
}
})
const cfgVer = (await settingsLocal.get<string>('version')) ?? client
const versVer = (await versionsLocal.get<string>('version')) ?? client
if (semver.gt(cfgVer, client) || semver.gt(versVer, client)) {
exit(1)
return
}
await settingsLocal.set('version', client)
await versionsLocal.set('version', client)
await accountLocal.set('version', client)
if (await exists('config.json', legacyOptions)) {
const config = await readTextFile('config.json', legacyOptions)
const raw = JSON.parse(config)
if (
raw.settings &&
raw.settings.theme &&
semver.satisfies(raw.version, '>=1.0.0 <=1.5.4')
) {
const parsed = Number(raw.settings.theme)
if (parsed == 3) raw.settings.theme = 2
if (parsed == 4) raw.settings.theme = 3
else if (parsed != 0 && parsed != 1) raw.settings.theme = 0
}
if (raw.settings.allowNotifications)
await settingsLocal?.set(
'notificationsAllowed',
Boolean(raw.settings.allowNotifications)
)
if (raw.settings.alwaysShowGamesInSidebar)
await settingsLocal?.set(
'sidebarAlwaysShowGames',
Boolean(raw.settings.alwaysShowGamesInSidebar)
)
if (raw.settings.useWineOnUnixWhenNeeded)
await settingsLocal?.set(
'linuxUseWine',
Boolean(raw.settings.useWineOnUnixWhenNeeded)
)
if (raw.settings.wineOnUnixCommand)
await settingsLocal?.set(
'linuxWineCommand',
String(raw.settings.wineOnUnixCommand)
)
if (raw.settings.theme) {
const tempTheme = (() => {
switch (Number(raw.settings.theme)) {
case 1:
return 'red'
case 2:
return 'blue'
case 3:
return 'purple'
default:
return 'dark'
}
})()
await settingsLocal?.set('theme', tempTheme)
}
await remove('config.json', legacyOptions)
}
setSettings(settingsLocal)
setVersions(versionsLocal)
setAccount(accountLocal)
try {
const response = await fetch(
'https://games.lncvrt.xyz/api/launcher/versions',
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch(),
Authorization: (await accountLocal.get('session')) ?? ''
}
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status == 200 &&
(await verifySignature(JSON.stringify(data), signature))
) {
setServerVersionList(data)
} else {
await message(
'Failed to get version manifest, please try again later.',
{ title: 'Error fetching launcher manifest', kind: 'error' }
)
await exit(1)
return
}
} catch {
await message(
'Failed to get version manifest, please try again later.',
{ title: 'Error fetching launcher manifest', kind: 'error' }
)
await exit(1)
return
}
if (!(await isPermissionGranted())) {
await requestPermission()
}
setLoading(false)
})()
}, [])
useEffect(() => {
if (revisionCheck.current) return
if (!serverVersionList) return
revisionCheck.current = true
;(async () => {
for (const [key, value] of Object.entries(versionsList)) {
const verInfo = serverVersionList.versions.find(item => item.id === key)
if (
verInfo &&
verInfo.lastRevision > 0 &&
value / 1000 <= verInfo.lastRevision
) {
await invoke('remove_stale_executable', {
version: key,
executable: verInfo.executable
})
}
}
})()
}, [serverVersionList, versionsList])
useEffect(() => {
if (
downloadProgress.length === 0 &&
downloadQueue.length === 0 &&
showPopup &&
popupMode === 1
) {
setTimeout(async () => {
await notifyUser('Downloads Complete', 'All downloads finished!')
await getCurrentWindow().requestUserAttention(
UserAttentionType.Informational
)
closePopup()
}, 0)
}
}, [downloadProgress, downloadQueue, showPopup, popupMode, closePopup])
useEffect(() => {
if (!settings || !versions) return
const unlisteners: Promise<() => void>[] = []
function watchSettings<T> (key: string, fn: (v: T | undefined) => void) {
settings!.get<T>(key).then(fn)
unlisteners.push(settings!.onKeyChange<T>(key, fn))
}
function watchVersions<T> (key: string, fn: (v: T | undefined) => void) {
versions!.get<T>(key).then(fn)
unlisteners.push(versions!.onKeyChange<T>(key, fn))
}
function watchAccount<T> (key: string, fn: (v: T | undefined) => void) {
account!.get<T>(key).then(fn)
unlisteners.push(account!.onKeyChange<T>(key, fn))
}
watchSettings<string>('theme', v => setTheme(v ?? 'dark'))
watchSettings<boolean>('sidebarShowGames', v =>
setSidebarShowGames(v ?? true)
)
watchSettings<boolean>('linuxUseWine', v => setLinuxUseWine(v ?? false))
watchSettings<string>('linuxWineCommand', v =>
setLinuxWineCommand(v ?? 'wine %path%')
)
watchSettings<string>('customDataLocation', v =>
setCustomDataLocation(v ?? '')
)
watchSettings<boolean>('developerMode', v => setDeveloperMode(v ?? false))
watchVersions<Record<string, number>>('list', v => setVersionsList(v ?? {}))
watchVersions<Record<string, Record<string, number>>>('mods', v =>
setModsList(v ?? {})
)
watchVersions<Record<string, Record<string, number>>>('launches', v =>
setLaunchesList(v ?? {})
)
watchAccount<string | null>('session', v => setAccountSession(v ?? null))
watchAccount<string | null>('id', v => setAccountId(v ?? null))
watchAccount<string | null>('name', v => setAccountName(v ?? null))
watchAccount<boolean>('admin', v => setAccountAdmin(v ?? false))
return () => {
unlisteners.forEach(u => u.then(fn => fn()))
}
}, [settings, versions, account])
return (
<>
<html lang='en' className={roboto.className}>
<body className={theme + '-theme'}>
{!loading && (
<GlobalProvider
value={{
serverVersionList,
setServerVersionList,
selectedVersionList,
setSelectedVersionList,
downloadProgress,
setDownloadProgress,
showPopup,
setShowPopup,
popupMode,
setPopupMode,
fadeOut,
setFadeOut,
managingVersion,
setManagingVersion,
setSelectedGame,
viewingInfoFromDownloads,
version,
downloadVersions,
category,
setCategory,
downloadQueue,
setDownloadQueue,
closePopup,
selectedGame,
setViewingInfoFromDownloads,
showModInfo,
setShowModInfo,
getSpecialVersionsList,
settings,
versions,
account,
sidebarShowGames,
linuxUseWine,
linuxWineCommand,
theme,
customDataLocation,
developerMode,
versionsList,
modsList,
launchesList,
accountSession,
accountId,
accountName,
accountAdmin,
managingGame,
setManagingGame,
needsRevisionUpdate,
uninstallVersion,
launchGame
}}
>
<div
tabIndex={0}
onKeyDown={e => {
if (showPopup && e.key === 'Escape') closePopup()
}}
>
<Sidebar />
{platformName == 'windows' && (
<div className='relative z-2 ml-59.75 w-[calc(100vw-239px)] border-b border-b-(--col3) h-8.25 bg-(--col1)' />
)}
<div className='relative z-0'>
<main className='ml-60'>{children}</main>
</div>
{showPopup && (
<div
className={`popup-overlay ${fadeOut ? 'fade-out' : ''}`}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
if (showModInfo) setShowModInfo(null)
if (viewingInfoFromDownloads) {
setPopupMode(0)
setViewingInfoFromDownloads(false)
setManagingVersion(null)
setSelectedGame(null)
}
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
}
}}
>
<div className='popup-box'>
<button
className='popup-top-button left-2 btntheme1'
onClick={() => closePopup()}
>
<FontAwesomeIcon
icon={
(popupMode == 0 &&
selectedGame &&
pathname === '/main/library') ||
viewingInfoFromDownloads ||
(popupMode == 3 && showModInfo) ||
popupMode == 4
? faChevronLeft
: faXmark
}
/>
</button>
{popupMode === 0 && selectedGame ? (
<VersionsDownloadPopup />
) : popupMode === 0 && !selectedGame ? (
<GamesDownloadPopup />
) : popupMode === 1 ? (
<DownloadsPopup />
) : popupMode === 2 ? (
<ManageVersionPopup />
) : popupMode === 3 ? (
<ModDownloadsPopup />
) : popupMode === 4 ? (
<VersionChangelogPopup />
) : popupMode === 5 ? (
<ManageGamePopup />
) : null}
</div>
</div>
)}
</div>
</GlobalProvider>
)}
</body>
</html>
</>
)
}
+147
View File
@@ -0,0 +1,147 @@
'use client'
import { useEffect } from 'react'
import { useGlobal } from '@/providers/GlobalProvider'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCheck,
faShieldHalved,
faWarning
} from '@fortawesome/free-solid-svg-icons'
import { platform } from '@tauri-apps/plugin-os'
import { useRouter } from 'next/navigation'
export default function LibraryPage () {
const {
versionsList,
serverVersionList,
showPopup,
setShowPopup,
setPopupMode,
setFadeOut,
setSelectedVersionList,
setSelectedGame,
setCategory,
setManagingGame,
developerMode
} = useGlobal()
const router = useRouter()
useEffect(() => {
if (!showPopup) return
setSelectedVersionList([])
}, [setSelectedVersionList, showPopup])
const filteredGames =
serverVersionList?.games.filter(g =>
serverVersionList.versions
.filter(v => v.game === g.id)
.some(v => Object.keys(versionsList).includes(v.id))
) ?? []
return (
<div className='mx-4 mt-4'>
<div className='flex justify-between items-center mb-4'>
<p className='text-3xl'>Games</p>
<button
className='button btntheme1'
onClick={() => {
setSelectedGame(null)
setPopupMode(0)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to download more games.'
>
Download game
</button>
</div>
<div
className={`main-container main-flex ${
platform() == 'windows'
? 'h-[calc(100vh-116px)]'
: 'h-[calc(100vh-84px)]'
}`}
>
{filteredGames.length == 0 ? (
<div className='flex justify-center items-center h-full'>
<p className='text-3xl'>No games installed</p>
</div>
) : (
filteredGames.map(i => (
<div
key={i.id}
className='main-entry'
title='Click to view game installs.'
onClick={() => {
setCategory(-1)
router.push('/main/game?id=' + i.id)
}}
onContextMenu={e => {
e.preventDefault()
setManagingGame(i.id)
setPopupMode(5)
setShowPopup(true)
setFadeOut(false)
}}
>
<div className='h-18 w-screen relative'>
{developerMode && (
<p className='absolute bottom-0 right-0 text-sm'>{i.id}</p>
)}
<p className='text-2xl'>{i.name}</p>
<div className='flex gap-2 absolute left-0 bottom-0'>
<div
className='entry-info-item'
title='The amount of versions installed of this game in installed/installable format.'
>
<p>
{(() => {
const gameVersions = (
serverVersionList?.versions ?? []
).filter(v => v.game === i.id)
const installed = gameVersions.filter(v =>
Object.keys(versionsList).includes(v.id)
).length
return gameVersions.length
? `${installed}/${gameVersions.length}`
: 'N/A'
})()}{' '}
versions installed
</p>
</div>
{i.official ? (
<div
className='entry-info-item'
title='This game is official.'
>
<FontAwesomeIcon icon={faCheck} color='#19c84b' />
<p>Official</p>
</div>
) : (
<div
className='entry-info-item'
title={
i.verified
? 'This game is verified to be safe'
: 'This game is not verified to be save. Proceed with caution.'
}
>
<FontAwesomeIcon
icon={i.verified ? faShieldHalved : faWarning}
color={i.verified ? '#19c84b' : '#ffc800'}
/>
<p>{i.verified ? 'Verified' : 'Unverified'}</p>
</div>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
)
}
+180
View File
@@ -0,0 +1,180 @@
'use client'
import { useGlobal } from '@/providers/GlobalProvider'
import { Swiper, SwiperSlide } from 'swiper/react'
import type { Swiper as SwiperType } from 'swiper'
import { Navigation } from 'swiper/modules'
import 'swiper/css'
import { formatDistanceToNow } from 'date-fns'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faChevronLeft,
faChevronRight,
faEllipsis,
faPlay
} from '@fortawesome/free-solid-svg-icons'
import { useEffect, useRef, useState } from 'react'
export default function HomePage () {
const {
launchesList,
serverVersionList,
versionsList,
accountName,
launchGame,
setManagingVersion,
setPopupMode,
setShowPopup,
setFadeOut
} = useGlobal()
const filteredLaunchesList = Object.fromEntries(
Object.entries(launchesList)
.flatMap(([gameId, launches]) => {
const entries = Object.entries(launches).filter(([version]) => {
return (
serverVersionList?.versions.find(v => version == v.id) &&
Object.entries(versionsList).find(([v]) => v == version)
)
})
if (entries.length === 0) return []
const [version, timestamp] = entries.reduce((latest, current) =>
current[1] > latest[1] ? current : latest
)
return [[`${gameId}-${version}`, timestamp] as const]
})
.sort((a, b) => b[1] - a[1])
)
const swiperRef = useRef<SwiperType | null>(null)
const prevRef = useRef<HTMLButtonElement>(null)
const nextRef = useRef<HTMLButtonElement>(null)
const [isLocked, setIsLocked] = useState(false)
const [recentSlidesPerView, setRecentSlidesPerView] = useState(1)
useEffect(() => {
const update = () => {
setRecentSlidesPerView(Math.floor(window.innerWidth / 400))
}
update()
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}, [])
return (
<div className='mx-4 mt-4'>
<div className='flex flex-col gap-1 mb-4'>
<p className='text-3xl'>Home</p>
<p className='text-gray-300'>Welcome back, {accountName ?? 'User'}!</p>
</div>
<div>
<p className='text-2xl'>Jump back in</p>
{Object.entries(filteredLaunchesList).length == 0 ? (
<p className='text-gray-300'>You have no recently launched games</p>
) : (
<div className='flex flex-col items-center gap-2'>
<Swiper
modules={[Navigation]}
spaceBetween={16}
slidesPerView={Math.min(
recentSlidesPerView,
Object.entries(filteredLaunchesList).length
)}
autoHeight={false}
className='w-full'
onSwiper={swiper => {
swiperRef.current = swiper
setIsLocked(swiper.isLocked)
}}
onResize={swiper => {
setIsLocked(swiper.isLocked)
}}
onUpdate={swiper => {
setIsLocked(swiper.isLocked)
}}
onBeforeInit={swiper => {
if (
swiper.params.navigation &&
typeof swiper.params.navigation !== 'boolean'
) {
swiper.params.navigation.prevEl = prevRef.current
swiper.params.navigation.nextEl = nextRef.current
}
}}
>
{Object.entries(filteredLaunchesList).map(([key, timestamp]) => {
const versionInfo = serverVersionList?.versions.find(
v => v.id == key.split('-').slice(1).join('-')
)
if (!versionInfo) return
return (
<SwiperSlide
key={key}
className='flex flex-col gap-2 bg-(--col1) border border-(--col3) rounded-lg px-3 py-2'
>
<p
className='truncate text-2xl'
title={versionInfo?.displayName}
>
{versionInfo?.displayName}
</p>
<p className='truncate text-gray-300'>
Last played{' '}
<span title={new Date(timestamp).toString()}>
{formatDistanceToNow(new Date(timestamp), {
addSuffix: true
})}
</span>
</p>
<div className='flex flex-row gap-2 my-1'>
<FontAwesomeIcon
icon={faPlay}
className='button btntheme1'
onClick={async () => await launchGame(versionInfo)}
/>
<FontAwesomeIcon
icon={faEllipsis}
className='button btntheme1'
onClick={() => {
setManagingVersion(versionInfo.id)
setPopupMode(2)
setShowPopup(true)
setFadeOut(false)
}}
/>
</div>
</SwiperSlide>
)
})}
</Swiper>
{!isLocked && (
<div className='flex flex-row gap-2'>
<button
ref={prevRef}
onClick={() => swiperRef.current?.slidePrev()}
className='button btntheme1 w-8 h-8 flex justify-center items-center'
>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
<button
ref={nextRef}
onClick={() => swiperRef.current?.slideNext()}
className='button btntheme1 w-8 h-8 flex justify-center items-center'
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
</div>
)}
</div>
)}
</div>
</div>
)
}
+310
View File
@@ -0,0 +1,310 @@
'use client'
import { Setting } from '@/components/Setting'
import { useGlobal } from '@/providers/GlobalProvider'
import { copyToClipboard } from '@/lib/clipboard'
import { platform } from '@tauri-apps/plugin-os'
import { confirm, message, open } from '@tauri-apps/plugin-dialog'
import { invoke } from '@tauri-apps/api/core'
import Dropdown from '@/components/Dropdown'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { exit, relaunch } from '@tauri-apps/plugin-process'
import { BaseDirectory, exists, readDir, remove } from '@tauri-apps/plugin-fs'
import { openPath } from '@tauri-apps/plugin-opener'
import { appLocalDataDir } from '@tauri-apps/api/path'
import { getCurrentWindow } from '@tauri-apps/api/window'
export default function SettingsPage () {
const {
settings,
versions,
account,
version,
versionsList,
linuxUseWine,
linuxWineCommand,
theme,
customDataLocation,
downloadProgress
} = useGlobal()
return (
<>
<p className='text-3xl ml-4 mt-4'>Settings</p>
<div className='flex flex-row p-4 gap-4'>
<div className='bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
<Setting
label='Use wine if needed'
value={linuxUseWine}
onChange={async () => {
await settings?.set('linuxUseWine', !linuxUseWine)
}}
className={platform() == 'linux' ? '' : 'hidden'}
/>
{platform() == 'linux' && linuxUseWine && (
<>
<p>Wine Command:</p>
<input
type='text'
value={linuxWineCommand}
onChange={async e =>
await settings?.set('linuxWineCommand', e.target.value)
}
className='input-field my-1'
></input>
</>
)}
<Setting
label='Use custom data location'
value={!!customDataLocation}
disabled={downloadProgress.length > 0}
onChange={async () => {
if (customDataLocation) {
try {
const confirmation = await confirm(
'Data will be moved back to the original location. The launcher will restart when completed and will not be visible until the restart.',
{ title: 'Moving data', kind: 'warning' }
)
if (!confirmation) return
const window = getCurrentWindow()
await window.hide()
await invoke('move_game_data', { destination: '' })
await settings?.set('customDataLocation', '')
await relaunch()
} catch {
await exit(1)
}
} else {
const selected = await open({
directory: true,
title: 'Select data location'
})
if (!selected || typeof selected !== 'string') return
try {
const confirmation = await confirm(
`Data will be moved to the directory "${selected}". The launcher will restart when completed and will not be visible until the restart.`,
{ title: 'Moving data', kind: 'warning' }
)
if (!confirmation) return
const window = getCurrentWindow()
await window.hide()
await invoke('move_game_data', { destination: selected })
await settings?.set('customDataLocation', selected)
await relaunch()
} catch {
await exit(1)
await exit(1)
}
}
}}
title='Move game data to a custom storage location, like a different disk.'
/>
{customDataLocation && (
<p className='text-sm opacity-50 ml-6 -mt-1 mb-2'>
{customDataLocation}
</p>
)}
<div className='flex flex-row gap-2 items-center'>
<p
title='The theme you want the launcher to use.'
className='text-lg'
>
Theme:
</p>
<Dropdown
value={theme}
options={[
{ label: 'Dark', value: 'dark' },
{ label: 'Red', value: 'red' },
{ label: 'Blue', value: 'blue' },
{ label: 'Purple', value: 'purple' }
]}
onChange={async val => {
await settings?.set('theme', val)
const window = await WebviewWindow.getByLabel(
'berrydashleaderboards'
)
if (window) {
await window.close()
new WebviewWindow('berrydashleaderboards', {
title: 'Berry Dash Leaderboards',
url:
'https://games.lncvrt.xyz/game/berry-dash/leaderboards?launcher=1&theme=' +
val,
width: 800,
height: 600,
resizable: false,
maximizable: false
})
}
}}
/>
</div>
</div>
<div className='bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit flex flex-col gap-2'>
<button
className='button btntheme1'
title='Restarts the launcher. This may help if the launcher is having issues.'
onClick={async () => await relaunch()}
>
Restart launcher
</button>
<button
className='button btntheme1'
title='Completely reset the launcher, delete games, logout of account & reset settings.'
onClick={async () => {
if (
await exists(
(customDataLocation ? customDataLocation + '/' : '') + 'game',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation ? customDataLocation + '/' : '') + 'game',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
if (
await exists(
(customDataLocation ? customDataLocation + '/' : '') +
'downloads',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation ? customDataLocation + '/' : '') +
'downloads',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
await settings?.clear()
await versions?.clear()
await account?.clear()
await relaunch()
}}
>
Reset launcher
</button>
<button
className='button btntheme1'
title='If you pause or cancel a download, it will be removed here.'
onClick={async () => {
if (
await exists(
(customDataLocation ? customDataLocation + '/' : '') +
'downloads',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation ? customDataLocation + '/' : '') +
'downloads',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
}}
>
Delete download cache
</button>
<button
className='button btntheme1'
title='If you had a game that was downloaded before it was taken down after you downloaded it, it will get removed here.'
onClick={async () => {
const items = await readDir(
(customDataLocation ? customDataLocation + '/' : '') + 'game',
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
const removed: string[] = []
for (const item of items) {
if (item.isFile || item.isSymlink) continue
if (
!Object.entries(versionsList).find(v => v[0] == item.name)
) {
removed.push(item.name)
await remove(
(customDataLocation ? customDataLocation + '/' : '') +
'game/' +
item.name,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
}
}
await message(
'Removed the following game items: ' +
(removed.length == 0 ? '(none)' : removed.join(', ')),
{ title: 'Sucessfully cleaned games folder', kind: 'info' }
)
}}
>
Delete invalid/unavailable games
</button>
<button
className='button btntheme1'
title='This will open the directory with the app data.'
onClick={async () => await openPath(await appLocalDataDir())}
>
Open app data folder
</button>
{customDataLocation && (
<button
className='button btntheme1'
title='This will open the directory with the custom app data.'
onClick={async () => await openPath(customDataLocation)}
>
Open custom app data folder
</button>
)}
<p className='text-center'>Hover buttons for descriptions</p>
</div>
</div>
<p
className='fixed bottom-1.5 right-1.5 rounded-md cursor-pointer px-1 border z-100 transition-colors btntheme1'
onClick={async () => await copyToClipboard(`v${version}`)}
title='The current launcher version.'
>
v{version}
</p>
</>
)
}
-151
View File
@@ -1,151 +0,0 @@
'use client'
import { useEffect } from 'react'
import './Installs.css'
import { useGlobal } from './GlobalProvider'
import Link from 'next/link'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCheck,
faShieldHalved,
faWarning
} from '@fortawesome/free-solid-svg-icons'
import { platform } from '@tauri-apps/plugin-os'
import { useRouter } from 'next/navigation'
export default function Installs () {
const {
showPopup,
setShowPopup,
setPopupMode,
setFadeOut,
setSelectedVersionList,
downloadedVersionsConfig,
normalConfig,
setSelectedGame,
getListOfGames,
getVersionsAmountData
} = useGlobal()
const router = useRouter()
useEffect(() => {
if (!showPopup) return
setSelectedVersionList([])
}, [normalConfig, setSelectedVersionList, showPopup])
return (
<div className='mx-4 mt-4'>
<div className='flex justify-between items-center mb-4'>
<p className='text-3xl'>Games</p>
<button
className='button btntheme1'
onClick={() => {
setSelectedGame(null)
setPopupMode(0)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to download more games.'
>
Download game
</button>
</div>
<div className='downloads-container'>
<div
className={`downloads-scroll ${
platform() === 'windows'
? 'h-[calc(100vh-116px)]'
: 'h-[calc(100vh-84px)]'
}`}
>
{downloadedVersionsConfig &&
Object.keys(downloadedVersionsConfig.list).length ? (
getListOfGames()
.sort((a, b) => {
return a.id - b.id
})
.map(i => (
<div
key={i.id}
className={`downloads-entry ${
normalConfig?.settings.useLegacyInteractButtons
? ''
: 'cursor-pointer'
}`}
title={
normalConfig?.settings.useLegacyInteractButtons
? ''
: 'Click to view game installs'
}
onClick={() => {
if (normalConfig?.settings.useLegacyInteractButtons) return
router.push('/game?id=' + i.id)
}}
>
<div className='h-18 w-screen relative'>
<p className='text-2xl'>{i.name}</p>
<div className='flex gap-2 absolute left-0 bottom-0'>
<div
className='entry-info-item'
title='The amount of versions installed of this game in installed/installable format.'
onClick={e => e.stopPropagation()}
>
<p>
{(() => {
const data = getVersionsAmountData(i.id)
if (!data) return 'N/A'
return `${data.installed}/${data.total}`
})()}{' '}
versions installed
</p>
</div>
<div
className='entry-info-item'
hidden={!i.official}
title='This game is official.'
onClick={e => e.stopPropagation()}
>
<FontAwesomeIcon icon={faCheck} color='#19c84b' />
<p>Official</p>
</div>
<div
className='entry-info-item'
hidden={i.official}
title={
i.verified
? 'This game is verified to be safe'
: 'This game is not verified to be save. Proceed with caution.'
}
onClick={e => e.stopPropagation()}
>
<FontAwesomeIcon
icon={i.verified ? faShieldHalved : faWarning}
color={i.verified ? '#19c84b' : '#ffc800'}
/>
<p>{i.verified ? 'Verified' : 'Unverified'}</p>
</div>
</div>
<Link
className='button absolute right-0 bottom-0'
href={'/game?id=' + i.id}
hidden={!normalConfig?.settings.useLegacyInteractButtons}
title='Click to view game installs'
>
Installs
</Link>
</div>
</div>
))
) : (
<div className='flex justify-center items-center h-full'>
<p className='text-3xl'>No games installed</p>
</div>
)}
</div>
</div>
</div>
)
}
-213
View File
@@ -1,213 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Setting } from '../componets/Setting'
import { writeNormalConfig } from '../util/BazookaManager'
import { useGlobal } from '../GlobalProvider'
import { copyToClipboard } from '../util/Clipboard'
import { platform } from '@tauri-apps/plugin-os'
export default function Settings () {
const [allowNotifications, setAllowNotifications] = useState(false)
const [alwaysShowGamesInSidebar, setAlwaysShowGamesInSidebar] =
useState(false)
const [useLegacyInteractButtons, setUseLegacyInteractButtons] =
useState(false)
const [useWineOnUnixWhenNeeded, setUseWineOnUnixWhenNeeded] = useState(false)
const [wineOnUnixCommand, setWineOnUnixCommand] = useState('wine %path%')
const [theme, setTheme] = useState(0)
const [loaded, setLoaded] = useState(false)
const { normalConfig, setNormalConfig, version } = useGlobal()
useEffect(() => {
;(async () => {
while (normalConfig != null) {
setAllowNotifications(normalConfig.settings.allowNotifications)
setAlwaysShowGamesInSidebar(
normalConfig.settings.alwaysShowGamesInSidebar
)
setUseLegacyInteractButtons(
normalConfig.settings.useLegacyInteractButtons
)
setUseWineOnUnixWhenNeeded(
normalConfig.settings.useWineOnUnixWhenNeeded
)
setWineOnUnixCommand(normalConfig.settings.wineOnUnixCommand)
setTheme(normalConfig.settings.theme)
setLoaded(true)
break
}
})()
}, [normalConfig])
return (
<>
<p className='text-3xl ml-4 mt-4'>Settings</p>
{loaded && (
<div className='ml-4 mt-4 bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
<Setting
label='Allow sending notifications'
value={allowNotifications}
onChange={async () => {
if (!normalConfig) return
setAllowNotifications(!allowNotifications)
setNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
allowNotifications: !allowNotifications
}
})
await writeNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
allowNotifications: !allowNotifications
}
})
}}
title='This setting does as you expect, allow the launcher to send notifications for when stuff like downloading is done.'
/>
<Setting
label='Always show games in sidebar'
value={alwaysShowGamesInSidebar}
onChange={async () => {
if (!normalConfig) return
setAlwaysShowGamesInSidebar(!alwaysShowGamesInSidebar)
setNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
alwaysShowGamesInSidebar: !alwaysShowGamesInSidebar
}
})
await writeNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
alwaysShowGamesInSidebar: !alwaysShowGamesInSidebar
}
})
}}
title="This setting will make it so when you are on a page like this, the games won't disappear."
/>
<Setting
label='Show Installs/Launch/Manage Buttons'
value={useLegacyInteractButtons}
onChange={async () => {
if (!normalConfig) return
setUseLegacyInteractButtons(!useLegacyInteractButtons)
setNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
useLegacyInteractButtons: !useLegacyInteractButtons
}
})
await writeNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
useLegacyInteractButtons: !useLegacyInteractButtons
}
})
}}
title='Enable the legacy method of using the installs/launch/manage buttons. In the future this setting may be removed so try and get used to the new method.'
/>
<Setting
label='Use wine when needed to launch games'
value={useWineOnUnixWhenNeeded}
onChange={async () => {
if (!normalConfig) return
setUseWineOnUnixWhenNeeded(!useWineOnUnixWhenNeeded)
setNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
useWineOnUnixWhenNeeded: !useWineOnUnixWhenNeeded
}
})
await writeNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
useWineOnUnixWhenNeeded: !useWineOnUnixWhenNeeded
}
})
}}
className={platform() == 'linux' ? '' : 'hidden'}
/>
<p hidden={!(platform() == 'linux' && useWineOnUnixWhenNeeded)}>
Wine Command:
</p>
<input
type='text'
value={wineOnUnixCommand}
onChange={async e => {
if (!normalConfig) return
setWineOnUnixCommand(e.target.value)
setNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
wineOnUnixCommand: e.target.value
}
})
await writeNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
wineOnUnixCommand: e.target.value
}
})
}}
className={`input-field my-1 ${
platform() == 'linux' && useWineOnUnixWhenNeeded ? '' : 'hidden'
}`}
></input>
<div title='The theme you want the launcher to use.'>
<label className='text-lg'>Theme:</label>
<select
className='ml-2 bg-(--col2) border border-(--col4) rounded-md'
value={theme}
onChange={async e => {
if (!normalConfig) return
const newTheme = parseInt(e.target.value)
setTheme(newTheme)
setNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
theme: newTheme
}
})
await writeNormalConfig({
...normalConfig,
settings: {
...normalConfig.settings,
theme: newTheme
}
})
}}
>
<option value={0}>Dark (default)</option>
<option value={1}>Red</option>
<option value={2}>Blue</option>
<option value={3}>Purple</option>
</select>
</div>
</div>
)}
<p
className='fixed bottom-1.5 right-1.5 rounded-md cursor-pointer px-1 border z-100 transition-colors btntheme1'
onClick={async () => {
await copyToClipboard(`v${version}`, normalConfig)
}}
title='The current launcher version.'
>
v{version}
</p>
</>
)
}
-13
View File
@@ -1,13 +0,0 @@
export class DownloadProgress {
constructor (
public version: string,
public progress: number,
public progressBytes: number,
public failed: boolean,
public queued: boolean,
public hash_checking: boolean,
public finishing: boolean,
public speed: number,
public etaSecs: number
) {}
}
-10
View File
@@ -1,10 +0,0 @@
export interface LeaderboardEntry {
username: string
userid: bigint
value: bigint
icon: number
overlay: number
birdColor: number[]
overlayColor: number[]
customIcon: string | null
}
-6
View File
@@ -1,6 +0,0 @@
import { LeaderboardEntry } from './LeaderboardEntry'
export interface LeaderboardResponse {
entries: LeaderboardEntry[]
customIcons: Record<string, string>
}
-21
View File
@@ -1,21 +0,0 @@
import { SettingsType } from './SettingsType'
type NormalConfigData = {
version: string
settings?: Partial<SettingsType>
}
export class NormalConfig {
constructor (
public version: string,
public settings: SettingsType = new SettingsType()
) {}
static import (data: NormalConfigData) {
const cfg = new NormalConfig(data.version)
if (data.settings) {
cfg.settings = { ...cfg.settings, ...data.settings }
}
return cfg
}
}
-10
View File
@@ -1,10 +0,0 @@
export class SettingsType {
constructor (
public allowNotifications: boolean = true,
public alwaysShowGamesInSidebar: boolean = true,
public useLegacyInteractButtons: boolean = false,
public useWineOnUnixWhenNeeded: boolean = false,
public wineOnUnixCommand: string = 'wine %path%',
public theme: number = 0
) {}
}
-17
View File
@@ -1,17 +0,0 @@
export type VersionsConfigData = {
version: string
list: Record<string, number>
}
export class VersionsConfig {
constructor (
public version: string,
public list: Record<string, number> = {}
) {}
static import (data: VersionsConfigData) {
const cfg = new VersionsConfig(data.version)
cfg.list = { ...data.list }
return cfg
}
}
+16
View File
@@ -0,0 +1,16 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export default function UpdateCheckingPage () {
const router = useRouter()
useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
router.push('/main')
}
}, [])
return <p className='text-5xl text-center'>Checking for updates</p>
}
+41
View File
@@ -0,0 +1,41 @@
'use client'
import '@/styles/globals.css'
import { platform } from '@tauri-apps/plugin-os'
import { Roboto } from 'next/font/google'
import { useEffect, useState } from 'react'
const roboto = Roboto({
subsets: ['latin']
})
export default function RootLayout ({
children
}: {
children: React.ReactNode
}) {
const [platformName, setPlatformName] = useState<string | null>(null)
useEffect(() => {
setTimeout(() => setPlatformName(platform()), 0)
}, [])
return (
<>
<html lang='en' className={roboto.className}>
<body className='dark-theme'>
{platformName == 'windows' && (
<div className='relative z-2 w-screen border-b border-b-(--col3) h-8.25 bg-(--col1)' />
)}
<div
className={`w-screen ${
platformName == 'windows' ? 'h-[calc(100vh-64px)]' : 'h-screen'
} flex items-center justify-center`}
>
{children}
</div>
</body>
</html>
</>
)
}
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { openUrl } from '@tauri-apps/plugin-opener'
export default function UpdateCheckingPage () {
return (
<div className='text-center'>
<p className='text-6xl mb-4'>Outdated Launcher!</p>
<p className='text-2xl mb-4'>
Please update to the latest version to continue.
</p>
<button
className='button btntheme1'
onClick={() => openUrl('https://games.lncvrt.xyz/download')}
>
Download latest version!
</button>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
'use client'
export default function UpdateCheckingPage () {
return <p className='text-5xl text-center'>Updating launcher</p>
}
-148
View File
@@ -1,148 +0,0 @@
import { app } from '@tauri-apps/api'
import { NormalConfig } from '../types/NormalConfig'
import {
BaseDirectory,
create,
exists,
mkdir,
readTextFile,
writeFile
} from '@tauri-apps/plugin-fs'
import { VersionsConfig } from '../types/VersionsConfig'
export async function readNormalConfig (): Promise<NormalConfig> {
const version = await app.getVersion()
try {
const options = {
baseDir: BaseDirectory.AppLocalData
}
const doesFolderExist = await exists('', options)
const doesConfigExist = await exists('config.json', options)
if (!doesFolderExist || !doesConfigExist) {
if (!doesFolderExist) {
await mkdir('', options)
}
const file = await create('config.json', options)
await file.write(
new TextEncoder().encode(
JSON.stringify(new NormalConfig(version), null, 2)
)
)
await file.close()
return new NormalConfig(version)
}
const config = await readTextFile('config.json', options)
const raw = JSON.parse(config)
if (
raw.settings &&
raw.settings.theme &&
(raw.version == '1.0.0' ||
raw.version == '1.1.0' ||
raw.version == '1.1.1' ||
raw.version == '1.2.0' ||
raw.version == '1.3.0' ||
raw.version == '1.3.1' ||
raw.version == '1.4.0' ||
raw.version == '1.5.0' ||
raw.version == '1.5.1' ||
raw.version == '1.5.2' ||
raw.version == '1.5.3' ||
raw.version == '1.5.4')
) {
const parsed = Number(raw.settings.theme)
if (parsed == 3) raw.settings.theme = 2
if (parsed == 4) raw.settings.theme = 3
else if (parsed != 0 && parsed != 1) raw.settings.theme = 0
}
raw.version = version
writeNormalConfig(raw)
return NormalConfig.import(raw)
} catch {
return new NormalConfig(version)
}
}
export async function writeNormalConfig (data: NormalConfig) {
const options = {
baseDir: BaseDirectory.AppLocalData
}
const doesFolderExist = await exists('', options)
const doesConfigExist = await exists('config.json', options)
if (!doesFolderExist || !doesConfigExist) {
if (!doesFolderExist) {
await mkdir('', options)
}
const file = await create('config.json', options)
await file.write(new TextEncoder().encode(JSON.stringify(data, null, 2)))
await file.close()
} else {
await writeFile(
'config.json',
new TextEncoder().encode(JSON.stringify(data, null, 2)),
options
)
}
}
export async function readVersionsConfig (): Promise<VersionsConfig> {
const version = await app.getVersion()
try {
const options = {
baseDir: BaseDirectory.AppLocalData
}
const doesFolderExist = await exists('', options)
const doesConfigExist = await exists('versions.json', options)
if (!doesFolderExist || !doesConfigExist) {
if (!doesFolderExist) {
await mkdir('', options)
}
const file = await create('versions.json', options)
await file.write(
new TextEncoder().encode(
JSON.stringify(new VersionsConfig(version), null, 2)
)
)
await file.close()
return new VersionsConfig(version)
}
const config = await readTextFile('versions.json', options)
const raw = JSON.parse(config)
if (raw.list && raw.timestamps) {
raw.list = raw.timestamps
delete raw.timestamps
await writeFile(
'versions.json',
new TextEncoder().encode(JSON.stringify(raw, null, 2)),
options
)
}
raw.version = version
writeVersionsConfig(raw)
return VersionsConfig.import(raw)
} catch {
return new VersionsConfig(version)
}
}
export async function writeVersionsConfig (data: VersionsConfig) {
const options = {
baseDir: BaseDirectory.AppLocalData
}
const doesFolderExist = await exists('', options)
const doesConfigExist = await exists('versions.json', options)
if (!doesFolderExist || !doesConfigExist) {
if (!doesFolderExist) {
await mkdir('', options)
}
const file = await create('versions.json', options)
await file.write(new TextEncoder().encode(JSON.stringify(data, null, 2)))
await file.close()
} else {
await writeFile(
'versions.json',
new TextEncoder().encode(JSON.stringify(data, null, 2)),
options
)
}
}
-10
View File
@@ -1,10 +0,0 @@
import { NormalConfig } from '../types/NormalConfig'
import { notifyUser } from './Notifications'
export async function copyToClipboard (
text: string,
normalConfig: NormalConfig | null
) {
if (normalConfig?.settings.allowNotifications)
await notifyUser('Copied', 'Text "' + text + '" copied to clipboard')
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+61
View File
@@ -0,0 +1,61 @@
import { useState, useRef, useEffect } from 'react'
import '@/styles/dropdown.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
type Option = {
label: string
value: string | number
}
type Props = {
value: string | number
options: Option[]
onChange: (value: string | number) => void | Promise<void>
}
export default function Dropdown ({ value, options, onChange }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selected = options.find(o => o.value === value)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) {
setOpen(false)
}
}
window.addEventListener('mousedown', handleClick)
return () => window.removeEventListener('mousedown', handleClick)
}, [])
return (
<div ref={ref} className='dropdown'>
<button className='dropdown-btn' onClick={() => setOpen(o => !o)}>
<span>{selected?.label ?? 'Select...'}</span>
<FontAwesomeIcon
icon={faChevronDown}
className={`dropdown-arrow ${open ? 'open' : ''}`}
/>
</button>
<div className={`dropdown-menu ${open ? 'open' : ''}`}>
{options.map((option, i) => (
<div
key={option.value}
className={`dropdown-item ${
option.value === value ? 'active' : ''
}`}
style={{ ['--i']: i } as React.CSSProperties}
onClick={async () => {
await onChange(option.value)
setOpen(false)
}}
>
{option.label}
</div>
))}
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import '@/styles/dropdown.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
export default function ExpandArrow ({ open }: { open: boolean }) {
return (
<FontAwesomeIcon
icon={faChevronDown}
className={`dropdown-arrow ${!open ? 'open' : ''}`}
/>
)
}
File renamed without changes.
@@ -1,14 +1,15 @@
import './Setting.css' import '@/styles/setting.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons' import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { SettingProps } from '../types/SettingProps' import { SettingProps } from '@/types/SettingProps'
export function Setting ({ export function Setting ({
label, label,
value, value,
onChange, onChange,
className, className,
title title,
disabled
}: SettingProps) { }: SettingProps) {
return ( return (
<div className={`flex items-center gap-2 mb-2 ${className}`} title={title}> <div className={`flex items-center gap-2 mb-2 ${className}`} title={title}>
@@ -18,9 +19,10 @@ export function Setting ({
type='checkbox' type='checkbox'
className='setting-checkbox' className='setting-checkbox'
checked={value} checked={value}
disabled={disabled}
onChange={() => onChange(!value)} onChange={() => onChange(!value)}
/> />
{value && <FontAwesomeIcon icon={faCheck} className='fa-check-icon' />} {value && <FontAwesomeIcon icon={faCheck} />}
</div> </div>
</div> </div>
) )
+282
View File
@@ -0,0 +1,282 @@
'use client'
import '@/styles/sidebar.css'
import Icon from '@/assets/Icon.png'
import { openUrl } from '@tauri-apps/plugin-opener'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCog,
faDownload,
faGamepad,
faHexagonNodes,
faHome,
faLayerGroup,
faUser,
faUserShield
} from '@fortawesome/free-solid-svg-icons'
import { faDiscord } from '@fortawesome/free-brands-svg-icons'
import { platform } from '@tauri-apps/plugin-os'
import { useGlobal } from '@/providers/GlobalProvider'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Lexend } from 'next/font/google'
import React, { useState } from 'react'
import { message } from '@tauri-apps/plugin-dialog'
import ExpandArrow from './ExpandArrow'
const lexend = Lexend({
subsets: ['latin']
})
export default function Sidebar () {
const {
serverVersionList,
versionsList,
setShowPopup,
setPopupMode,
setFadeOut,
downloadProgress,
category,
setCategory,
linuxUseWine,
setManagingGame,
settings,
developerMode,
accountName,
accountAdmin,
sidebarShowGames
} = useGlobal()
const pathname = usePathname()
const params = useSearchParams()
const router = useRouter()
const [devModeClicks, setDevModeClicks] = useState<number>(0)
return (
<aside className='sidebar'>
{platform() == 'macos' && (
<div
className='macos-drag'
onMouseDown={async e => {
if (e.buttons === 1) {
if (e.detail === 2) {
await getCurrentWindow().toggleMaximize()
} else {
await getCurrentWindow().startDragging()
}
}
}}
/>
)}
<div
className={
'flex items-center h-10 w-60 ' +
(platform() == 'windows' ? 'pl-1 pt-1' : 'pl-2 pt-2') +
(platform() == 'macos'
? // ? Number(version().split('.')[0]) >= 26
// ? ' mt-6'
' mt-5'
: '')
}
onMouseDown={async e => {
if (platform() != 'macos') return
if (e.buttons === 1) {
if (e.detail === 2) {
await getCurrentWindow().toggleMaximize()
} else {
await getCurrentWindow().startDragging()
}
}
}}
>
<Image draggable={false} src={Icon} width={36} height={36} alt='' />
<p className={`ml-1 text-[16px] whitespace-nowrap ${lexend.className}`}>
Lncvrt Games Launcher
</p>
</div>
<nav className='nav-links overflow-auto'>
<Link
draggable={false}
href='/main'
className={`link relative flex items-center ${
pathname === '/main' ? 'active' : ''
}`}
>
<FontAwesomeIcon icon={faHome} className='mr-2' /> Home
</Link>
<Link
draggable={false}
href='/main/library'
className={`link relative flex items-center ${
pathname === '/main/library' || pathname.startsWith('/main/game')
? 'active'
: ''
}`}
onContextMenu={async e => {
e.preventDefault()
if (developerMode) {
await settings?.set('developerMode', false)
await message(
'Developer mode disabled! You can enable it again by right clicking the games button 3 times again.',
{ title: 'Developer mode off', kind: 'info' }
)
return
}
if (devModeClicks == 2) {
await settings?.set('developerMode', true)
setDevModeClicks(0)
await message(
'Developer mode sucessfully enabled! You can disable it by right clicking the games button again.',
{ title: 'Developer mode on', kind: 'info' }
)
} else {
setDevModeClicks(devModeClicks + 1)
}
}}
>
<FontAwesomeIcon icon={faHexagonNodes} className='mr-2' /> Library
{(pathname === '/main/library' ||
pathname.startsWith('/main/game')) && (
<div
className='ml-auto w-6 h-6 flex justify-center items-center'
onClick={async e => {
e.preventDefault()
await settings?.set('sidebarShowGames', !sidebarShowGames)
}}
>
<ExpandArrow open={sidebarShowGames} />
</div>
)}
</Link>
{(pathname === '/main/library' || pathname.startsWith('/main/game')) &&
sidebarShowGames &&
serverVersionList?.games
.filter(g =>
serverVersionList.versions
.filter(v => v.game === g.id)
.some(v => Object.keys(versionsList).includes(v.id))
)
.map(i => (
<React.Fragment key={i.id}>
<div
draggable={false}
className={`link ${
pathname === '/main/game' &&
Number(params.get('id') || 0) == i.id
? 'active'
: ''
} ml-auto w-50`}
onClick={() => {
setCategory(-1)
router.push('/main/game?id=' + i.id)
}}
onContextMenu={e => {
e.preventDefault()
setManagingGame(i.id)
setPopupMode(5)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to view game installs.'
>
<div className='flex items-center'>
<FontAwesomeIcon
icon={
Object.entries(i.categoryNames).length > 0
? faLayerGroup
: faGamepad
}
className='mr-2'
/>
<span className='truncate max-w-full'>{i.name}</span>
</div>
</div>
{Object.entries(i.categoryNames)
.sort(([a], [b]) => Number(b) - Number(a))
.filter(([key]) => {
const count = Object.keys(versionsList).filter(v => {
const info = serverVersionList.versions.find(
vf => vf.id == v
)
if (!info) return false
if (platform() == 'linux' && info.wine && !linuxUseWine)
return false
return info.game === i.id && info.category === Number(key)
}).length
return count >= 1
})
.map(([key, value]) => (
<div
key={`${i.id}-${key}`}
draggable={false}
className={`link ${
pathname === '/main/game' &&
Number(params.get('id') || 0) == i.id &&
category == Number(key)
? 'active'
: ''
} ml-auto w-47.5`}
onClick={() => {
setCategory(Number(key))
router.push('/main/game?id=' + i.id)
}}
title="Click to view this game's category."
>
<div className='flex items-center'>
<FontAwesomeIcon icon={faGamepad} className='mr-1' />
<span className='truncate max-w-full'>{value}</span>
</div>
</div>
))}
</React.Fragment>
))}
<Link
draggable={false}
href='/main/settings'
className={`link ${pathname === '/main/settings' ? 'active' : ''}`}
>
<FontAwesomeIcon icon={faCog} className='mr-1' /> Settings
</Link>
<Link
draggable={false}
href='/main/account'
className={`link ${pathname === '/main/account' ? 'active' : ''}`}
>
<FontAwesomeIcon
icon={accountAdmin ? faUserShield : faUser}
className='mr-1'
/>{' '}
{accountName ?? 'Account'}
</Link>
<button
onClick={() => openUrl('https://games.lncvrt.xyz/discord')}
className='link mr-auto'
>
<FontAwesomeIcon icon={faDiscord} className='mr-1' /> Community
</button>
</nav>
{downloadProgress.length != 0 && (
<div
className='sidebar-downloads'
onClick={() => {
setPopupMode(1)
setShowPopup(true)
setFadeOut(false)
}}
>
<p>
<FontAwesomeIcon icon={faDownload} /> Downloads
</p>
</div>
)}
</aside>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { notifyUser } from './notifications'
export async function copyToClipboard (text: string) {
await writeText(text)
await notifyUser('Copied', 'Text "' + text + '" copied to clipboard')
}
File renamed without changes.
+40
View File
@@ -0,0 +1,40 @@
import { message } from '@tauri-apps/plugin-dialog'
import { invoke } from '@tauri-apps/api/core'
export const openFolder = async (name: string) => {
try {
await invoke('open_game_folder', { version: name })
} catch (e) {
await message(String(e), {
title: 'Folder not found',
kind: 'error'
})
}
}
export const formatEtaSmart = (seconds: number) => {
if (seconds < 60) return `${Math.floor(seconds)}s`
if (seconds < 3600)
return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`
if (seconds < 86400) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
return `${d}d ${h}h`
}
export const verifySignature = async (body: string, signature: string) => {
try {
const result = await invoke<boolean>('verify_signature', {
body: body ?? '',
signature: signature ?? '',
publicKey: process.env.NEXT_PUBLIC_PUBLIC_SIGNING_KEY ?? ''
})
return Boolean(result)
} catch {
return false
}
}
+257
View File
@@ -0,0 +1,257 @@
import { useGlobal } from '@/providers/GlobalProvider'
import prettyBytes from 'pretty-bytes'
import ProgressBar from '@/components/ProgressBar'
import { formatEtaSmart } from '@/lib/util'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCancel,
faPause,
faPlay,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import { invoke } from '@tauri-apps/api/core'
import { notifyUser } from '@/lib/notifications'
import { getCurrentWindow, UserAttentionType } from '@tauri-apps/api/window'
export default function DownloadsPopup () {
const {
downloadProgress,
setDownloadProgress,
downloadQueue,
setDownloadQueue,
versionsList,
modsList,
versions,
serverVersionList
} = useGlobal()
return (
<>
<p className='text-xl text-center'>Downloads</p>
<div className='popup-content'>
{downloadProgress.map((v, i) => {
const queuePosition = downloadQueue.indexOf(v.version)
return (
<div
key={i}
className='popup-entry flex flex-col justify-between relative'
>
<div className='absolute right-2 top-2 flex flex-row gap-2'>
{(v.downloading || v.paused) && (
<>
<div
className='cursor-pointer bg-(--col5) hover:bg-(--col7) border border-(--col7) hover:border-(--col9) transition-colors w-8 h-8 flex items-center justify-center rounded-full'
onClick={async () => {
if (v.downloading) {
await invoke('cancel_download', { name: v.version })
} else {
setDownloadProgress(prev => {
const i = prev.findIndex(
d => d.version === v.version
)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
paused: false,
downloading: true
}
return copy
})
const res = await invoke<string>('download', {
url: v.url,
name: v.version,
hash: v.hash,
downloadType: v.type,
modId: String(v.modId ?? '')
})
if (res === '1') {
setDownloadProgress(prev =>
prev.filter(d => d.version !== v.version)
)
if (v.type != 1) {
if (v.type == 2) {
await versions?.set('mods', {
...modsList,
[v.modGame! + '-' + v.modId!]: {
[v.modVersion!]: Date.now()
}
})
} else {
await versions?.set('list', {
...versionsList,
[v.version]: Date.now()
})
}
}
} else if (res == '0') {
setDownloadProgress(prev => {
const i = prev.findIndex(
d => d.version === v.version
)
if (i === -1) return prev
if (prev[i].canceled) {
return prev.filter((_, idx) => idx !== i)
}
const copy = [...prev]
copy[i] = {
...copy[i],
downloading: false,
paused: true,
failed: false
}
return copy
})
} else if (res == '-1') {
setDownloadProgress(prev =>
prev.map(d =>
d.version === v.version
? {
...d,
queued: false,
failed: true,
progress: 0
}
: d
)
)
await notifyUser(
'Download Failed',
`The download for version ${
serverVersionList?.versions.find(
vf => vf.id == v.version
)?.displayName
} has failed.`
)
await getCurrentWindow().requestUserAttention(
UserAttentionType.Critical
)
}
}
}}
>
<FontAwesomeIcon
icon={v.downloading ? faPause : faPlay}
className='w-6 h-6'
/>
</div>
<div
className='cursor-pointer bg-(--col5) hover:bg-(--col7) border border-(--col7) hover:border-(--col9) transition-colors w-8 h-8 flex items-center justify-center rounded-full'
onClick={async () => {
if (!v.paused) {
await invoke('cancel_download', { name: v.version })
setDownloadProgress(prev => {
const i = prev.findIndex(
d => d.version === v.version
)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
canceled: true
}
return copy
})
} else {
setDownloadProgress(prev => {
const i = prev.findIndex(
d => d.version === v.version
)
if (i === -1) return prev
return prev.filter((_, idx) => idx !== i)
})
}
}}
>
<FontAwesomeIcon icon={faCancel} className='w-6 h-6' />
</div>
</>
)}
{(v.failed || v.queued) && (
<div
className='cursor-pointer bg-(--col5) hover:bg-(--col7) border border-(--col7) hover:border-(--col9) transition-colors w-8 h-8 flex items-center justify-center rounded-full'
onClick={() => {
setDownloadQueue(prev =>
prev.filter(id => id !== v.version)
)
setDownloadProgress(prev =>
prev.filter(d => d.version !== v.version)
)
}}
>
<FontAwesomeIcon icon={faTrash} className='w-6 h-6' />
</div>
)}
</div>
<p className='text-2xl text-center'>
{v.type == 1
? 'Mod loader for '
: v.type == 2
? 'Mod for '
: null}{' '}
{
serverVersionList?.versions.find(vf => vf.id == v.version)
?.displayName
}
</p>
<div className='mt-6.25 flex items-center justify-between'>
{v.failed || v.queued ? (
<div className='flex items-center justify-between w-full'>
<span
className={`${
v.failed ? 'text-red-500' : 'text-yellow-300'
} inline-block text-center flex-1`}
>
{v.failed
? 'Download failed'
: queuePosition === 0
? 'Starting soon...'
: `Queued (Position ${queuePosition + 1})`}
</span>
</div>
) : v.hash_checking ? (
<span className='text-blue-300 inline-block w-full text-center'>
Verifying file integrity...
</span>
) : v.unzipping ? (
<div className='flex flex-col gap-1 w-full'>
<span className='text-center'>
Unzipped {v.unzipped} / {v.unzipTotal} files
</span>
<ProgressBar
progress={(v.unzipped / v.unzipTotal) * 100}
className='w-full'
/>
</div>
) : (
<div className='flex flex-col gap-1 w-full'>
<span className='text-center'>
Downloaded{' '}
{prettyBytes(v.progressBytes, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}{' '}
of{' '}
{prettyBytes(v.size, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}{' '}
(ETA: {formatEtaSmart(v.etaSecs)} &bull; Speed:{' '}
{prettyBytes(v.speed, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}
/s)
</span>
<ProgressBar progress={v.progress} className='w-full' />
</div>
)}
</div>
</div>
)
})}
</div>
</>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useGlobal } from '@/providers/GlobalProvider'
import {
faArrowUpRightFromSquare,
faInfo
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
export default function GamesDownloadPopup () {
const {
serverVersionList,
setSelectedGame,
setManagingGame,
setPopupMode,
setShowPopup,
setFadeOut,
setViewingInfoFromDownloads,
developerMode
} = useGlobal()
return (
<>
<p className='text-xl text-center'>Select a game to download</p>
<div className='popup-content'>
{serverVersionList?.games
.filter(g => {
const gameVersions = serverVersionList.versions.filter(
gv => gv.game === g.id
)
if (gameVersions.length > 0) return true
})
.map((g, i) => (
<div
key={i}
className='popup-entry grid items-center gap-3'
style={{ gridTemplateColumns: 'auto minmax(0, 1fr) auto' }}
>
<button
className='button btntheme3'
onClick={() => {
setViewingInfoFromDownloads(true)
setManagingGame(g.id)
setPopupMode(5)
setShowPopup(true)
setFadeOut(false)
}}
title='Click to view game info.'
>
<FontAwesomeIcon icon={faInfo} /> Info
</button>
<p className='text-2xl text-center truncate' title={g.name}>
{developerMode ? <span className='text-sm'>{g.id} </span> : ''}
{g.name}
</p>
<button
className='button btntheme3'
onClick={() => setSelectedGame(g.id)}
title='Click to view versions of the game.'
>
<FontAwesomeIcon icon={faArrowUpRightFromSquare} /> View
</button>
</div>
))}
</div>
</>
)
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
import {
faCheck,
faCode,
faDownload,
faHardDrive,
faShieldHalved,
faWarning
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useGlobal } from '@/providers/GlobalProvider'
import { invoke } from '@tauri-apps/api/core'
import { useEffect, useMemo, useState } from 'react'
import prettyBytes from 'pretty-bytes'
import { BaseDirectory, exists, remove } from '@tauri-apps/plugin-fs'
export default function ManageGamePopup () {
const {
versionsList,
serverVersionList,
closePopup,
versions,
customDataLocation,
managingGame
} = useGlobal()
const [gameSize, setGameSize] = useState<number>(0)
const versionsInstalled = useMemo(() => {
return serverVersionList?.versions.filter(
vf => vf.game == managingGame && Object.keys(versionsList).includes(vf.id)
)
}, [serverVersionList, managingGame, versionsList])
const gameInfo = serverVersionList?.games.find(vf => vf.id == managingGame)
useEffect(() => {
if (!versionsInstalled || versionsInstalled.length == 0) return
let cancelled = false
const run = async () => {
let total = 0
setGameSize(0)
for (const version of versionsInstalled) {
if (cancelled) return
const size = await invoke<string>('folder_size', {
version: version.id
}).catch(() => '0')
total += Number(size)
setGameSize(total)
}
}
run()
return () => {
cancelled = true
}
}, [versionsInstalled])
return (
<>
<p className='text-xl text-center'>Viewing {gameInfo?.name}</p>
<div className='popup-content h-full w-full flex justify-center items-center flex-col gap-2'>
<div className='flex flex-row gap-2 justify-center'>
{gameInfo?.official ? (
<div className='entry-info-item btntheme2'>
<FontAwesomeIcon icon={faCheck} color='#19c84b' />
<p>Official</p>
</div>
) : (
<div className='entry-info-item btntheme2'>
<FontAwesomeIcon
icon={gameInfo?.verified ? faShieldHalved : faWarning}
color={gameInfo?.verified ? '#19c84b' : '#ffc800'}
/>
<p>{gameInfo?.verified ? 'Verified' : 'Unverified'}</p>
</div>
)}
{gameInfo?.developer != null && (
<div className='entry-info-item btntheme2'>
<FontAwesomeIcon icon={faCode} color='lightgray' />
<p>Developer: {gameInfo?.developer}</p>
</div>
)}
</div>
<div className='flex flex-row gap-2 justify-center'>
<div className='entry-info-item btntheme2'>
<FontAwesomeIcon icon={faDownload} color='lightgray' />
<p>
{(() => {
if (!serverVersionList) return 'N/A'
const gameVersions = serverVersionList.versions.filter(
vf => vf.game === managingGame
)
const installed = gameVersions.filter(v =>
Object.keys(versionsList).includes(v.id)
).length
return gameVersions.length
? `${installed}/${gameVersions.length}`
: 'N/A'
})()}{' '}
versions installed
</p>
</div>
{versionsInstalled?.length != 0 && (
<>
<div className='entry-info-item btntheme2'>
<FontAwesomeIcon icon={faHardDrive} color='lightgray' />
<p>
Size on disk:{' '}
{prettyBytes(gameSize, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</p>
</div>
<div
className='entry-info-item btntheme2'
onClick={async () => {
closePopup()
if (!versionsInstalled) return
const updatedList = { ...versionsList }
for (const version of versionsInstalled)
delete updatedList[version.id]
await versions?.set('list', updatedList)
for (const version of versionsInstalled) {
const managingVersion = version.id
if (
await exists(
(customDataLocation ? customDataLocation + '/' : '') +
'game/' +
managingVersion,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation ? customDataLocation + '/' : '') +
'game/' +
managingVersion,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
}
}}
title='Click to uninstall this game. This will NOT remove any progress or any save files.'
>
Uninstall
</div>
</>
)}
</div>
</div>
</>
)
}
+149
View File
@@ -0,0 +1,149 @@
'use client'
import {
faArrowUpRightFromSquare,
faHardDrive
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useGlobal } from '@/providers/GlobalProvider'
import { invoke } from '@tauri-apps/api/core'
import { useEffect, useState } from 'react'
import prettyBytes from 'pretty-bytes'
import { openFolder } from '@/lib/util'
export default function ManageVersionPopup () {
const {
versionsList,
serverVersionList,
managingVersion,
viewingInfoFromDownloads,
setManagingVersion,
closePopup,
setPopupMode,
setSelectedVersionList,
downloadVersions,
uninstallVersion
} = useGlobal()
const [versionSize, setVersionSize] = useState<number>(0)
useEffect(() => {
if (viewingInfoFromDownloads) return
invoke<string>('folder_size', {
version: managingVersion
}).then(size => {
setVersionSize(Number(size))
})
}, [managingVersion, setVersionSize, viewingInfoFromDownloads])
if (!managingVersion) return null
const versionInfo = serverVersionList?.versions.find(
vf => vf.id == managingVersion
)
return (
<>
<p className='text-xl text-center'>Viewing {versionInfo?.displayName}</p>
<div className='popup-content h-full w-full flex justify-center items-center flex-col gap-2'>
<div className='flex flex-row gap-2 justify-center'>
{!viewingInfoFromDownloads && (
<div className='entry-info-item btntheme2'>
<p>
Installed{' '}
{new Intl.DateTimeFormat(undefined).format(
versionsList[managingVersion] ?? 0
)}
</p>
</div>
)}
{versionInfo && (
<>
{versionInfo.releaseDate != 0 && (
<div className='entry-info-item btntheme2'>
<p>
Released{' '}
{new Intl.DateTimeFormat(undefined).format(
versionInfo?.releaseDate
? versionInfo.releaseDate * 1000
: 0
)}
</p>
</div>
)}
{versionInfo.changelog && (
<div
className='entry-info-item btntheme2'
onClick={() => setPopupMode(4)}
>
<p>View Changelog</p>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
color='lightgray'
/>
</div>
)}
</>
)}
</div>
{!viewingInfoFromDownloads && (
<div className='flex flex-row gap-2 justify-center'>
<div className='entry-info-item btntheme2'>
<FontAwesomeIcon icon={faHardDrive} color='lightgray' />
<p>
Size on disk:{' '}
{prettyBytes(versionSize, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</p>
</div>
<div
className='entry-info-item btntheme2'
onClick={async () => openFolder(managingVersion)}
title="Click to browse the game's files."
>
Open Folder
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
color='lightgray'
/>
</div>
<div
className='entry-info-item btntheme2'
onClick={async () => {
closePopup()
uninstallVersion(managingVersion)
}}
title='Click to uninstall this game. This will NOT remove any progress or any save files.'
>
Uninstall
</div>
<div
className='entry-info-item btntheme2'
onClick={async () => {
//change popup to downloads
setManagingVersion(null)
setPopupMode(1)
//uninstall
uninstallVersion(managingVersion)
//reinstall
setSelectedVersionList([managingVersion])
await downloadVersions([
{
id: managingVersion,
type: 0
}
])
}}
title="Click to reinstall this game. This will NOT remove any progress or any save files. This WILL uninstall any modifications to the game's executable files."
>
Reinstall
</div>
</div>
)}
</div>
</>
)
}
+421
View File
@@ -0,0 +1,421 @@
'use client'
import { useGlobal } from '@/providers/GlobalProvider'
import { verifySignature } from '@/lib/util'
import { Mod } from '@/types/Mod'
import {
faArrowUpRightFromSquare,
faClock,
faCodeBranch,
faDownload,
faGlobe,
faPeopleGroup,
faRefresh,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { message } from '@tauri-apps/plugin-dialog'
import { BaseDirectory, exists, remove } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import { arch, platform } from '@tauri-apps/plugin-os'
import { useEffect, useState } from 'react'
import { app } from '@tauri-apps/api'
export default function ModDownloadsPopup () {
const {
modsList,
serverVersionList,
managingVersion,
showModInfo,
setShowModInfo,
downloadVersions,
downloadProgress,
versions,
customDataLocation
} = useGlobal()
const [mods, setMods] = useState<Mod[] | 0 | 1>(0)
const [tab, setTab] = useState<number>(0)
useEffect(() => {
if (!managingVersion) return
;(async () => {
const response = await fetch(
'https://games.lncvrt.xyz/api/launcher/mods?version=' + managingVersion,
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch()
}
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status == 200 &&
(await verifySignature(JSON.stringify(data), signature))
) {
setMods(data as Mod[])
} else {
setMods(1)
}
})()
}, [managingVersion])
if (!managingVersion || !serverVersionList) return null
const versionInfo = serverVersionList.versions.find(
v => v.id == managingVersion
)
return (
<>
{typeof mods != 'number' && showModInfo == null && (
<button
className='popup-top-button btntheme1 right-2'
onClick={async () => {
setMods(0)
const response = await fetch(
'https://games.lncvrt.xyz/api/launcher/mods?version=' +
managingVersion,
{
headers: {
Requester: 'LncvrtGamesLauncherClient',
ClientVersion: await app.getVersion(),
ClientPlatform: platform() + '-' + arch()
}
}
)
const signature = response.headers.get('x-signature') ?? ''
const data = await response.json()
if (
response.status == 200 &&
(await verifySignature(JSON.stringify(data), signature))
) {
setMods(data as Mod[])
} else {
setMods(1)
}
}}
>
<FontAwesomeIcon icon={faRefresh} />
</button>
)}
<p className='text-xl text-center'>
{!showModInfo
? versionInfo?.displayName + ' Mod Manager'
: showModInfo.name +
' by ' +
showModInfo.creators[0] +
(showModInfo.creators.length > 1
? ' + ' + (showModInfo.creators.length - 1) + ' more'
: '')}
</p>
{typeof mods != 'number' && !showModInfo ? (
<div className='flex flex-row justify-center items-center -mb-2 mt-0.5 gap-1.5'>
<button
className={`button ${tab == 0 ? 'btntheme3' : 'btntheme2'}`}
onClick={() => setTab(0)}
>
<FontAwesomeIcon icon={faDownload} />
Installed
</button>
<button
className={`button ${tab == 1 ? 'btntheme3' : 'btntheme2'}`}
onClick={() => setTab(1)}
>
<FontAwesomeIcon icon={faGlobe} />
Download
</button>
</div>
) : null}
<div className='popup-content h-full'>
{typeof mods == 'number' ||
(!showModInfo &&
mods.filter(v =>
tab == 0
? Object.keys(modsList).includes(
String(versionInfo?.game + '-' + v.id)
)
: !Object.keys(modsList).includes(
String(versionInfo?.game + '-' + v.id)
)
).length == 0) ? (
<p className='text-2xl flex justify-center items-center h-full'>
{typeof mods == 'number'
? mods == 0
? 'Loading'
: 'Failed to load mods'
: tab == 0
? 'No mods installed'
: 'No mods available to download'}
</p>
) : !showModInfo ? (
<div className='flex flex-col items-center justify-center gap-2 p-2'>
{mods
.filter(v =>
tab == 0
? Object.keys(modsList).includes(
String(versionInfo?.game + '-' + v.id)
)
: !Object.keys(modsList).includes(
String(versionInfo?.game + '-' + v.id)
)
)
.map(v => {
const localVersion =
tab == 0
? Object.keys(
modsList[versionInfo?.game + '-' + v.id] ?? {}
)[0]
: null
return (
<div
key={v.id}
className='bg-(--col3) border border-(--col5) rounded-lg w-full h-16 flex flex-row'
>
<div className='flex flex-col justify-center h-full px-3 w-fit'>
<p>
<span className='text-lg'>{v.name}</span>{' '}
<span
className={
tab != 0 || v.latestVersion == localVersion
? 'text-green-300'
: 'text-red-300'
}
>
v{tab == 0 ? localVersion : v.latestVersion}
</span>
{tab == 0 && v.latestVersion != localVersion && (
<span className='text-green-300'>
{' '}
(available: v{v.latestVersion})
</span>
)}
</p>
<p className='text-yellow-200'>
Made by {v.creators[0]}
{v.creators.length > 1
? ' + ' + (v.creators.length - 1) + ' more'
: null}
</p>
</div>
<div className='flex flex-row items-center h-full gap-2 px-3 ml-auto'>
{tab != 0 && (
<p className='text-green-400'>
<FontAwesomeIcon icon={faDownload} />{' '}
{v.downloads.toLocaleString()}
</p>
)}
{tab == 0 && v.latestVersion != localVersion && (
<button
className='button btntheme3'
onClick={async () => {
const path =
'game/' +
versionInfo?.id +
'/BepInEx/plugins/' +
v.id
if (
await exists(
(customDataLocation
? customDataLocation + '/'
: null) + path,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation
? customDataLocation + '/'
: null) + path,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
await versions?.set(
'mods',
Object.fromEntries(
Object.entries(modsList).filter(
([k]) => k !== versionInfo?.game + '-' + v.id
)
)
)
await downloadVersions([
{
id: managingVersion,
type: 2,
modDownload: v.latestDownload,
gameId: versionInfo?.game,
modId: v.id,
modVersion: v.latestVersion
}
])
}}
disabled={downloadProgress.length != 0}
>
Update
</button>
)}
<button
className='button btntheme3'
onClick={() => setShowModInfo(v)}
>
{tab == 0 ? 'View' : 'Get'}
</button>
</div>
</div>
)
})}
</div>
) : (
<div className='flex flex-col h-full w-full items-center'>
<div className='flex flex-col p-2 h-fit w-fit bg-(--col3) border border-(--col5) rounded-lg m-2 items-center'>
<p>
<FontAwesomeIcon icon={faDownload} className='text-green-400' />{' '}
Downloads {showModInfo.downloads.toLocaleString()}
</p>
<p>
<FontAwesomeIcon icon={faClock} /> Released{' '}
{new Intl.DateTimeFormat(undefined).format(
showModInfo.released * 1000
)}
</p>
<p>
<FontAwesomeIcon icon={faClock} /> Updated{' '}
{new Intl.DateTimeFormat(undefined).format(
showModInfo.updated * 1000
)}
</p>
<p>
<FontAwesomeIcon icon={faCodeBranch} /> Version{' '}
{showModInfo.latestVersion}
</p>
</div>
<div className='flex flex-col p-2 bg-(--col3) border border-(--col5) rounded-lg h-full w-[calc(100%-16px)]'>
{showModInfo.description
? atob(showModInfo.description)
: '(No description added)'}
</div>
<div className='flex flex-row h-fit w-full gap-2 justify-center my-2'>
{Object.keys(modsList).includes(
String(versionInfo?.game + '-' + showModInfo.id)
) ? (
<button
className='button btntheme2 w-fit'
disabled={downloadProgress.length != 0}
onClick={async () => {
const path =
'game/' +
versionInfo?.id +
'/BepInEx/plugins/' +
showModInfo.id
if (
await exists(
(customDataLocation ? customDataLocation + '/' : '') +
path,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData
}
)
)
await remove(
(customDataLocation ? customDataLocation + '/' : '') +
path,
{
baseDir: customDataLocation
? undefined
: BaseDirectory.AppLocalData,
recursive: true
}
)
await versions?.set(
'mods',
Object.fromEntries(
Object.entries(modsList).filter(
([k]) =>
k !== versionInfo?.game + '-' + showModInfo.id
)
)
)
}}
>
<FontAwesomeIcon icon={faTrash} className='text-red-400' />{' '}
Uninstall
</button>
) : (
<button
className='button btntheme2 w-fit'
onClick={async () =>
await downloadVersions([
{
id: managingVersion,
type: 2,
modDownload: showModInfo.latestDownload,
gameId: versionInfo?.game,
modId: showModInfo.id,
modVersion: showModInfo.latestVersion
}
])
}
disabled={downloadProgress.length != 0}
>
<FontAwesomeIcon
icon={faDownload}
className='text-green-400'
/>{' '}
Install
</button>
)}
<button
className='button btntheme2 w-fit'
onClick={async () => {
await message(
!showModInfo.changelog
? '(No changelog provided)'
: atob(showModInfo?.changelog ?? ''),
{
title: 'Changelog for ' + showModInfo.name,
kind: 'info'
}
)
}}
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
color='lightgray'
/>{' '}
View changelog
</button>
{showModInfo.creators.length > 2 && (
<button
className='button btntheme2 w-fit'
onClick={async () => {
if (!versionInfo) return
await message(showModInfo.creators.join(', '), {
title: 'Creators for ' + showModInfo.name,
kind: 'info'
})
}}
>
<FontAwesomeIcon icon={faPeopleGroup} color='lightgray' />{' '}
View all creators
</button>
)}
</div>
</div>
)}
</div>
</>
)
}
+60
View File
@@ -0,0 +1,60 @@
'use client'
import { useGlobal } from '@/providers/GlobalProvider'
import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import DOMPurify from 'dompurify'
import { useState } from 'react'
export default function VersionChangelogPopup () {
const { serverVersionList, managingVersion, developerMode } = useGlobal()
const [raw, setRaw] = useState<boolean>(false)
if (!managingVersion) return null
const versionInfo = serverVersionList?.versions.find(
vf => vf.id == managingVersion
)
return (
<>
{developerMode && (
<button
className='popup-top-button btntheme1 right-2'
onClick={() => setRaw(!raw)}
>
<FontAwesomeIcon icon={raw ? faEyeSlash : faEye} />
</button>
)}
<p className='text-xl text-center'>
Viewing changelog {versionInfo?.displayName}
</p>
<div className='popup-content h-full w-full p-2 select-text'>
{raw ? (
<>
<p>Raw: {versionInfo?.changelog}</p>
<p
className='whitespace-pre-line'
dangerouslySetInnerHTML={{
__html:
'Decoded: \n' +
DOMPurify.sanitize(atob(versionInfo?.changelog ?? ''))
}}
></p>
</>
) : (
<p
className='whitespace-pre-line'
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
atob(versionInfo?.changelog ?? '')
.replace(/^- /gm, '&bull; ')
.replaceAll(/`([^`]+)`/g, '<code>$1</code>')
)
}}
></p>
)}
</div>
</>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useGlobal } from '@/providers/GlobalProvider'
import { faAdd, faInfo, faRemove } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
export default function VersionsDownloadPopup () {
const {
serverVersionList,
getSpecialVersionsList,
selectedVersionList,
setSelectedVersionList,
setManagingVersion,
setPopupMode,
selectedGame,
setViewingInfoFromDownloads,
downloadVersions,
developerMode
} = useGlobal()
const game = serverVersionList?.games.find(g => g.id === selectedGame)
if (!selectedGame || !game) return null
const list = getSpecialVersionsList(selectedGame)
return (
<>
<p className='text-xl text-center'>Select versions to download</p>
<div className='popup-content'>
{list.length != 0 ? (
list.map((v, i) => (
<div
key={i}
className='popup-entry grid items-center gap-3'
style={{ gridTemplateColumns: 'auto minmax(0, 1fr) auto' }}
>
<button
className='button btntheme3'
onClick={() => {
setManagingVersion(v.id)
setViewingInfoFromDownloads(true)
setPopupMode(2)
}}
title='Click to view version info.'
>
<FontAwesomeIcon icon={faInfo} /> Info
</button>
<p
className='text-2xl text-center truncate'
title={v.displayName}
>
{developerMode ? <span className='text-sm'>{v.id} </span> : ''}
{v.displayName}
</p>
<button
className='button btntheme3'
onClick={() => {
setSelectedVersionList(prev =>
prev.includes(v.id)
? prev.filter(i => i !== v.id)
: [...prev, v.id]
)
}}
title={
selectedVersionList.includes(v.id)
? 'This version will be downloaded. Click to remove from the list of versions that will be downloaded.'
: 'This version will NOT be downloaded. Click to add from the list of versions that will be downloaded.'
}
>
{selectedVersionList.includes(v.id) ? (
<>
<FontAwesomeIcon icon={faRemove} /> Remove
</>
) : (
<>
<FontAwesomeIcon icon={faAdd} /> Add
</>
)}
</button>
</div>
))
) : (
<div className='flex flex-col gap-2 justify-center text-center w-full h-full'>
<p className='text-2xl'>No versions available to download</p>
<p>You may already have all versions installed for this game</p>
</div>
)}
</div>
<div className='flex justify-center'>
<button
className='button btntheme1 w-fit mt-2 -mb-4'
onClick={async () => {
await downloadVersions(
selectedVersionList.map(versionId => ({
id: versionId,
type: 0
}))
)
setPopupMode(1)
}}
disabled={selectedVersionList.length == 0}
title={`Download ${selectedVersionList.length} version${
selectedVersionList.length == 1 ? '' : 's'
} of ${game.name}.`}
>
Download {selectedVersionList.length} version
{selectedVersionList.length == 1 ? '' : 's'}
</button>
<button
className='button btntheme1 w-fit mt-2 ml-2 -mb-4'
onClick={() => {
const allIds = list.map(v => v.id)
setSelectedVersionList(prev =>
prev.length === allIds.length ? [] : allIds
)
}}
title={
selectedVersionList.length === list.length
? 'Click to remove all selected versions for download.'
: 'Click to add all selected versions for download.'
}
>
{selectedVersionList.length === list.length
? 'Deselect All'
: 'Select All'}
</button>
</div>
</>
)
}
+96
View File
@@ -0,0 +1,96 @@
'use client'
import {
createContext,
useContext,
ReactNode,
Dispatch,
SetStateAction
} from 'react'
import { DownloadProgress } from '@/types/DownloadProgress'
import { ServerVersionsResponse } from '@/types/ServerVersionsResponse'
import { Mod } from '@/types/Mod'
import { Store } from '@tauri-apps/plugin-store'
import { GameVersion } from '@/types/GameVersion'
type GlobalCtxType = {
serverVersionList: ServerVersionsResponse | null
setServerVersionList: Dispatch<SetStateAction<ServerVersionsResponse | null>>
selectedVersionList: string[]
setSelectedVersionList: (value: SetStateAction<string[]>) => void
downloadProgress: DownloadProgress[]
setDownloadProgress: Dispatch<SetStateAction<DownloadProgress[]>>
showPopup: boolean
setShowPopup: Dispatch<SetStateAction<boolean>>
popupMode: number | null
setPopupMode: Dispatch<SetStateAction<number | null>>
fadeOut: boolean
setFadeOut: Dispatch<SetStateAction<boolean>>
managingVersion: string | null
setManagingVersion: Dispatch<SetStateAction<string | null>>
setSelectedGame: Dispatch<SetStateAction<number | null>>
viewingInfoFromDownloads: boolean
version: string | null
downloadVersions: (
list: Array<{
id: string
type: 0 | 1 | 2
modDownload?: number
gameId?: number
modId?: number
modVersion?: string
}>
) => Promise<void>
category: number
setCategory: Dispatch<SetStateAction<number>>
downloadQueue: string[]
setDownloadQueue: Dispatch<SetStateAction<string[]>>
closePopup: () => void
selectedGame: number | null
setViewingInfoFromDownloads: Dispatch<SetStateAction<boolean>>
showModInfo: Mod | null
setShowModInfo: Dispatch<SetStateAction<Mod | null>>
getSpecialVersionsList: (game?: number) => GameVersion[]
settings: Store | null
versions: Store | null
account: Store | null
sidebarShowGames: boolean
linuxUseWine: boolean
linuxWineCommand: string
theme: string
customDataLocation: string
developerMode: boolean
versionsList: Record<string, number>
modsList: Record<string, Record<string, number>>
launchesList: Record<string, Record<string, number>>
accountSession: string | null
accountId: string | null
accountName: string | null
accountAdmin: boolean
managingGame: number | null
setManagingGame: Dispatch<SetStateAction<number | null>>
needsRevisionUpdate: (
lastRevision: number | undefined,
version: string
) => boolean
uninstallVersion: (version: string, updateList?: boolean) => Promise<void>
launchGame: (versionInfo: GameVersion) => Promise<void>
}
const GlobalCtx = createContext<GlobalCtxType | null>(null)
export const useGlobal = () => {
const ctx = useContext(GlobalCtx)
if (!ctx) throw new Error('useGlobal must be inside GlobalProvider')
return ctx
}
export const GlobalProvider = ({
children,
value
}: {
children: ReactNode
value: GlobalCtxType
}) => {
return <GlobalCtx.Provider value={value}>{children}</GlobalCtx.Provider>
}
+60
View File
@@ -0,0 +1,60 @@
@import "tailwindcss";
.dropdown {
@apply relative w-40;
}
.dropdown-btn {
@apply w-full flex items-center justify-between px-2 py-1 rounded-md cursor-pointer
bg-(--col2) hover:bg-(--col3)
border border-(--col4) hover:border-(--col5)
text-white;
}
.dropdown-arrow {
@apply w-3 h-3 transition-transform stroke-current stroke-2 fill-none;
}
.dropdown-arrow.open {
@apply rotate-180;
}
.dropdown-menu {
@apply absolute z-50 mt-1 w-full rounded-md shadow-2xl overflow-hidden
bg-(--col2) border border-(--col4)
origin-top
opacity-0 scale-95 -translate-y-1 pointer-events-none
transition-[opacity,transform] duration-150 ease-out;
}
.dropdown-menu.open {
@apply opacity-100 scale-100 translate-y-0 pointer-events-auto;
}
.dropdown-item {
@apply px-2 py-1 cursor-pointer text-white transition-colors border-b border-(--col4) last:border-b-0;
opacity: 0;
transform: translateY(-10px);
}
.dropdown-item:hover,
.dropdown-item.active {
@apply bg-(--col3);
}
.dropdown-menu.open .dropdown-item {
animation: dropIn 220ms ease-out forwards;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes dropIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+44 -15
View File
@@ -1,5 +1,9 @@
@import "tailwindcss"; @import "tailwindcss";
html {
overscroll-behavior: none;
}
body { body {
@apply bg-(--col0) text-white select-none; @apply bg-(--col0) text-white select-none;
} }
@@ -14,6 +18,7 @@ body {
--col6: rgb(96, 96, 96); --col6: rgb(96, 96, 96);
--col7: rgb(112, 112, 112); --col7: rgb(112, 112, 112);
--col8: rgb(128, 128, 128); --col8: rgb(128, 128, 128);
--col9: rgb(144, 144, 144);
} }
.red-theme { .red-theme {
@@ -26,6 +31,7 @@ body {
--col6: rgb(120, 96, 96); --col6: rgb(120, 96, 96);
--col7: rgb(136, 112, 112); --col7: rgb(136, 112, 112);
--col8: rgb(152, 128, 128); --col8: rgb(152, 128, 128);
--col9: rgb(168, 144, 144);
} }
.blue-theme { .blue-theme {
@@ -38,6 +44,7 @@ body {
--col6: rgb(96, 96, 120); --col6: rgb(96, 96, 120);
--col7: rgb(112, 112, 136); --col7: rgb(112, 112, 136);
--col8: rgb(128, 128, 152); --col8: rgb(128, 128, 152);
--col9: rgb(144, 144, 168);
} }
.purple-theme { .purple-theme {
@@ -50,9 +57,11 @@ body {
--col6: rgb(108, 96, 120); --col6: rgb(108, 96, 120);
--col7: rgb(124, 112, 136); --col7: rgb(124, 112, 136);
--col8: rgb(140, 128, 152); --col8: rgb(140, 128, 152);
--col9: rgb(156, 144, 168);
} }
.button { .button,
.input-field {
@apply rounded-md cursor-pointer text-[16px] py-1 px-2 transition-colors border; @apply rounded-md cursor-pointer text-[16px] py-1 px-2 transition-colors border;
} }
@@ -61,17 +70,29 @@ body {
} }
.btntheme2, .btntheme2,
.downloads-entry .entry-info-item, .main-container .main-entry .entry-info-item,
.downloads-entry .button { .main-container .main-entry .button {
@apply bg-(--col3) border-(--col5) hover:bg-(--col5) hover:border-(--col7) disabled:text-gray-300 disabled:bg-(--col2) disabled:border-(--col6) disabled:hover:text-gray-100 disabled:hover:bg-(--col3) disabled:hover:border-(--col7); @apply bg-(--col3) border-(--col5) hover:bg-(--col5) hover:border-(--col7) disabled:text-gray-300 disabled:bg-(--col2) disabled:border-(--col6) disabled:hover:text-gray-100 disabled:hover:bg-(--col3) disabled:hover:border-(--col7);
} }
.btntheme3, .btntheme3,
.downloads-entry:hover .entry-info-item, .main-container .main-entry:hover .entry-info-item,
.downloads-entry:hover .button { .main-container .main-entry:hover .button {
@apply bg-(--col4) border-(--col6) hover:bg-(--col6) hover:border-(--col8) disabled:text-gray-300 disabled:bg-(--col2) disabled:border-(--col7) disabled:hover:text-gray-100 disabled:hover:bg-(--col4) disabled:hover:border-(--col8); @apply bg-(--col4) border-(--col6) hover:bg-(--col6) hover:border-(--col8) disabled:text-gray-300 disabled:bg-(--col2) disabled:border-(--col7) disabled:hover:text-gray-100 disabled:hover:bg-(--col4) disabled:hover:border-(--col8);
} }
.input-field.btntheme1 {
@apply focus:bg-(--col4) focus:border-(--col6);
}
.input-field.btntheme2 {
@apply focus:bg-(--col5) focus:border-(--col7);
}
.input-field.btntheme3 {
@apply focus:bg-(--col6) focus:border-(--col8);
}
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-2; @apply w-2;
} }
@@ -102,6 +123,18 @@ body {
} }
} }
.main-container {
@apply bg-(--col1) border border-(--col3) rounded-lg overflow-y-auto w-full;
}
.main-container.main-flex {
@apply flex flex-col gap-2 p-2;
}
.main-container .main-entry {
@apply flex justify-between items-center p-2 rounded-lg text-gray-200 text-lg transition-colors bg-(--col2) hover:bg-(--col3) border border-(--col4) hover:border-(--col5) cursor-pointer;
}
.popup-overlay { .popup-overlay {
@apply fixed w-screen h-screen z-99999 flex justify-center items-center animate-[fadeIn_0.2s_ease-out_forwards] left-0 top-0 bg-[rgba(0,0,0,0.5)]; @apply fixed w-screen h-screen z-99999 flex justify-center items-center animate-[fadeIn_0.2s_ease-out_forwards] left-0 top-0 bg-[rgba(0,0,0,0.5)];
} }
@@ -122,22 +155,18 @@ body {
@apply relative h-fit bg-(--col3) m-2 p-2 rounded-lg border border-(--col5); @apply relative h-fit bg-(--col3) m-2 p-2 rounded-lg border border-(--col5);
} }
.popup-entry button { .popup-top-button {
@apply absolute; @apply flex justify-center items-center absolute text-2xl text-gray-200 hover:text-white cursor-pointer h-12 w-12 p-3 rounded-lg top-2 transition-colors border;
}
.close-button {
@apply flex justify-center items-center absolute text-2xl text-gray-200 hover:text-white cursor-pointer h-12 w-12 p-3 rounded-lg left-2 top-2 transition-colors border;
} }
*:focus { *:focus {
@apply outline-none; @apply outline-none;
} }
.input-field {
@apply border-2 border-(--col4) rounded-md bg-(--col2) py-1 px-2 focus:bg-(--col3) focus:border-(--col6) transition-colors w-full;
}
.entry-info-item { .entry-info-item {
@apply flex flex-row items-center gap-1 border text-gray-300 py-1 px-2 rounded-md w-fit text-[16px] transition-colors cursor-pointer; @apply flex flex-row items-center gap-1 border text-gray-300 py-1 px-2 rounded-md w-fit text-[16px] transition-colors cursor-pointer;
} }
code {
@apply bg-(--col4) border border-(--col6) rounded-lg px-1 py-0.5 text-sm;
}
@@ -5,13 +5,13 @@
} }
.setting-checkbox { .setting-checkbox {
@apply appearance-none w-full h-full border-2 border-(--col4) rounded-md bg-(--col2) transition-colors cursor-pointer; @apply appearance-none w-full h-full border border-(--col4) rounded-sm bg-(--col2) transition-colors cursor-pointer;
} }
.setting-checkbox:checked { .setting-checkbox:checked {
@apply bg-(--col4) border-(--col6); @apply bg-(--col4) border-(--col6);
} }
.fa-check-icon { .setting-checkbox-wrapper svg {
@apply absolute top-1/2 left-1/2 text-white text-[11px] pointer-events-none -translate-x-2/4 -translate-y-2/4; @apply absolute top-1/2 left-1/2 text-white text-[12px] pointer-events-none -translate-x-2/4 -translate-y-2/4;
} }
@@ -5,7 +5,7 @@
} }
.sidebar-downloads { .sidebar-downloads {
@apply text-[#bdbdbd] fixed bottom-2 left-2 bg-(--col2) rounded-lg border border-(--col3) px-4 py-3 text-xl cursor-pointer transition-colors duration-[0.25s] opacity-75; @apply text-[#bdbdbd] fixed bottom-2 left-2 bg-(--col2) rounded-lg border border-(--col3) px-4 py-3 text-xl cursor-pointer transition-colors opacity-75;
} }
.sidebar-downloads:hover { .sidebar-downloads:hover {
@@ -13,16 +13,12 @@
@apply bg-(--col3) border-(--col4); @apply bg-(--col3) border-(--col4);
} }
.logo {
@apply flex items-center pl-2 h-10 w-60 pt-2;
}
.nav-links { .nav-links {
@apply flex flex-col px-4 space-y-1; @apply flex flex-col px-4 space-y-1 pt-3;
} }
.link { .link {
@apply text-[#bdbdbd] p-2 rounded-md no-underline cursor-pointer transition-colors duration-[0.25s] border border-transparent; @apply text-[#bdbdbd] p-2 rounded-md no-underline cursor-pointer transition-colors border border-transparent;
} }
.link.active { .link.active {
+27
View File
@@ -0,0 +1,27 @@
export class DownloadProgress {
constructor (
public version: string,
public progress: number,
public progressBytes: number,
public failed: boolean,
public downloading: boolean,
public paused: boolean,
public canceled: boolean,
public queued: boolean,
public hash_checking: boolean,
public speed: number,
public size: number,
public etaSecs: number,
public unzipping: boolean,
public unzipTotal: number,
public unzipped: number,
public url: string | null,
public executable: string | null,
public hash: string | null,
public type: 0 | 1 | 2,
public modDownload: number | null,
public modGame: number | null,
public modId: number | null,
public modVersion: string | null
) {}
}
File renamed without changes.
@@ -3,13 +3,12 @@ export interface GameVersion {
displayName: string displayName: string
releaseDate: number releaseDate: number
game: number game: number
downloadUrl: string
executable: string
sha512sum: string
size: number
place: number place: number
changelog: string changelog: string
wine: number | undefined wine: number | undefined
category: number category: number
lastRevision: number lastRevision: number
download: number | undefined
executable: string
modSupportDownload: number | undefined
} }
+12
View File
@@ -0,0 +1,12 @@
export interface Mod {
id: number
name: string
description: string | null
creators: string[]
downloads: number
released: number
updated: number
latestVersion: string
latestDownload: number
changelog: string | null
}
File renamed without changes.
@@ -4,4 +4,5 @@ export type SettingProps = {
onChange: (val: boolean) => void onChange: (val: boolean) => void
className?: string className?: string
title?: string title?: string
disabled?: boolean
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "es2024",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,